diff --git a/e2e/browser/first-run.test.ts b/e2e/browser/first-run.test.ts index ed43475735..c2ec564a54 100644 --- a/e2e/browser/first-run.test.ts +++ b/e2e/browser/first-run.test.ts @@ -1,20 +1,115 @@ -// 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 { test } from "bun:test"; +// A fresh user signs up in a real browser, connects a placeholder provider, +// and creates three benches from /new: each is its own child tenant with +// exactly one agent, the sidebar lists them by short title, and no bench's +// thread shows another's opening message. One test, because a third hub boot +// in the same `bun test` process hangs its deploys. Inference is not +// scripted in this harness, so the agent's self-naming reply is not covered. +import { expect, test } from "bun:test"; +import type { Page } from "puppeteer-core"; import { bootBrowserApp, browserGate } from "../lib/browser"; -import { runFirstRunFlow } from "../lib/first-run"; +import { clickText, STEP_TIMEOUT, waitForText } from "../lib/first-run"; const describeBrowser = browserGate(import.meta.path); +const MESSAGES = [ + "Summarize what shipped this week across every repository we track, grouped by team", + "Draft a launch checklist for the onboarding redesign including owners and due dates", + "Research competing pricing pages and list the three patterns we should borrow first", +] as const; + +const VIEWPORT = { width: 1400, height: 900 }; + +type Principal = { tenantId: string; kind: string; status: string }; +type Tenant = { id: string; parentId: string | null }; + +async function hubJson(page: Page, url: string): Promise { + return (await page.evaluate(`fetch(${JSON.stringify(url)}).then((r) => r.json())`)) as T; +} + +async function signUp(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]"); + 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"); + await clickText(page, "button", "Start your first workbench"); +} + +async function createBench(page: Page, message: string): Promise { + await page.waitForSelector("textarea", { timeout: STEP_TIMEOUT }); + await page.type("textarea", message); + await page.click("button[aria-label='Start this workbench']"); + await page.waitForFunction(`location.pathname.startsWith("/w/")`, { timeout: STEP_TIMEOUT }); + await waitForText(page, message); + return new URL(page.url()).pathname.slice("/w/".length); +} + +async function runFlow(page: Page, origin: string): Promise { + await signUp(page, origin); + await page.setViewport(VIEWPORT); + const ids: string[] = []; + for (const [i, message] of MESSAGES.entries()) { + if (i > 0) await page.goto(`${origin}/new`, { waitUntil: "networkidle0" }); + ids.push(await createBench(page, message)); + } + expect(new Set(ids).size).toBe(3); + + // (a) three child tenants under the one workspace, via stock routes. + const principals = await hubJson<{ data: Principal[] }>(page, "/api/me/principals"); + const tenantIds = principals.data + .filter((p) => p.kind === "user" && p.status === "active") + .map((p) => p.tenantId); + const tenants = await Promise.all( + tenantIds.map((id) => hubJson(page, `/api/tenants/${id}`)), + ); + const workspace = tenants.find((t) => t.parentId === null || t.parentId === undefined); + expect(workspace).toBeDefined(); + const children = tenants.filter((t) => t.parentId === workspace?.id); + expect(children.map((t) => t.id).sort()).toEqual([...ids].sort()); + + // (b) each bench deploys exactly one agent (one definition asset). + for (const id of ids) { + const deployments = await hubJson<{ definitionAssetId: string }[]>( + page, + `/api/tenants/${id}/workflows/deployments`, + ); + expect(new Set(deployments.map((d) => d.definitionAssetId)).size).toBe(1); + } + + // (c) sidebar: three bench rows with short titles, plus the worker group. + await page.goto(`${origin}/w/${ids[0]}`, { waitUntil: "networkidle0" }); + await waitForText(page, "Workers"); + const rows = (await page.evaluate( + `Array.from(document.querySelectorAll(".panel-stack-group")).map((g) => ({ label: g.querySelector(".shell-panel-section-label")?.textContent, names: Array.from(g.querySelectorAll(".shell-ch-name")).map((n) => n.textContent) }))`, + )) as { label: string; names: string[] }[]; + const benches = rows.find((g) => g.label === "Workbenches")?.names ?? []; + expect(benches).toHaveLength(3); + for (const name of benches) expect(name.length).toBeLessThanOrEqual(40); + expect(rows.find((g) => g.label === "Workers")?.names.length).toBeGreaterThanOrEqual(1); + + // (d) no bench shows another's opening message. + for (const [i, id] of ids.entries()) { + await page.goto(`${origin}/w/${id}`, { waitUntil: "networkidle0" }); + await waitForText(page, MESSAGES[i] as string); + const text = (await page.evaluate("document.body.innerText")) as string; + for (const [j, other] of MESSAGES.entries()) { + if (j !== i) expect(text.includes(other)).toBe(false); + } + } +} -// 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 () => { + test("sign up, connect a provider, create three isolated benches", async () => { const { page, errors } = await app().newPage(); try { - await runFirstRunFlow(page, app().origin); + await runFlow(page, app().origin); } catch (cause) { const body = await page.evaluate("document.body.innerText"); process.stderr.write( @@ -23,5 +118,5 @@ describeBrowser("first run", () => { throw cause; } await page.close(); - }, 240_000); + }, 420_000); }); diff --git a/e2e/grants/grants-not-walkable.test.ts b/e2e/grants/grants-not-walkable.test.ts index 8415cfeb8f..1fe9d7c52d 100644 --- a/e2e/grants/grants-not-walkable.test.ts +++ b/e2e/grants/grants-not-walkable.test.ts @@ -2,7 +2,7 @@ // from an ancestor tenant, grants never do. Booting the hub runs package // migrations, so the suite skips without a reachable DATABASE_URL. -import { expect, test } from "bun:test"; +import { afterAll, expect, test } from "bun:test"; import { type } from "arktype"; import { dbGate } from "../lib/db-gate"; import { installDisposableHubDataDir } from "../lib/disposable-hub-data-dir"; @@ -23,6 +23,13 @@ if (databaseUrl !== "") { process.env["DB_NAME"] = url.pathname.replace(/^\//, ""); } +// A hub left running keeps reconciling the shared database and fails the +// allocations of every hub booted after it in the same process. +const closers: (() => Promise)[] = []; +afterAll(async () => { + for (const close of closers) await close(); +}); + const Id = type({ id: "string" }); const GrantPage = type({ data: type({ id: "string" }).array() }); const Evaluation = type({ effect: "string", matchingGrants: type({ id: "string" }).array() }); @@ -31,6 +38,7 @@ describeIfDb("grants are explicit per tenant", () => { test("a credential resolves from a child tenant; a grant does not", async () => { const { createHubServer } = await import("../../apps/hub/src/server"); const hub = await createHubServer(); + closers.push(() => hub.shutdown()); const origin = "http://localhost"; const suffix = crypto.randomUUID().slice(0, 8); diff --git a/e2e/hub/composition.test.ts b/e2e/hub/composition.test.ts index a4dc7b2dba..deb4ebd8c4 100644 --- a/e2e/hub/composition.test.ts +++ b/e2e/hub/composition.test.ts @@ -43,6 +43,7 @@ describeIfDb("boot", () => { test("serves stock platform routes", async () => { const { createHubServer } = await import("../../apps/hub/src/server"); const opts = await createHubServer(); + closers.push(() => opts.shutdown()); const status = await opts.fetch(new Request("http://localhost/status")); expect(status.status).toBe(200); @@ -54,6 +55,7 @@ describeIfDb("boot", () => { test("a Corbits mount (mailbox) sits inside the native tenant middleware", async () => { const { createHubServer } = await import("../../apps/hub/src/server"); const opts = await createHubServer(); + closers.push(() => opts.shutdown()); // Anonymous request to a Corbits-mounted route: the platform's tenant // middleware answers 401 before the mailbox library's own handler runs.