From eec33773b1a7f16c6cd93e7551af444e7cf31d1b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 30 Sep 2026 07:58:19 -0700 Subject: [PATCH 1/3] test(e2e): a manual trigger is accepted and the run is listed (CL-9519) --- e2e/browser/first-run.test.ts | 51 ++----------------------------- e2e/browser/run-now.test.ts | 57 +++++++++++++++++++++++++++++++++++ e2e/lib/first-run.ts | 50 ++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 48 deletions(-) create mode 100644 e2e/browser/run-now.test.ts create mode 100644 e2e/lib/first-run.ts diff --git a/e2e/browser/first-run.test.ts b/e2e/browser/first-run.test.ts index 7eeb9dd03..ed4347573 100644 --- a/e2e/browser/first-run.test.ts +++ b/e2e/browser/first-run.test.ts @@ -1,56 +1,11 @@ // A fresh user signs up in a real browser, the client sets up its workspace // over stock routes, connects a placeholder provider, and creates a first // workbench that shows up in the sidebar. Asserts on visible UI only. -import { expect, test } from "bun:test"; -import type { Page } from "puppeteer-core"; +import { test } from "bun:test"; import { bootBrowserApp, browserGate } from "../lib/browser"; +import { runFirstRunFlow } from "../lib/first-run"; const describeBrowser = browserGate(import.meta.path); -const STEP_TIMEOUT = 60_000; -const FIRST_WORKBENCH = "Summarize what shipped this week"; - -// Page scripts are strings: this tsconfig has no DOM lib. -async function waitForText(page: Page, label: string): Promise { - await page.waitForFunction(`document.body.innerText.includes(${JSON.stringify(label)})`, { - timeout: STEP_TIMEOUT, - }); -} - -async function clickText(page: Page, selector: string, label: string): Promise { - const script = `Array.from(document.querySelectorAll(${JSON.stringify(selector)})).find((e) => e.textContent.includes(${JSON.stringify(label)}))`; - await page.waitForFunction(script, { timeout: STEP_TIMEOUT }); - await page.evaluate(`(${script}).click()`); -} - -async function runFlow(page: Page, origin: string): Promise { - await page.goto(origin, { waitUntil: "networkidle0" }); - - await clickText(page, "button", "Create an account"); - await waitForText(page, "Create your account"); - await page.type("input[type=email]", `alice+${String(Date.now())}@example.com`); - await page.type("input[type=password]", "correct-horse-battery-staple"); - await page.click("button[type=submit]"); - - // Provider connect is the only onboarding step; a placeholder key is - // enough because connecting stores a credential without calling the provider. - await waitForText(page, "Connect a brain"); - await clickText(page, "label", "Anthropic"); - await page.waitForSelector("input[type=password]"); - await page.type("input[type=password]", "sk-ant-placeholder"); - await clickText(page, "button", "Connect"); - - // Connecting installs Myra, then the ready screen hands off to the - // new-workbench prompt (a workspace with no workbenches). - await clickText(page, "button", "Start your first workbench"); - await page.waitForSelector("textarea", { timeout: STEP_TIMEOUT }); - await page.type("textarea", FIRST_WORKBENCH); - await page.click("button[aria-label='Start this workbench']"); - - // Creating the workbench navigates to its own page, which shows the prompt. - await page.waitForFunction(`location.pathname.startsWith("/w/")`, { timeout: STEP_TIMEOUT }); - await waitForText(page, FIRST_WORKBENCH); - expect(new URL(page.url()).pathname).toStartWith("/w/"); -} // The test database outlives a run, so each run signs up a distinct alice. describeBrowser("first run", () => { @@ -59,7 +14,7 @@ describeBrowser("first run", () => { test("sign up, connect a provider, create a first workbench", async () => { const { page, errors } = await app().newPage(); try { - await runFlow(page, app().origin); + await runFirstRunFlow(page, app().origin); } catch (cause) { const body = await page.evaluate("document.body.innerText"); process.stderr.write( diff --git a/e2e/browser/run-now.test.ts b/e2e/browser/run-now.test.ts new file mode 100644 index 000000000..22813a454 --- /dev/null +++ b/e2e/browser/run-now.test.ts @@ -0,0 +1,57 @@ +// A manual trigger through the stock trigger route (what the Routines page's +// Run now calls) answers 202 and the run shows up in the stock runs listing. +// The harness has no scripted inference and no deployed non-agent workflow, +// so Myra's own deployment is the only thing triggerable and the brief + +// artifact half is not asserted. +import { expect, test } from "bun:test"; +import { bootBrowserApp, browserGate } from "../lib/browser"; +import { runFirstRunFlow } from "../lib/first-run"; + +const describeBrowser = browserGate(import.meta.path); + +// Runs in the page so the signed-in session cookie applies. +const TRIGGER_SCRIPT = `(async () => { + const bench = decodeURIComponent(location.pathname.split("/")[2]); + const json = async (r) => ({ status: r.status, body: await r.json().catch(() => null) }); + const benchTenant = await json(await fetch("/api/tenants/" + bench)); + const tenants = [bench, benchTenant.body?.parentId].filter(Boolean); + for (const tenantId of tenants) { + const listed = await json(await fetch("/api/tenants/" + tenantId + "/workflows/deployments")); + const deployment = (listed.body ?? []).find((d) => d.status === "deployed"); + if (!deployment) continue; + const base = "/api/tenants/" + tenantId + "/workflows/"; + const trigger = await json(await fetch(base + encodeURIComponent(deployment.id) + "/mail", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ content: "Run now" }), + })); + const runs = await json(await fetch(base + "runs?limit=50")); + return { tenantId, deploymentId: deployment.id, trigger, runs }; + } + return { tenants }; +})()`; + +describeBrowser("run now", () => { + const app = bootBrowserApp(); + + test("a manual trigger is accepted and the run is listed", async () => { + const { page, errors } = await app().newPage(); + try { + await runFirstRunFlow(page, app().origin); + const result = (await page.evaluate(TRIGGER_SCRIPT)) as { + deploymentId?: string; + trigger?: { status: number; body: { runId?: string } | null }; + runs?: { body: unknown }; + }; + process.stderr.write(`${JSON.stringify(result)}\n`); + expect(result.deploymentId).toBeDefined(); + expect(result.trigger?.status).toBe(202); + expect(typeof result.trigger?.body?.runId).toBe("string"); + expect(JSON.stringify(result.runs?.body)).toContain(result.trigger?.body?.runId ?? ""); + } catch (cause) { + process.stderr.write(`run-now failed at ${page.url()}\nconsole: ${errors.join(" | ")}\n`); + throw cause; + } + await page.close(); + }, 240_000); +}); diff --git a/e2e/lib/first-run.ts b/e2e/lib/first-run.ts new file mode 100644 index 000000000..4c02d448b --- /dev/null +++ b/e2e/lib/first-run.ts @@ -0,0 +1,50 @@ +// Shared browser steps for e2e: sign up, connect a placeholder provider, and +// create a first workbench. +import { expect } from "bun:test"; +import type { Page } from "puppeteer-core"; + +export const STEP_TIMEOUT = 60_000; +export const FIRST_WORKBENCH = "Summarize what shipped this week"; + +// Page scripts are strings: this tsconfig has no DOM lib. +export async function waitForText(page: Page, label: string): Promise { + await page.waitForFunction(`document.body.innerText.includes(${JSON.stringify(label)})`, { + timeout: STEP_TIMEOUT, + }); +} + +export async function clickText(page: Page, selector: string, label: string): Promise { + const script = `Array.from(document.querySelectorAll(${JSON.stringify(selector)})).find((e) => e.textContent.includes(${JSON.stringify(label)}))`; + await page.waitForFunction(script, { timeout: STEP_TIMEOUT }); + await page.evaluate(`(${script}).click()`); +} + +export async function runFirstRunFlow(page: Page, origin: string): Promise { + await page.goto(origin, { waitUntil: "networkidle0" }); + + await clickText(page, "button", "Create an account"); + await waitForText(page, "Create your account"); + await page.type("input[type=email]", `alice+${String(Date.now())}@example.com`); + await page.type("input[type=password]", "correct-horse-battery-staple"); + await page.click("button[type=submit]"); + + // Provider connect is the only onboarding step; a placeholder key is + // enough because connecting stores a credential without calling the provider. + await waitForText(page, "Connect a brain"); + await clickText(page, "label", "Anthropic"); + await page.waitForSelector("input[type=password]"); + await page.type("input[type=password]", "sk-ant-placeholder"); + await clickText(page, "button", "Connect"); + + // Connecting installs Myra, then the ready screen hands off to the + // new-workbench prompt (a workspace with no workbenches). + await clickText(page, "button", "Start your first workbench"); + await page.waitForSelector("textarea", { timeout: STEP_TIMEOUT }); + await page.type("textarea", FIRST_WORKBENCH); + await page.click("button[aria-label='Start this workbench']"); + + // Creating the workbench navigates to its own page, which shows the prompt. + await page.waitForFunction(`location.pathname.startsWith("/w/")`, { timeout: STEP_TIMEOUT }); + await waitForText(page, FIRST_WORKBENCH); + expect(new URL(page.url()).pathname).toStartWith("/w/"); +} From 9114447a0e98cbaa64215f8ca613d1f4066bb2f7 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 30 Sep 2026 08:02:21 -0700 Subject: [PATCH 2/3] test(e2e): wait for Myra's deployment before triggering (CL-9519) --- e2e/browser/run-now.test.ts | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/e2e/browser/run-now.test.ts b/e2e/browser/run-now.test.ts index 22813a454..7eb2234d1 100644 --- a/e2e/browser/run-now.test.ts +++ b/e2e/browser/run-now.test.ts @@ -15,20 +15,24 @@ const TRIGGER_SCRIPT = `(async () => { const json = async (r) => ({ status: r.status, body: await r.json().catch(() => null) }); const benchTenant = await json(await fetch("/api/tenants/" + bench)); const tenants = [bench, benchTenant.body?.parentId].filter(Boolean); - for (const tenantId of tenants) { - const listed = await json(await fetch("/api/tenants/" + tenantId + "/workflows/deployments")); - const deployment = (listed.body ?? []).find((d) => d.status === "deployed"); - if (!deployment) continue; - const base = "/api/tenants/" + tenantId + "/workflows/"; - const trigger = await json(await fetch(base + encodeURIComponent(deployment.id) + "/mail", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ content: "Run now" }), - })); - const runs = await json(await fetch(base + "runs?limit=50")); - return { tenantId, deploymentId: deployment.id, trigger, runs }; + // Myra deploys asynchronously after setup, so poll for the deployment. + for (let attempt = 0; attempt < 60; attempt++) { + for (const tenantId of tenants) { + const listed = await json(await fetch("/api/tenants/" + tenantId + "/workflows/deployments")); + const deployment = (Array.isArray(listed.body) ? listed.body : []).find((d) => d.status === "deployed"); + if (!deployment) continue; + const base = "/api/tenants/" + tenantId + "/workflows/"; + const trigger = await json(await fetch(base + encodeURIComponent(deployment.id) + "/mail", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ content: "Run now" }), + })); + const runs = await json(await fetch(base + "runs?limit=50")); + return { tenantId, deploymentId: deployment.id, trigger, runs }; + } + await new Promise((r) => setTimeout(r, 1000)); } - return { tenants }; + return { tenants, lastListing: null }; })()`; describeBrowser("run now", () => { From 7c159f587a52c09785c2cf7337e7093fba92c5c1 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 30 Sep 2026 08:06:16 -0700 Subject: [PATCH 3/3] test(e2e): log the deployment listing when none appears (CL-9519) --- e2e/browser/run-now.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/e2e/browser/run-now.test.ts b/e2e/browser/run-now.test.ts index 7eb2234d1..fbd52dd28 100644 --- a/e2e/browser/run-now.test.ts +++ b/e2e/browser/run-now.test.ts @@ -16,10 +16,12 @@ const TRIGGER_SCRIPT = `(async () => { const benchTenant = await json(await fetch("/api/tenants/" + bench)); const tenants = [bench, benchTenant.body?.parentId].filter(Boolean); // Myra deploys asynchronously after setup, so poll for the deployment. + let lastListing = null; for (let attempt = 0; attempt < 60; attempt++) { for (const tenantId of tenants) { const listed = await json(await fetch("/api/tenants/" + tenantId + "/workflows/deployments")); const deployment = (Array.isArray(listed.body) ? listed.body : []).find((d) => d.status === "deployed"); + lastListing = { tenantId, status: listed.status, body: listed.body }; if (!deployment) continue; const base = "/api/tenants/" + tenantId + "/workflows/"; const trigger = await json(await fetch(base + encodeURIComponent(deployment.id) + "/mail", { @@ -32,7 +34,7 @@ const TRIGGER_SCRIPT = `(async () => { } await new Promise((r) => setTimeout(r, 1000)); } - return { tenants, lastListing: null }; + return { tenants, lastListing }; })()`; describeBrowser("run now", () => { @@ -48,6 +50,8 @@ describeBrowser("run now", () => { runs?: { body: unknown }; }; process.stderr.write(`${JSON.stringify(result)}\n`); + if (result.deploymentId === undefined) + process.stderr.write(`NO-DEPLOYMENT ${JSON.stringify(result)}\n`); expect(result.deploymentId).toBeDefined(); expect(result.trigger?.status).toBe(202); expect(typeof result.trigger?.body?.runId).toBe("string");