Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 104 additions & 9 deletions e2e/browser/first-run.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(page: Page, url: string): Promise<T> {
return (await page.evaluate(`fetch(${JSON.stringify(url)}).then((r) => r.json())`)) as T;
}

async function signUp(page: Page, origin: string): Promise<void> {
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<string> {
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<void> {
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<Tenant>(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(
Expand All @@ -23,5 +118,5 @@ describeBrowser("first run", () => {
throw cause;
}
await page.close();
}, 240_000);
}, 420_000);
});
10 changes: 9 additions & 1 deletion e2e/grants/grants-not-walkable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<void>)[] = [];
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() });
Expand All @@ -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);

Expand Down
2 changes: 2 additions & 0 deletions e2e/hub/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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.
Expand Down
Loading