From 20e527402c77aacff8ba779957a8496a84c842df Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 29 Sep 2026 22:38:01 -0700 Subject: [PATCH 1/5] fix(e2e): browser harness serves the hub's sidecar WebSocket (CL-9567) --- e2e/lib/browser.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/e2e/lib/browser.ts b/e2e/lib/browser.ts index c5f5cedf7..5137a0aa3 100644 --- a/e2e/lib/browser.ts +++ b/e2e/lib/browser.ts @@ -81,13 +81,16 @@ export function bootBrowserApp(): () => BrowserApp { server = Bun.serve({ port: 0, fetch: () => new Response("booting", { status: 503 }) }); const origin = `http://localhost:${String(server.port)}`; process.env["BASE_URL"] = origin; + // The in-process sidecar dials the hub's own port back over WebSocket. + process.env["PORT"] = String(server.port); const { createHubServer } = await import("../../apps/hub/src/server"); const hub = await createHubServer(); server.reload({ - fetch: async (req: Request) => { + websocket: hub.websocket as Bun.WebSocketHandler, + fetch: async (req, srv) => { const { pathname } = new URL(req.url); - if (pathname.startsWith("/api") || pathname === "/status") return hub.fetch(req); + if (pathname.startsWith("/api") || pathname === "/status") return hub.fetch(req, srv); const file = Bun.file(path.join(DIST_DIR, pathname)); if (pathname !== "/" && (await file.exists())) return new Response(file); return new Response(Bun.file(path.join(DIST_DIR, "index.html"))); From cee35dd1e1d9c83129250898daa3975e5e7c48e6 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 29 Sep 2026 22:38:01 -0700 Subject: [PATCH 2/5] test(e2e): browser signup lands on a workspace and creates a first workbench (CL-9567) --- e2e/browser/first-run.test.ts | 71 +++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 e2e/browser/first-run.test.ts diff --git a/e2e/browser/first-run.test.ts b/e2e/browser/first-run.test.ts new file mode 100644 index 000000000..7fda7fff0 --- /dev/null +++ b/e2e/browser/first-run.test.ts @@ -0,0 +1,71 @@ +// 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 { bootBrowserApp, browserGate } from "../lib/browser"; + +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 model provider"); + 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"); + + // A workspace with no workbenches lands on the new-workbench prompt. + await waitForText(page, "What do you want your Workbench to do?"); + await page.type("textarea[placeholder^='What do you want']", FIRST_WORKBENCH); + await page.click("button[aria-label='Start this workbench']"); + + await page.waitForFunction( + `document.querySelector("[aria-label='Workbenches']")?.innerText.includes(${JSON.stringify(FIRST_WORKBENCH)})`, + { timeout: STEP_TIMEOUT }, + ); + expect(page.url()).not.toEndWith("/new"); +} + +// The test database outlives a run, so each run signs up a distinct alice. +describeBrowser("first run", () => { + const app = bootBrowserApp(); + + test("sign up, connect a provider, create a first workbench", async () => { + const { page, errors } = await app().newPage(); + try { + await runFlow(page, app().origin); + } catch (cause) { + const body = await page.evaluate("document.body.innerText"); + process.stderr.write( + `first-run failed at ${page.url()}:\n${String(body)}\nconsole: ${errors.join(" | ")}\n`, + ); + throw cause; + } + await page.close(); + }, 240_000); +}); From 44154936f1f96d7990f745935bb45a2ae8ba9606 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 29 Sep 2026 22:38:59 -0700 Subject: [PATCH 3/5] chore(ci): retrigger checks From f3501e37205fbf2a3030b26775da33090b98f674 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 29 Sep 2026 23:11:39 -0700 Subject: [PATCH 4/5] fix(e2e): follow current onboarding copy and always rebuild the web bundle (CL-9567) --- e2e/browser/first-run.test.ts | 6 +++--- e2e/lib/browser.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/e2e/browser/first-run.test.ts b/e2e/browser/first-run.test.ts index 7fda7fff0..aaae522e4 100644 --- a/e2e/browser/first-run.test.ts +++ b/e2e/browser/first-run.test.ts @@ -33,15 +33,15 @@ async function runFlow(page: Page, origin: string): Promise { // 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 model 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"); // A workspace with no workbenches lands on the new-workbench prompt. - await waitForText(page, "What do you want your Workbench to do?"); - await page.type("textarea[placeholder^='What do you want']", FIRST_WORKBENCH); + await waitForText(page, "What should this workbench do?"); + await page.type("textarea[aria-label='What should this workbench do?']", FIRST_WORKBENCH); await page.click("button[aria-label='Start this workbench']"); await page.waitForFunction( diff --git a/e2e/lib/browser.ts b/e2e/lib/browser.ts index 5137a0aa3..b5c43ac5b 100644 --- a/e2e/lib/browser.ts +++ b/e2e/lib/browser.ts @@ -44,8 +44,8 @@ export type BrowserApp = { newPage: () => Promise<{ page: Page; errors: string[] }>; }; +// Always rebuild: a leftover dist from an older checkout serves stale UI. async function ensureWebBuild(): Promise { - if (existsSync(path.join(DIST_DIR, "index.html"))) return; const proc = Bun.spawn(["bun", "run", "build"], { cwd: WEB_DIR, stdout: "inherit", From c69386c0c6bb7e7d13a707dd969fc1e32b06842b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 29 Sep 2026 23:19:52 -0700 Subject: [PATCH 5/5] fix(e2e): first-run follows the ready screen and workbench page (CL-9567) --- e2e/browser/first-run.test.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/e2e/browser/first-run.test.ts b/e2e/browser/first-run.test.ts index aaae522e4..7eeb9dd03 100644 --- a/e2e/browser/first-run.test.ts +++ b/e2e/browser/first-run.test.ts @@ -39,16 +39,17 @@ async function runFlow(page: Page, origin: string): Promise { await page.type("input[type=password]", "sk-ant-placeholder"); await clickText(page, "button", "Connect"); - // A workspace with no workbenches lands on the new-workbench prompt. - await waitForText(page, "What should this workbench do?"); - await page.type("textarea[aria-label='What should this workbench do?']", FIRST_WORKBENCH); + // 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']"); - await page.waitForFunction( - `document.querySelector("[aria-label='Workbenches']")?.innerText.includes(${JSON.stringify(FIRST_WORKBENCH)})`, - { timeout: STEP_TIMEOUT }, - ); - expect(page.url()).not.toEndWith("/new"); + // 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.