OpenWorker: initial import

Imported from andrewyng/aisuite@1b4bbf303e
(contents of its platform/ directory, hoisted to the repo root).
Development history prior to this commit lives in that repository.

Co-authored-by: Devika <devikaverma11@gmail.com>
This commit is contained in:
Rohit C Prasad
2026-07-21 11:09:41 -07:00
co-authored by Devika
commit 2b45018ffa
413 changed files with 93539 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
# E2E tests (Playwright)
End-to-end regression tests for the GUI. They drive the real app in Chromium but are **hermetic**:
every `/v1` request and the event WebSocket are mocked at the network layer, so tests need **no
Python backend**, run deterministically, and never mutate real state.
## Run
```bash
npm run e2e # headless
npm run e2e:ui # Playwright UI mode (watch/inspect)
npx playwright test e2e/settings.spec.ts # a single spec
```
## Live smoke (not CI)
`npm run e2e:live` runs `e2e-live/` (separate `playwright.live.config.ts`) against the **real**
backend on :8765. Two flavors, both skip cleanly when the backend is down:
- **API-shape smoke** (`api-smoke.spec.ts`) — no model tokens, no creds. Asserts `/v1/health` and
`/v1/providers` return the shapes the GUI reads, catching drift between the mocks and the real
backend. Cheap enough to run anytime the sidecar is up.
- **Full vertical** (`fib.spec.ts`, …) — asks a fresh Cowork session to produce `fib.md` and
verifies the file lands on disk. Needs a model configured, is nondeterministic, and costs a few
tokens per run. Exercises the vertical the hermetic specs mock: model wiring, the tool/approval
loop, file I/O, and WebSocket streaming.
The config (`playwright.config.ts`) starts the Vite dev server on port **5199** (dedicated, so it
won't clash with a running `npm run dev` on 5173) and reuses it if already up.
## How the mock works
`e2e/fixtures.ts` exports a `test` whose `page` has `mockApi()` installed before navigation:
- `page.route("**/v1/**", …)` dispatches by pathname + method to fixtures whose shapes mirror the
real backend (captured from a live server). Unknown endpoints return an empty-but-valid body.
- Mutations are held in per-test in-memory state so they reflect through the real UI on re-fetch:
sessions (archive/rename/delete), personas (enable/surface/delete — enable implies surface,
matching the backend), inbox items + the routing binding, roots, channel subscriptions.
- The session WebSocket (`routeWebSocket`) is a **scripted fake agent** speaking the real
`{type, data}` event protocol: `ready` on connect; `user_message``turn_start` → deltas →
`assistant_message "Echo: <text>"``turn_done`; a message containing **"run a tool"** emits
`tool_proposed` + `permission_required` and suspends until the client's `approval` decision
arrives. This runs the production send/stream/approve code paths with zero model cost.
- Seed data worth knowing: the pinned session "Draft the launch note" is the newest (boot-resume
target); 7 unpinned "Weekly plan N" cowork sessions exercise the sidebar peek cap; two pending
Inbox items (approval on cowork, question on ops) drive the Inbox filters; `acme-notes` is a
disabled non-builtin persona for enable/delete flows. Providers are seeded in three states
(OpenAI configured+used, Anthropic configured-unused, Z AI unconfigured w/ prefilled endpoint) —
`POST /v1/providers` flips `configured` on save, `/verify` fails on a key containing "bad". One
automation ("Daily AI News") with a running run — `POST .../run` appends a run, `PATCH`/`DELETE`
toggle and remove.
## Adding a spec
```ts
import { test, expect } from "./fixtures";
test("…", async ({ page }) => {
await page.goto("/");
// interact + assert
});
```
If a flow reads a new endpoint, add its fixture + a route branch in `fixtures.ts` — the catch-all
returns `{}`, which will crash components that expect arrays (e.g. persona `recommends`). Prefer
`getByRole`, but note some controls (the Sources bar, the ✕ remove) take their accessible name from
inner content — target those with `getByTitle`/`getByLabel`.
```
+96
View File
@@ -0,0 +1,96 @@
// The rail's Access section (§32 — absorbs the §23 Session-settings drawer; the topbar
// row/glance machinery is retired). Contract: the header carries a PERMANENT summary of what
// the session can touch; expanding edits inline at rail width (no overlay, no dialog).
// Fixture state: browser + slack + github connected/enabled (github is two_way WITHOUT
// channels — relay mentions, no subscriptions), gmail recommended-not-connected, one
// primary root → summary "Browser, Slack +1 · 1 folder".
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("no topbar opener; the Access header IS the ambient glance; expanding edits inline", async ({
page,
}) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
// §32: the settings row/icon is gone from the topbar — the panel toggle is the one entry.
await expect(page.getByRole("button", { name: "Open session settings" })).toHaveCount(0);
await expect(page.getByTestId("session-settings-row")).toHaveCount(0);
// The trust surface is ambient: the collapsed header always shows the summary — and no
// nudge text ever renders at rest (§23's rule carried over).
const section = page.getByTestId("access-section");
await expect(section.getByTestId("access-summary")).toHaveText("Browser, Slack +1 · 1 folder");
await expect(section.getByText(/recommended/i)).toHaveCount(0);
// Expand → Sources (per-session toggles), Recommended (with its reason), Folders — all
// inline in the rail; no dialog appears anywhere.
await section.getByTestId("access-toggle").click();
const body = page.getByRole("region", { name: "Session access" });
await expect(body.getByText("Sources")).toBeVisible();
await expect(body.getByText("Slack", { exact: true })).toBeVisible();
await expect(body.getByText("email context for morning summaries")).toBeVisible();
await expect(body.getByTestId("drawer-directories").getByText("Temporary space")).toBeVisible();
await expect(page.getByRole("dialog")).toHaveCount(0);
// Channels is a chat capability, not a two_way one: Slack gets the drill-down, GitHub
// (two_way via the relay, no channel semantics) must NOT (owner report 2026-07-13).
await expect(body.getByRole("button", { name: /Channels ·/ })).toHaveCount(1);
await expect(body.getByText("GitHub", { exact: true })).toBeVisible();
});
test("+ Add a source: full catalog on focus, filter as you type → connect-in-context; connected sources never match", async ({
page,
}) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
await page.getByTestId("access-toggle").click();
// Focusing the empty input shows the FULL catalog (FB-012) — every available connector
// minus the already-connected three, before any typing.
await page.getByTestId("access-add-source").click();
const search = page.getByTestId("access-add-search");
await expect(search).toBeFocused();
const rows = page.locator('[data-testid^="access-add-"]:not([data-testid="access-add-search"])');
await expect(rows).toHaveCount(9); // 12 in the catalog browser/slack/github (connected)
await expect(page.getByTestId("access-add-notion")).toBeVisible();
// Already-connected sources don't match (Slack and GitHub are connected in fixtures)…
await search.fill("slack");
await expect(page.getByText("No match — see all on the Connectors page below.")).toBeVisible();
await search.fill("github");
await expect(page.getByText("No match — see all on the Connectors page below.")).toBeVisible();
// …and clearing the query restores the full list ("filter as you type", not search-only).
await search.fill("");
await expect(rows).toHaveCount(9);
// Capability aliases match too: "calendar" surfaces Outlook (title alone never would).
await search.fill("calendar");
await expect(page.getByTestId("access-add-outlook")).toBeVisible();
// …the long tail does: Notion is in the catalog but neither connected nor recommended.
await search.fill("notion");
await page.getByTestId("access-add-notion").click();
// Lands in the SAME connect-in-context child view the Recommended flow uses, with the
// scope-semantics line; back returns to the Sources list.
const body = page.getByRole("region", { name: "Session access" });
await expect(body.getByText("Connecting makes Notion available to all your coworkers", { exact: false })).toBeVisible();
await expect(body.getByPlaceholder("ntn_…")).toBeVisible();
await body.getByRole("button", { name: "Back to sources" }).click();
await expect(body.getByText("Slack", { exact: true })).toBeVisible();
});
test("per-session mute round-trips; the summary follows", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const section = page.getByTestId("access-section");
await section.getByTestId("access-toggle").click();
const body = page.getByRole("region", { name: "Session access" });
// Muting Slack for this session drops it from the live summary (the fixture flips
// enabled on POST and the section reloads).
await body.getByTitle("Enabled for this session — tap to mute here").nth(1).click();
await expect(section.getByTestId("access-summary")).toHaveText("Browser, GitHub · 1 folder");
});
+78
View File
@@ -0,0 +1,78 @@
// The generic multi-account detail page (AccountsDetail) + the modal's generic
// one-click pane, exercised via Notion — the pattern all batch-2 connectors
// share (accounts.py layer: AccountRow shape, Default badge, per-account ×).
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
async function signInAndConnectFirstWorkspace(page) {
await openConnectors(page);
await page.getByTestId("account-row").click();
await page.getByTestId("account-sign-in").click();
await expect(page.getByTestId("account-row")).toContainText("Rohit", { timeout: 10_000 });
// Available row → modal with One click | Manual pills → generic one-click
await page
.getByTestId("connector-notion")
.getByRole("button", { name: "Connect", exact: true })
.click();
await expect(page.getByTestId("modal-pane-manual")).toBeVisible();
await page.getByTestId("modal-generic-one-click").click();
await page.keyboard.press("Escape");
await expect(page.getByTestId("connector-notion")).toContainText("Rohit's Workspace", {
timeout: 10_000,
});
}
test("one-click connect, add a second workspace from the page; first stays default", async ({
page,
}) => {
await signInAndConnectFirstWorkspace(page);
await page.getByTestId("connector-notion").click();
await expect(page.getByTestId("accounts-detail")).toBeVisible();
await page.getByTestId("add-account-btn").click();
const first = page.getByTestId("account-ws-1");
const second = page.getByTestId("account-ws-2");
await expect(second).toBeVisible({ timeout: 10_000 });
await expect(first).toContainText("Rohit's Workspace");
await expect(first).toContainText("Default");
await expect(second).not.toContainText("Default");
// list row summarizes the multi-account state
await page.getByTestId("connectors-breadcrumb").click();
await expect(page.getByTestId("connector-notion")).toContainText("2 accounts");
});
test("Make default moves the badge; disconnecting the default repoints it", async ({
page,
}) => {
await signInAndConnectFirstWorkspace(page);
await page.getByTestId("connector-notion").click();
await page.getByTestId("add-account-btn").click();
await expect(page.getByTestId("account-ws-2")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("account-make-default-ws-2").click();
await expect(page.getByTestId("account-ws-2")).toContainText("Default");
await expect(page.getByTestId("account-ws-1")).not.toContainText("Default");
await page.getByTestId("account-disconnect-ws-2").click();
await expect(page.getByTestId("account-ws-2")).toHaveCount(0);
await expect(page.getByTestId("account-ws-1")).toContainText("Default");
});
test("signed out: the modal's one-click pane offers inline cloud sign-in; manual pane has the token form", async ({
page,
}) => {
await openConnectors(page);
await page
.getByTestId("connector-notion")
.getByRole("button", { name: "Connect", exact: true })
.click();
await expect(page.getByTestId("inline-cloud-sign-in")).toBeVisible();
await page.getByTestId("modal-pane-manual").click();
await expect(page.getByPlaceholder("ntn_…")).toBeVisible();
});
+80
View File
@@ -0,0 +1,80 @@
// §35 (UX-018): approval cards speak the transcript's language. Routine workspace writes
// are a compact ROW (humanized title, inline args-preview, short "Always allow" with the
// full rule on hover); everything else is a full card — shell titles with the model's
// description, external actions wear the leaves-this-Mac note. No "PERMISSION REQUIRED"
// kicker, no raw args dump, no solid-fill buttons.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("routine write → compact row: humanized title, inline preview, Allow resolves", async ({
page,
}) => {
await page.goto("/");
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("please write a file");
await page.getByRole("button", { name: "Send" }).click();
const row = page.getByTestId("approval-row");
await expect(row).toContainText("Write fetch_data.py");
await expect(row).not.toContainText(/permission required/i);
await expect(row.getByRole("button", { name: "Always allow", exact: true })).toHaveAttribute(
"title",
/for this session/,
);
// Preview expands INLINE from the tool args — the file doesn't exist yet.
await row.getByText("preview ▾").click();
await expect(row).toContainText("import json");
await row.getByText("show all 6 lines").click();
await expect(row).toContainText("done = True");
await page.screenshot({ path: "test-results/ux018-compact-row.png", fullPage: false });
await row.getByRole("button", { name: "Allow", exact: true }).click();
await expect(page.getByText(/Done via write_file/)).toBeVisible();
});
test("run_shell → full card: description title, command preview, stays-on-this-Mac note", async ({
page,
}) => {
await page.goto("/");
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("please run a tool");
await page.getByRole("button", { name: "Send" }).click();
// The mocked proposal has no description → plain "Run a command" title; the command is
// the preview; the reason still renders; the scope note replaces the old badge.
await expect(page.getByText("Run a command").last()).toBeVisible();
await expect(page.getByText("stays on this Mac").last()).toBeVisible();
await expect(page.getByText("The coworker wants to run a command.").first()).toBeVisible();
await expect(page.getByRole("button", { name: "Always allow this command" }).last()).toBeVisible();
await expect(page.getByText(/local action/)).toHaveCount(0);
await page.screenshot({ path: "test-results/ux018-shell-card.png", fullPage: false });
await page.getByRole("button", { name: "Allow once" }).last().click();
await expect(page.getByText("The command ran; 1 file found.")).toBeVisible();
});
test("a one-paragraph digest send is clamped to a card, expandable in place", async ({
page,
}) => {
await page.goto("/");
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("post the long digest");
await page.getByRole("button", { name: "Send" }).click();
// The message rides in a clamped preview box — not an unbounded quote wall.
const prev = page.locator(".approval-prev");
await expect(prev).toBeVisible();
await expect(prev).toContainText("aisuite — last 24 hours");
const clampedHeight = (await prev.boundingBox())!.height;
expect(clampedHeight).toBeLessThan(200);
await page.screenshot({ path: "test-results/send-digest-clamped.png", fullPage: false });
// Expands in place, and can collapse back.
await prev.getByText("show the full message").click();
expect((await prev.boundingBox())!.height).toBeGreaterThan(clampedHeight);
await expect(prev.getByText("show less")).toBeVisible();
});
@@ -0,0 +1,52 @@
// Automations management — the parts of Rohit's manual pass that automations.spec.ts (run-banner +
// Back) doesn't cover: the task list, triggering a manual run (POST .../run appends a run and opens
// its live session), pausing via the enable toggle, and deleting. Seeded with one task.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openAutomations(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Automations", exact: true }).click();
await expect(page.getByText("Recurring tasks OpenWorker runs on a schedule.")).toBeVisible();
}
test("lists a scheduled task with its schedule and run count", async ({ page }) => {
await openAutomations(page);
const card = page.locator(".sched-card", { hasText: "Daily AI News" });
await expect(card).toBeVisible();
await expect(card).toContainText("Every day at ~5:40 PM");
await expect(card).toContainText("last running");
});
test("Run now triggers a manual run and opens its live session", async ({ page }) => {
await openAutomations(page);
await page.locator(".sched-card", { hasText: "Daily AI News" }).click();
await page.getByRole("button", { name: /Run now/ }).click();
// The manual run opens as a session with the automation-context banner.
const banner = page.getByTestId("run-banner");
await expect(banner).toBeVisible();
await expect(banner).toContainText("Daily AI News");
});
test("enable toggle pauses the task", async ({ page }) => {
await openAutomations(page);
await page.locator(".sched-card", { hasText: "Daily AI News" }).click();
await expect(page.getByText(/Active · next/)).toBeVisible();
// The checkbox is visually hidden behind a styled slider — click the label wrapper.
await page.locator("label.switch").click();
await expect(page.getByText("Paused", { exact: false })).toBeVisible();
});
test("delete removes the task; deleting the last one shows the empty state", async ({ page }) => {
await openAutomations(page);
await page.locator(".sched-card", { hasText: "Daily AI News" }).click();
await page.getByRole("button", { name: /Delete/ }).click();
// Back on the list, the deleted task is gone; the other seeded task remains.
await expect(page.locator(".sched-card", { hasText: "Daily AI News" })).toHaveCount(0);
await expect(page.locator(".sched-card", { hasText: "Weekly CRM digest" })).toHaveCount(1);
await page.locator(".sched-card", { hasText: "Weekly CRM digest" }).click();
await page.getByRole("button", { name: /Delete/ }).click();
await expect(page.getByText(/No scheduled tasks yet/)).toBeVisible();
});
@@ -0,0 +1,126 @@
// The Automations quickstart (UX-DECISIONS §29): ONE template system — the former onboarding
// recipe (role templates, connect rows, lazy cloud sign-in, §25 consent) merged into the page's
// "Start from a template" grid. Cards carry §27's connector-dot vocabulary; picking one expands
// the configure card. The `ob-*` testids moved here with the machinery.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openAutomations(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Automations", exact: true }).click();
await expect(page.getByText("Recurring tasks OpenWorker runs on a schedule.")).toBeVisible();
}
// The fixtures seed one task, so the quickstart isn't on the bare list — surface it via the
// "+ New automation" toggle (empty state shows it without the toggle; covered indirectly by
// the delete test in automations-manage.spec.ts).
async function openQuickstart(page) {
await openAutomations(page);
await page.getByRole("button", { name: "+ New automation" }).click();
await expect(page.getByText("Start from a template")).toBeVisible();
}
test("role recipe: connect rows, lazy single sign-in, channel by name, consent mints the grant", async ({
page,
}) => {
await openQuickstart(page);
// Pipeline digest: Slack is connected in fixtures, HubSpot isn't. No recipe form yet.
await page.getByTestId("qs-template-pipeline").click();
const cfg = page.getByTestId("qs-configure");
// §30: the card names its template — "SET UP · Pipeline digest" — instead of starting
// abruptly after the grid.
await expect(cfg).toContainText("Set up");
await expect(cfg).toContainText("Pipeline digest");
await expect(cfg.getByText("✓ Connected").first()).toBeVisible();
await expect(page.getByTestId("ob-recipe")).toHaveCount(0);
await expect(page.getByTestId("ob-create")).toBeDisabled();
await expect(page.getByTestId("ob-create-hint")).toContainText("Connect HubSpot");
// Connect HubSpot while signed out → the ONE cloud pane appears; signing in finishes the
// pending connect without another click.
await page.getByTestId("ob-connect-hubspot").click();
await expect(page.getByTestId("ob-cloudpane")).toBeVisible();
await page.getByTestId("ob-cloud-signin").click();
await expect(page.getByTestId("ob-recipe")).toBeVisible({ timeout: 15_000 });
// Connected but no channel → the gate names the missing piece (tester catch 2026-07-12).
await expect(page.getByTestId("ob-create-hint")).toContainText("Pick a channel");
// Channel picked BY NAME; §25 consent pre-checked; create lands on the task's detail with
// the standing grant listed.
const chan = page.locator('[data-testid="ob-channel"] input');
await chan.click();
await page.getByTestId("channel-suggestions").getByText("#ocw-test").click();
await expect(chan).toHaveValue("#ocw-test");
await expect(page.getByTestId("ob-consent")).toBeChecked();
await page.getByTestId("ob-create").click();
await expect(page.getByRole("button", { name: /Run now/ })).toBeVisible();
await expect(page.getByText("Pipeline digest").first()).toBeVisible();
await expect(page.getByTestId("task-grants")).toContainText("send_message");
});
test("connect narrates itself: Opening browser → waiting strip → Cancel restores the button", async ({
page,
}) => {
await openQuickstart(page);
// Sign in out-of-band so Connect goes straight to the broker flow (no cloud pane).
await page.evaluate(() => fetch("/v1/cloud/login", { method: "POST" }));
// Hold the connect POST open (§30's 45 s of dead air) and never flip the fixture's
// connected state — the waiting strip owns the gap until the user acts.
let release: (() => void) | undefined;
const held = new Promise<void>((r) => (release = r));
await page.route(/\/v1\/connectors\/hubspot\/connect-managed$/, async (route) => {
await held;
await route.fulfill({ json: { ok: true } });
});
await page.getByTestId("qs-template-pipeline").click();
// The mount refresh must land the signed-in status before Connect is clicked, or the
// click would open the sign-in pane instead of the broker flow.
await page.waitForResponse(/\/v1\/cloud\/status/);
await page.getByTestId("ob-connect-hubspot").click();
await expect(page.getByText("Opening browser…")).toBeVisible();
release!();
await expect(page.getByText("Waiting for HubSpot…")).toBeVisible();
await expect(page.getByTestId("ob-connect-wait")).toContainText(
"Finish connecting HubSpot in your browser",
);
// Cancel clears only the LOCAL waiting state — the Connect button returns.
await page.getByTestId("ob-connect-cancel").click();
await expect(page.getByTestId("ob-connect-wait")).toHaveCount(0);
await expect(page.getByTestId("ob-connect-hubspot")).toBeVisible();
});
test("read-only recipe (Morning brief) carries disclosure, not a grant", async ({ page }) => {
await openQuickstart(page);
await page.getByTestId("qs-template-brief").click();
// Calendar + Gmail rows; no consent checkbox anywhere — reads never gate.
await expect(page.getByText("Today's meetings and gaps")).toBeVisible();
await expect(page.getByText("What arrived overnight")).toBeVisible();
await expect(page.getByTestId("ob-consent")).toHaveCount(0);
});
test("no-connection template: When is editable and create opens the detail", async ({ page }) => {
await openQuickstart(page);
// The card says so on its face.
await expect(page.getByTestId("qs-template-news")).toContainText("No connections needed");
await page.getByTestId("qs-template-news").click();
// No connect rows, no consent — just When (day × time) and an enabled Create.
await expect(page.getByTestId("ob-consent")).toHaveCount(0);
await expect(
page.getByTestId("ob-recipe").getByRole("button", { name: "Day" }),
).toContainText("Every day");
await expect(page.getByTestId("ob-create")).toBeEnabled();
await page.getByTestId("ob-create").click();
await expect(page.getByRole("button", { name: /Run now/ })).toBeVisible();
await expect(page.getByText("Morning news briefing").first()).toBeVisible();
});
+33
View File
@@ -0,0 +1,33 @@
import { test, expect } from "./fixtures";
// Automation runs open as live sessions — which used to look like any other chat with no way
// back (owner report, 2026-07-04). Guards: the run-session banner (task title + automation
// context) and "← Back to runs" returning to the task's detail page.
test("scheduled run session shows the run banner; Back returns to the task detail", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Automations", exact: true }).click();
// Task list → detail (runs list).
await page.getByText("Daily AI News").first().click();
await expect(page.getByRole("button", { name: /Run now/ })).toBeVisible();
await expect(page.getByText("Each run is a live conversation", { exact: false })).toBeVisible();
// Open the running run: a normal session view, but with the automation-context banner.
await page.getByTitle("Open this run's conversation").click();
const banner = page.getByTestId("run-banner");
await expect(banner).toBeVisible();
await expect(banner).toContainText("Scheduled run");
await expect(banner).toContainText("Daily AI News");
// Back link lands on the SAME task's detail, not the bare list.
await banner.getByRole("button", { name: "← Back to runs" }).click();
await expect(page.getByRole("button", { name: /Run now/ })).toBeVisible();
await expect(page.getByText("Daily AI News").first()).toBeVisible();
// A plain (non-run) session never shows the banner.
await page.getByText("Draft the launch note").first().click();
await expect(page.getByTestId("run-banner")).toHaveCount(0);
});
+48
View File
@@ -0,0 +1,48 @@
// Pre-connect connector detail page (UX-DECISIONS §38): an AVAILABLE row
// navigates to a subpage with the About paragraph, honest Access bullets, and
// the tool list behind a collapsed disclosure; Connect opens the same modal as
// the list's pill (which itself must NOT navigate).
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
test("available row opens the pre-connect detail page", async ({ page }) => {
await openConnectors(page);
await page.getByTestId("connector-gmail").click();
const detail = page.getByTestId("available-detail");
await expect(detail).toContainText("Search, summarize, and send over your Gmail.");
await expect(page.getByTestId("available-access")).toContainText("Reads and searches your mail.");
await expect(detail).toContainText("Keys and tokens are stored only on this computer");
// Tools are a collapsed disclosure — advanced detail, closed by default.
await expect(detail).toContainText("2 tools this connector adds");
await expect(detail).not.toContainText("Send email");
await page.getByTestId("available-tools-toggle").click();
await expect(detail).toContainText("Send email");
await expect(detail).toContainText("asks first"); // write tools carry the tag
// Breadcrumb returns to the list.
await page.getByTestId("connectors-breadcrumb").click();
await expect(page.getByTestId("connector-gmail")).toBeVisible();
});
test("detail Connect opens the modal; the list pill skips navigation", async ({ page }) => {
await openConnectors(page);
await page.getByTestId("connector-gmail").click();
await page.getByTestId("available-connect").click();
await expect(page.getByTestId("add-connection-modal")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("add-connection-modal")).not.toBeVisible();
// Back on the list, the pill goes straight to the modal — no detail page.
await page.getByTestId("connectors-breadcrumb").click();
await page.getByTestId("connector-gmail").getByRole("button", { name: "Connect" }).click();
await expect(page.getByTestId("add-connection-modal")).toBeVisible();
await expect(page.getByTestId("available-detail")).not.toBeVisible();
});
+59
View File
@@ -0,0 +1,59 @@
import { test, expect } from "./fixtures";
// The core loop: boot-resume into the last session, send a message over the WebSocket, and render
// the streamed reply — plus the in-session approval round-trip (permission_required suspends the
// turn until Allow/Deny goes back over the socket). The fake agent lives in fixtures.ts.
test("send → user bubble → streamed echo reply renders", async ({ page }) => {
await page.goto("/");
// Boot resumes the most recent session ("Draft the launch note") and connects; the composer is
// live once the fake agent's `ready` lands.
const box = page.getByPlaceholder(/Ask the coworker/);
await expect(box).toBeVisible();
await box.fill("hello agent");
await page.getByRole("button", { name: "Send" }).click();
// Local echo of the user message, then the agent's reply (delta-streamed, then finalized).
await expect(page.getByText("hello agent", { exact: true }).first()).toBeVisible();
await expect(page.getByText(/Echo: hello agent/)).toBeVisible();
// The message carried the composer's visible model (model-per-message contract): what the
// user sees at send time is exactly what serves the turn.
await expect(page.getByText("[model=anthropic:claude-opus-4-8]")).toBeVisible();
// …and having sent, the model is now FIXED for this session (§17/§22): the composer picker is
// gone and the fact reads in the topbar's facts subtitle instead.
await expect(page.locator(".dd").filter({ hasText: "Claude Opus" })).toHaveCount(0);
await expect(page.getByTestId("session-subtitle")).toContainText("Claude Opus 4.8");
// Composer cleared and re-armed for the next turn.
await expect(box).toHaveValue("");
});
test("approval: tool request suspends the turn; Allow once resumes it", async ({ page }) => {
await page.goto("/");
const box = page.getByPlaceholder(/Ask the coworker/);
await expect(box).toBeVisible();
await box.fill("please run a tool");
await page.getByRole("button", { name: "Send" }).click();
// The approval card surfaces the tool + reason and blocks until a decision.
await expect(page.getByText("The coworker wants to run a command.").first()).toBeVisible();
await page.getByRole("button", { name: "Allow once" }).last().click();
// Decision goes back over the socket; the agent finishes the tool and the turn.
await expect(page.getByText("The command ran; 1 file found.")).toBeVisible();
});
test("approval: Deny skips the tool and the agent says so", async ({ page }) => {
await page.goto("/");
const box = page.getByPlaceholder(/Ask the coworker/);
await expect(box).toBeVisible();
await box.fill("please run a tool");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByRole("button", { name: "Deny" }).last()).toBeVisible();
await page.getByRole("button", { name: "Deny" }).last().click();
await expect(page.getByText("Understood — skipped the command.")).toBeVisible();
});
@@ -0,0 +1,43 @@
// Regression guard (shipped once, 2026-07-09; reshaped by §26): cloud sign-in must be
// reachable by a FRESH user. The sidebar account row is the permanent sign-in home —
// always visible, never below any fold — and every signed-out one-click pane carries a
// real Sign-in button, not a hint pointing at another page.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByTestId("account-menu").getByRole("button", { name: "Connectors", exact: true }).click();
}
test("the account row is always visible and signs in from its menu", async ({ page }) => {
await page.goto("/");
const row = page.getByTestId("account-row");
await expect(row).toBeVisible();
await expect(row).toContainText("Not signed in");
await row.click();
await page.getByTestId("account-sign-in").click();
await expect(row).toContainText("Rohit", { timeout: 10_000 });
// Sign out is right there in the same menu once signed in.
await row.click();
await expect(
page.getByTestId("account-menu").getByRole("button", { name: "Sign out" }),
).toBeVisible();
});
test("signed-out one-click pane signs in inline, then connects", async ({ page }) => {
await openConnectors(page);
// Fresh user path: Available → Connect → the pane must offer sign-in itself.
await page
.getByTestId("connector-gmail")
.getByRole("button", { name: "Connect", exact: true })
.click();
await page.getByTestId("inline-cloud-sign-in").click();
// The mock signs in instantly; the section's poll re-renders the pane armed.
await expect(
page.getByRole("button", { name: /Connect Gmail with one click/i }),
).toBeVisible({ timeout: 10_000 });
});
@@ -0,0 +1,53 @@
// FB-013: a signed-in user opened the rail's connect pane and was told to sign in —
// the rail's single cloud-status fetch rendered PENDING (and any failure) as signed-out,
// with nothing that could ever flip it back. Contract now: unknown status shows a neutral
// "checking" line, never the sign-in ask; the pane polls while open; and completing
// sign-in from the inline prompt flips the pane itself (no other section's poll needed).
import { expect } from "@playwright/test";
import { test } from "./fixtures";
const openGmailPane = async (page: import("@playwright/test").Page) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
await page.getByTestId("access-toggle").click();
await page.getByTestId("access-add-source").click();
await page.getByTestId("access-add-gmail").click();
};
test("pending status shows 'checking', never the sign-in ask; resolves to one-click", async ({
page,
}) => {
// Hold every /v1/cloud/status response (test routes outrank the fixture's) — the user
// IS signed in, the app just doesn't know yet.
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
await page.route("**/v1/cloud/status", async (route) => {
await gate;
await route.fulfill({
json: { signed_in: true, account: "her@example.com", user_id: "u1", telemetry_enabled: true },
});
});
await openGmailPane(page);
await expect(page.getByTestId("cloud-status-pending")).toBeVisible();
await expect(page.getByTestId("inline-cloud-sign-in")).toHaveCount(0);
release();
await expect(page.getByRole("button", { name: "Connect Gmail with one click" })).toBeVisible();
await expect(page.getByTestId("cloud-status-pending")).toHaveCount(0);
});
test("signing in from the rail prompt flips the pane to one-click", async ({ page }) => {
// Fixture default: signed out — the resolved signed-out state legitimately asks.
await openGmailPane(page);
const ask = page.getByTestId("inline-cloud-sign-in");
await expect(ask).toBeVisible();
await expect(page.getByTestId("cloud-status-pending")).toHaveCount(0);
// The mock login flips CLOUD_STATE instantly; the inline button's own post-login poll
// plus the CLOUD_CHANGED broadcast must flip THIS pane without any other page open.
await ask.click();
await expect(page.getByRole("button", { name: "Connect Gmail with one click" })).toBeVisible({
timeout: 5_000,
});
});
+85
View File
@@ -0,0 +1,85 @@
// Cloud sign-in (§26: the sidebar account row is the sign-in home) + managed one-click
// connectors. Product invariant under test: manual token setup is always present; managed
// one-click is an ADDITION that appears only when signed in.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByTestId("account-menu").getByRole("button", { name: "Connectors", exact: true }).click();
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible();
}
async function signIn(page) {
await page.getByTestId("account-row").click();
await page.getByTestId("account-sign-in").click();
await expect(page.getByTestId("account-row")).toContainText("Rohit", { timeout: 10_000 });
}
test("signed out: the account row is the sign-in home; managed connector still connects manually", async ({
page,
}) => {
await page.goto("/");
const row = page.getByTestId("account-row");
await expect(row).toContainText("Not signed in");
// The menu leads with the sign-in CTA and always lists Inbox + Connectors.
await row.click();
const menu = page.getByTestId("account-menu");
await expect(menu).toContainText("one-click connections need OpenWorker Cloud");
await expect(menu.getByTestId("account-sign-in")).toBeVisible();
await expect(menu.getByRole("button", { name: "Inbox" })).toBeVisible();
await menu.getByRole("button", { name: "Connectors", exact: true }).click();
// The managed-capable connector's add-modal shows the hint + manual fields, no
// one-click button while signed out.
await page.getByTestId("connector-gmail").getByRole("button", { name: "Connect" }).click();
const modal = page.getByTestId("add-connection-modal");
await expect(modal.getByTestId("managed-connect")).toContainText("Sign in to OpenWorker Cloud");
await expect(modal.locator("input[type=password]")).toBeVisible(); // manual field rendered
await expect(modal.getByRole("button", { name: /one click/i })).toHaveCount(0);
});
test("signed in: account row shows the name; one-click appears; sign out from the menu", async ({
page,
}) => {
await openConnectors(page);
await signIn(page);
await page.getByTestId("connector-gmail").getByRole("button", { name: "Connect", exact: true }).click();
const modal = page.getByTestId("add-connection-modal");
await expect(modal.getByRole("button", { name: /Connect Gmail with one click/i })).toBeVisible();
// the manual path must still be offered alongside
await expect(modal.getByTestId("managed-connect")).toContainText("or connect manually");
await page.keyboard.press("Escape");
// The menu header carries the email; Sign out flips the row back.
await page.getByTestId("account-row").click();
const menu = page.getByTestId("account-menu");
await expect(menu).toContainText("rohit@openworker.com");
await menu.getByRole("button", { name: "Sign out" }).click();
await page.getByTestId("account-row").click(); // reopen → status refetch
await expect(page.getByTestId("account-row")).toContainText("Not signed in");
});
test("telemetry toggle: lives in Settings, signed-in only, default on, opt-out round-trips", async ({
page,
}) => {
// Signed out: Settings has no toggle at all — nothing is sent, nothing to configure.
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByTestId("account-menu").getByRole("button", { name: "Settings" }).click();
await expect(page.getByRole("heading", { name: "General" })).toBeVisible();
await expect(page.getByTestId("telemetry-toggle")).toHaveCount(0);
await signIn(page);
await page.getByTestId("account-row").click();
await page.getByTestId("account-menu").getByRole("button", { name: "Settings" }).click();
const toggle = page.getByTestId("telemetry-toggle");
await expect(toggle).toBeChecked({ timeout: 10_000 }); // default-on when signed in
await expect(page.getByText("never your prompts, files, or connector")).toBeVisible();
await toggle.uncheck();
await expect(toggle).not.toBeChecked(); // survives the status re-fetch (persisted)
});
+97
View File
@@ -0,0 +1,97 @@
import { test, expect } from "./fixtures";
// Guards the three-control composer row (§22): send-gating (accent only with content), the "+"
// attach menu, and the Mode menu (permission options + the folded-in Send-to-Inbox toggle).
test("composer: send-gating, + attach menu, Mode menu", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const box = page.getByPlaceholder(/Ask the coworker/);
const send = page.getByRole("button", { name: "Send" });
// Send is subtle grey when empty, accent once there's content, grey again when cleared.
await expect(send).not.toHaveClass(/bg-accent/);
await box.fill("hello there");
await expect(send).toHaveClass(/bg-accent/);
await box.fill("");
await expect(send).not.toHaveClass(/bg-accent/);
// "+" attach menu offers the three typed shortcuts.
await page.getByRole("button", { name: "Attach" }).click();
await expect(page.getByRole("button", { name: "Photo or image" })).toBeVisible();
await expect(page.getByRole("button", { name: "PDF", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Other files" })).toBeVisible();
// Clicking the backdrop closes it.
await page.locator(".fixed.inset-0.z-30").click();
await expect(page.getByRole("button", { name: "Photo or image" })).toHaveCount(0);
// Mode menu (workspace personas only): the five permission options with the current one
// marked, plus the Unattended/send-to-Inbox toggle at the bottom (§22).
await page.getByRole("button", { name: "Mode", exact: true }).click();
const menu = page.getByTestId("mode-menu");
await expect(menu.getByText("Discuss")).toBeVisible();
await expect(menu.getByText("Explore read-only, propose a plan")).toBeVisible();
// The current mode is marked with a ✓.
await expect(menu.locator("button").filter({ hasText: "Ask for approval" })).toContainText("✓");
await expect(menu.getByRole("switch", { name: "Send approvals to the Inbox" })).toBeVisible();
// Picking an option closes the menu (and would flip the live engine's mode).
await menu.getByText("Full access").click();
await expect(page.getByTestId("mode-menu")).toHaveCount(0);
});
// PDFs read as data URLs and show a named chip (DMG #29 walkthrough catch: PDFs silently
// no-op'd because readFile only handled images and text).
test("composer: picking a PDF shows an attachment chip and arms send", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const send = page.getByRole("button", { name: "Send" });
await expect(send).not.toHaveClass(/bg-accent/);
await page.locator('input[type="file"]').setInputFiles({
name: "report.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF"),
});
const chip = page.locator(".attach-chip");
await expect(chip).toContainText("report.pdf");
await expect(send).toHaveClass(/bg-accent/); // attachment alone arms send
// Removing the chip disarms send again.
await chip.locator(".attach-x").click();
await expect(page.locator(".attach-chip")).toHaveCount(0);
await expect(send).not.toHaveClass(/bg-accent/);
});
// Token-savings threshold (owner ask, 2026-07-17): a PDF over the user's page limit is
// REJECTED with a visible notice — no chip, send stays disarmed. Fixture limit: 2 pages;
// the mock inspect endpoint reads the page count from a "%%pages=N" marker in the body.
test("composer: PDF over the page threshold is rejected with a notice", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
await page.locator('input[type="file"]').setInputFiles({
name: "big-report.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4\n%%pages=34\ntrailer\n<<>>\n%%EOF"),
});
const notice = page.getByTestId("attach-notice");
await expect(notice).toContainText("big-report.pdf skipped");
await expect(notice).toContainText("34 pages is over your 2-page limit");
await expect(page.locator(".attach-chip")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Send" })).not.toHaveClass(/bg-accent/);
// The ✕ dismisses the notice.
await notice.getByRole("button").click();
await expect(page.getByTestId("attach-notice")).toHaveCount(0);
// A small PDF (1 page per the mock) still attaches fine after a rejection.
await page.locator('input[type="file"]').setInputFiles({
name: "small.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4\n%%pages=1\ntrailer\n<<>>\n%%EOF"),
});
await expect(page.locator(".attach-chip")).toContainText("small.pdf");
});
+62
View File
@@ -0,0 +1,62 @@
// Slack config is a detail SUBPAGE under Connectors (UX-DECISIONS §21): the list row
// navigates to it, and the §19 flows — parked senders (Allow & deliver / Allow / ×)
// and "listening" sessions — are filed under the workspace they belong to.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openSlackPage(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
await page.getByTestId("connector-slack").click();
}
test("list row status + navigation to the Slack page", async ({ page }) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
const row = page.getByTestId("connector-slack");
await expect(row).toContainText("2 workspaces · relay");
await row.click();
await expect(page.getByTestId("slack-workspaces")).toBeVisible();
// signed out (fixture default) → the status line leads with the actionable layer
await expect(page.getByTestId("slack-mode-badge")).toContainText("Sign-in needed");
});
test("parked sender files under ITS workspace; Allow & deliver adds to that allow-list only", async ({
page,
}) => {
await openSlackPage(page);
// pk1 belongs to T1DL — its Waiting row renders in that workspace's group only.
const t1 = page.getByTestId("slack-workspace-T1DL");
await expect(t1.getByTestId("waiting-pk1")).toContainText("Maya");
await expect(t1.getByTestId("waiting-pk1")).toContainText("in #ocw-test");
await expect(t1.getByTestId("waiting-pk1")).toContainText("hey ocw, can you summarize this thread?");
await expect(page.getByTestId("slack-workspace-T2AC").getByTestId("waiting-pk1")).toHaveCount(0);
await page.getByTestId("parked-allow-deliver-pk1").click();
await expect(page.getByTestId("waiting-pk1")).toHaveCount(0);
// The sender lands on the T1DL allow-list; the sibling workspace stays empty.
await expect(t1).toContainText("U0NEW");
await expect(page.getByTestId("slack-workspace-T2AC")).not.toContainText("U0NEW");
});
test("parked sender can be dismissed without allowing", async ({ page }) => {
await openSlackPage(page);
await page.getByTestId("parked-dismiss-pk1").click();
await expect(page.getByTestId("waiting-pk1")).toHaveCount(0);
await expect(page.getByTestId("slack-workspace-T1DL")).not.toContainText("U0NEW");
});
test("sessions listening in a workspace: listed with unsubscribe", async ({ page }) => {
await openSlackPage(page);
const t1 = page.getByTestId("slack-workspace-T1DL");
await expect(t1.getByTestId("listening-slack")).toContainText("Weekly plan 1");
await expect(t1.getByTestId("listening-slack")).toContainText("#ocw-test");
await t1.getByTitle("Unsubscribe this session").click();
await expect(t1.getByTestId("listening-slack")).toHaveCount(0); // row hides when empty
});
+66
View File
@@ -0,0 +1,66 @@
// The Connectors LIST (UX-DECISIONS §21): connected connectors first in their own
// section with a health chip, rows navigate to the connector's detail subpage
// (breadcrumb back), available connectors get a Connect pill → add-connection modal
// with One click | Manual pills for multi-mode connectors.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
test("connected connectors come first with status + health chip", async ({ page }) => {
await openConnectors(page);
const slack = page.getByTestId("connector-slack");
await expect(slack).toContainText("2 workspaces · relay");
// signed out + relay mode → the honest chip is the actionable one
await expect(slack).toContainText("Sign-in needed");
// available section renders the not-connected connectors with a Connect pill
await expect(
page.getByTestId("connector-telegram").getByRole("button", { name: "Connect" }),
).toBeVisible();
});
test("row navigates to the detail subpage; breadcrumb returns", async ({ page }) => {
await openConnectors(page);
await page.getByTestId("connector-slack").click();
await expect(page.getByTestId("slack-workspaces")).toBeVisible();
await page.getByTestId("connectors-breadcrumb").click();
await expect(page.getByTestId("connector-slack")).toContainText("2 workspaces · relay");
});
test("generic detail page: tools + two-way blocks + disconnect for telegram-alikes", async ({
page,
}) => {
await openConnectors(page);
// Browser is keyless-connected → generic page, no Disconnect for auth=none
await page.getByTestId("connector-browser").click();
await expect(page.getByRole("heading", { name: "Browser" })).toBeVisible();
await expect(page.getByRole("button", { name: "Disconnect" })).toHaveCount(0);
await page.getByTestId("connectors-breadcrumb").click();
});
test("Connect on a multi-mode connector opens the modal with One click | Manual pills", async ({
page,
}) => {
await openConnectors(page);
// make slack disconnected for this test: disconnect both workspaces via its page is
// heavy — instead assert the modal via the detail page's Add workspace in the slack spec;
// here we verify the generic modal path with telegram (single-mode → ConnectSetup pane).
await page.getByTestId("connector-telegram").getByRole("button", { name: "Connect" }).click();
const modal = page.getByTestId("add-connection-modal");
await expect(modal).toBeVisible();
await expect(modal.locator("input")).not.toHaveCount(0); // manual fields rendered
await page.keyboard.press("Escape");
await expect(page.getByTestId("add-connection-modal")).toHaveCount(0);
});
test("filter narrows both sections", async ({ page }) => {
await openConnectors(page);
await page.getByPlaceholder("Search").fill("tele");
await expect(page.getByTestId("connector-telegram")).toBeVisible();
await expect(page.getByTestId("connector-slack")).toHaveCount(0);
});
+43
View File
@@ -0,0 +1,43 @@
import { test, expect } from "./fixtures";
// §16 workspace collapse: the persona FAMILY alone decides the workspace behavior.
// code → an explicit project folder, enforced by the FolderGate (no chat-behind-it escape)
// knowledge → starts orphan on a transparent scratch dir — never gated
// (The mock's Ops persona is knowledge-family with zero sessions, so picking it exercises the
// brand-new-session path, not a resume.)
const personaMenu = (page: import("@playwright/test").Page) => page.locator(".newsplit-menu");
async function startAs(page: import("@playwright/test").Page, persona: RegExp) {
await page.getByLabel("Choose a persona").click();
await personaMenu(page).getByRole("button", { name: persona }).click();
}
test("knowledge persona: new session starts instantly, no folder gate", async ({ page }) => {
await page.goto("/");
await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible();
await startAs(page, /Ops/);
await expect(page.locator(".gate-overlay")).toHaveCount(0);
await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible();
});
test("code persona: the folder gate blocks until a project is chosen", async ({ page }) => {
await page.goto("/");
await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible();
await startAs(page, /Code/);
const gate = page.locator(".gate-overlay");
await expect(gate).toBeVisible();
await expect(gate.getByText("Choose a project folder")).toBeVisible();
// No escape hatch: the gate offers pick-a-folder only (no "switch to Chat" — owner call, §16).
await expect(gate.getByText(/chat/i)).toHaveCount(0);
await gate.getByPlaceholder("/path/to/your/project").fill("/tmp/e2e-project");
await gate.getByRole("button", { name: "Open", exact: true }).click();
// Gate clears, the session is rooted in the chosen folder, and the code composer is live.
await expect(page.locator(".gate-overlay")).toHaveCount(0);
await expect(page.getByPlaceholder(/Ask the coder/)).toBeVisible();
});
File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
// Settings ▸ Personas ▸ Gallery: the catalog lives in a screen-sized modal opened
// from the Personas page (link → featured carousel + list → in-modal solo page →
// informed install → Done lands back on Personas). Plus the page-level delete
// affordance for non-builtin personas.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openPersonas(page) {
// Personas is launch-flagged off by default — these suites cover the flagged-on flows.
await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "1"));
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Personas", exact: true }).click();
await expect(page.getByTestId("gallery-link")).toBeVisible();
}
async function openGallery(page) {
await openPersonas(page);
await page.getByTestId("gallery-link").click();
await expect(page.getByTestId("gallery-modal")).toBeVisible();
}
test("slow cloud: skeleton shows while the gallery loads, never a blank body", async ({
page,
}) => {
// The real gallery is a cloud round-trip (Lambda + Dynamo) that can take seconds;
// delay the mocked endpoints to assert the skeleton bridges the gap.
await page.route("**/v1/cloud/status", async (route) => {
await new Promise((r) => setTimeout(r, 1200));
await route.fulfill({ json: { ok: true, signed_in: false } });
});
await openGallery(page);
await expect(page.getByTestId("gallery-loading")).toBeVisible();
await expect(page.getByTestId("gallery-loading")).toContainText("Loading the gallery");
// Resolves into the real body (signed-out prompt here) once the cloud answers.
await expect(page.getByTestId("gallery-signin")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("gallery-loading")).toHaveCount(0);
});
test("signed out: modal prompts for sign-in, manual install path unaffected", async ({ page }) => {
await openGallery(page);
const prompt = page.getByTestId("gallery-signin");
await expect(prompt).toContainText("needs a (free) cloud sign-in");
await expect(prompt).toContainText("always works without an account");
await expect(prompt.getByRole("button", { name: "Sign in" })).toBeVisible();
// Esc closes; the Personas page (with its dir/Git importer) is still there.
await page.keyboard.press("Escape");
await expect(page.getByTestId("gallery-modal")).not.toBeVisible();
await expect(page.getByRole("button", { name: "Install", exact: true })).toBeVisible();
});
test("signed in: featured carousel + list; solo page installs informed; Done returns", async ({
page,
}) => {
await openGallery(page);
await page.getByTestId("gallery-signin").getByRole("button", { name: "Sign in" }).click();
// Featured carousel holds the flagged persona; the list holds both.
const featured = page.getByTestId("gallery-featured");
await expect(featured).toBeVisible({ timeout: 10_000 });
await expect(featured).toContainText("Sales Coworker");
await expect(featured).not.toContainText("Recruiter");
await expect(page.getByTestId("gallery-recruiter")).toContainText("View & install");
await expect(page.getByTestId("gallery-team-teaser")).toContainText("coming soon");
// Search narrows the list.
await page.getByPlaceholder("Search personas").fill("recruit");
await expect(page.getByTestId("gallery-sales")).not.toBeVisible();
await page.getByPlaceholder("Search personas").fill("");
// Solo page: pitch + manifest-derived capabilities BEFORE install.
await page.getByTestId("gallery-sales").click();
const detail = page.getByTestId("gallery-detail");
await expect(detail).toContainText("Walk into every call already knowing the account");
const caps = page.getByTestId("gallery-capabilities");
await expect(caps).toContainText("verified from its manifest");
await expect(caps).toContainText("files, search, todo");
await expect(caps).toContainText("hubspot · core");
await expect(caps).toContainText("read deals and contacts");
await detail.getByRole("button", { name: "Install" }).click();
await expect(detail).toContainText("disabled until you approve and enable it");
// Done closes the modal, landing back on the Personas page.
await detail.getByRole("button", { name: "Done" }).click();
await expect(page.getByTestId("gallery-modal")).not.toBeVisible();
await expect(page.getByTestId("gallery-link")).toBeVisible();
});
test("back link returns from the solo page to the catalog", async ({ page }) => {
await openGallery(page);
await page.getByTestId("gallery-signin").getByRole("button", { name: "Sign in" }).click();
await page.getByTestId("gallery-sales").click({ timeout: 10_000 });
await expect(page.getByTestId("gallery-detail")).toBeVisible();
await page.getByRole("button", { name: "← Gallery" }).click();
await expect(page.getByTestId("gallery-cards")).toBeVisible();
});
test("delete: non-builtin personas removable after confirm; built-ins are not", async ({
page,
}) => {
await openPersonas(page);
// Built-ins expose no delete affordance.
await expect(page.getByTestId("persona-delete-cowork")).toHaveCount(0);
// Non-builtin: trash → inline confirm → row gone (works signed out).
await expect(page.getByText("Acme Notes")).toBeVisible();
await page.getByTestId("persona-delete-acme-notes").click();
await page.getByTestId("persona-delete-confirm-acme-notes").click();
await expect(page.getByText("Acme Notes")).not.toBeVisible();
});
+62
View File
@@ -0,0 +1,62 @@
// The Google Calendar detail page: gmail-parity multi-account (Default badge,
// Make default, per-account disconnect, direct one-click add — no modal).
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
async function signInAndConnectFirstAccount(page) {
await openConnectors(page);
await page.getByTestId("account-row").click();
await page.getByTestId("account-sign-in").click();
await expect(page.getByTestId("account-row")).toContainText("Rohit", { timeout: 10_000 });
// starts disconnected → Available row → one click (mock connects instantly)
await page
.getByTestId("connector-google_calendar")
.getByRole("button", { name: "Connect", exact: true })
.click();
await page.getByRole("button", { name: /Connect Google Calendar with one click/i }).click();
await page.keyboard.press("Escape");
await expect(page.getByTestId("connector-google_calendar")).toContainText("rohit@gmail.com", {
timeout: 10_000,
});
}
test("connect, then add a second account from the page; first stays default", async ({
page,
}) => {
await signInAndConnectFirstAccount(page);
await page.getByTestId("connector-google_calendar").click();
await expect(page.getByTestId("gcal-detail")).toBeVisible();
await page.getByTestId("add-account-btn").click();
const rohit = page.getByTestId("gcal-account-rohit@gmail.com");
const work = page.getByTestId("gcal-account-work@dlai.com");
await expect(work).toBeVisible({ timeout: 10_000 });
await expect(rohit).toContainText("Default");
await expect(work).not.toContainText("Default");
// list row summarizes the multi-account state
await page.getByTestId("connectors-breadcrumb").click();
await expect(page.getByTestId("connector-google_calendar")).toContainText("2 accounts");
});
test("Make default moves the badge; disconnecting the default repoints it", async ({
page,
}) => {
await signInAndConnectFirstAccount(page);
await page.getByTestId("connector-google_calendar").click();
await page.getByTestId("add-account-btn").click();
await expect(page.getByTestId("gcal-account-work@dlai.com")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("gcal-make-default-work@dlai.com").click();
await expect(page.getByTestId("gcal-account-work@dlai.com")).toContainText("Default");
await expect(page.getByTestId("gcal-account-rohit@gmail.com")).not.toContainText("Default");
await page.getByTestId("gcal-disconnect-work@dlai.com").click();
await expect(page.getByTestId("gcal-account-work@dlai.com")).toHaveCount(0);
await expect(page.getByTestId("gcal-account-rohit@gmail.com")).toContainText("Default");
});
+107
View File
@@ -0,0 +1,107 @@
// The GitHub detail page (github-relay-spec §8): one group per App INSTALLATION
// with People / Waiting rows and a per-installation disconnect, add-installation
// via the header MODAL (One click | Manual), and the park → allow & deliver flow
// that admits a new sender login into that installation's allow-list.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openGithubPage(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
await page.getByTestId("connector-github").click();
}
test("lists each installation as its own group with people and waiting rows", async ({
page,
}) => {
await openGithubPage(page);
const group = page.getByTestId("github-install-101");
await expect(group).toContainText("acme");
await expect(group).toContainText("selected repos"); // repo consent is GitHub-native
await expect(group).toContainText("@rohit-dev"); // logins ARE the readable identity
// the parked mention files under ITS installation, quoting the trigger
await expect(group).toContainText("@maya-dev");
await expect(group).toContainText("please take a look");
});
test("allow & deliver admits the sender into that installation's list", async ({
page,
}) => {
await openGithubPage(page);
await page.getByTestId("parked-allow-deliver-gh-pk1").click();
const group = page.getByTestId("github-install-101");
await expect(group).toContainText("@maya-dev"); // now a People chip
await expect(page.getByTestId("waiting-gh-pk1")).toHaveCount(0);
});
test("add installation opens the modal; signed in installs a second org", async ({
page,
}) => {
await openGithubPage(page);
await page.getByTestId("add-installation-btn").click();
const modal = page.getByTestId("add-connection-modal");
await expect(modal).toContainText("@ocw-agent App"); // one-click pane
await expect(modal).toContainText("Sign in to OpenWorker Cloud"); // signed out
// Manual PAT pane is right there too — both modes, one entry point
await modal.getByTestId("modal-pane-manual").click();
await expect(modal).toContainText("Personal access token");
await page.keyboard.press("Escape");
// sign in from the list's cloud strip, then install one-click
await page.getByTestId("connectors-breadcrumb").click();
await page.getByTestId("account-row").click();
await page.getByTestId("account-sign-in").click();
await expect(page.getByTestId("account-row")).toContainText("Rohit", { timeout: 10_000 });
await page.getByTestId("connector-github").click();
await page.getByTestId("add-installation-btn").click();
await page.getByTestId("modal-install-github-app").click();
// the mock completes the browser install instantly; the page's poll shows it
await expect(page.getByTestId("github-install-202")).toContainText("hooli", {
timeout: 10_000,
});
await expect(page.getByTestId("github-install-202")).toContainText("all repos");
await expect(page.getByTestId("github-install-101")).toBeVisible(); // existing stays
});
test("modal has ONE connect button and sends no flow — authorize-first lives in the broker", async ({
page,
}) => {
// The broker's default github flow user-authorizes first (links existing installations,
// redirects to the install page only when there are none) — so the modal's old
// "Already installed? Link it" secondary and its flow=authorize are gone.
await openGithubPage(page);
await page.getByTestId("connectors-breadcrumb").click();
await page.getByTestId("account-row").click();
await page.getByTestId("account-sign-in").click();
await expect(page.getByTestId("account-row")).toContainText("Rohit", { timeout: 10_000 });
await page.getByTestId("connector-github").click();
let flowSent: string | null = null;
await page.route("**/v1/connectors/github/connect-managed", async (route) => {
flowSent = (route.request().postDataJSON() || {}).flow ?? "";
await route.fulfill({ contentType: "application/json", body: JSON.stringify({ ok: true }) });
});
await page.getByTestId("add-installation-btn").click();
await expect(page.getByTestId("modal-link-github-install")).toHaveCount(0);
await page.getByTestId("modal-install-github-app").click();
await expect.poll(() => flowSent).toBe("");
});
test("disconnect removes one installation and keeps the rest", async ({ page }) => {
await openGithubPage(page);
// add a second installation first (signed-in one-click)
await page.getByTestId("connectors-breadcrumb").click();
await page.getByTestId("account-row").click();
await page.getByTestId("account-sign-in").click();
await expect(page.getByTestId("account-row")).toContainText("Rohit", { timeout: 10_000 });
await page.getByTestId("connector-github").click();
await page.getByTestId("add-installation-btn").click();
await page.getByTestId("modal-install-github-app").click();
await expect(page.getByTestId("github-install-202")).toBeVisible({ timeout: 10_000 });
await page.keyboard.press("Escape"); // the modal never auto-closes (by design)
await page.getByTestId("disconnect-install-202").click();
await expect(page.getByTestId("github-install-202")).toHaveCount(0);
await expect(page.getByTestId("github-install-101")).toBeVisible();
});
+84
View File
@@ -0,0 +1,84 @@
// The Gmail detail page (M3.6 Step 3, UX-DECISIONS §21): multi-account with a
// Default badge, per-account disconnect, direct one-click add (no modal — Gmail
// has one connect mode), and the "Never show agents" filter lists.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
async function signInAndConnectFirstAccount(page) {
await openConnectors(page);
await page.getByTestId("account-row").click();
await page.getByTestId("account-sign-in").click();
await expect(page.getByTestId("account-row")).toContainText("Rohit", { timeout: 10_000 });
// gmail starts disconnected → Available row → modal → one click (mock connects instantly)
await page.getByTestId("connector-gmail").getByRole("button", { name: "Connect", exact: true }).click();
await page.getByRole("button", { name: /Connect Gmail with one click/i }).click();
await page.keyboard.press("Escape");
await expect(page.getByTestId("connector-gmail")).toContainText("rohit@gmail.com", {
timeout: 10_000,
});
}
test("connect, then add a second account from the page; first stays default", async ({
page,
}) => {
await signInAndConnectFirstAccount(page);
await page.getByTestId("connector-gmail").click();
await expect(page.getByTestId("gmail-detail")).toBeVisible();
await page.getByTestId("add-account-btn").click();
const rohit = page.getByTestId("gmail-account-rohit@gmail.com");
const work = page.getByTestId("gmail-account-work@dlai.com");
await expect(work).toBeVisible({ timeout: 10_000 });
await expect(rohit).toContainText("Default");
await expect(work).not.toContainText("Default");
// list row summarizes the multi-account state
await page.getByTestId("connectors-breadcrumb").click();
await expect(page.getByTestId("connector-gmail")).toContainText("2 accounts");
});
test("Make default moves the badge; disconnecting the default repoints it", async ({
page,
}) => {
await signInAndConnectFirstAccount(page);
await page.getByTestId("connector-gmail").click();
await page.getByTestId("add-account-btn").click();
await expect(page.getByTestId("gmail-account-work@dlai.com")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("gmail-make-default-work@dlai.com").click();
await expect(page.getByTestId("gmail-account-work@dlai.com")).toContainText("Default");
await expect(page.getByTestId("gmail-account-rohit@gmail.com")).not.toContainText("Default");
await page.getByTestId("gmail-disconnect-work@dlai.com").click();
await expect(page.getByTestId("gmail-account-work@dlai.com")).toHaveCount(0);
await expect(page.getByTestId("gmail-account-rohit@gmail.com")).toContainText("Default");
});
test("Never show agents: sender + label chips round-trip", async ({ page }) => {
await signInAndConnectFirstAccount(page);
await page.getByTestId("connector-gmail").click();
const senders = page.getByTestId("gmail-filter-senders");
await senders.getByRole("textbox").fill("ceo@corp.com");
await senders.getByRole("textbox").press("Enter");
await expect(senders).toContainText("ceo@corp.com");
const labels = page.getByTestId("gmail-filter-labels");
await labels.getByRole("textbox").fill("Personal");
await labels.getByRole("textbox").press("Enter");
await expect(labels).toContainText("Personal");
// chips survive a reload (persisted through the PATCH route, re-read on load)
await page.reload();
await openConnectors(page);
await page.getByTestId("connector-gmail").click();
await expect(page.getByTestId("gmail-filter-senders")).toContainText("ceo@corp.com");
// remove round-trips too
await page.getByTestId("gmail-filter-senders").getByTitle("remove").click();
await expect(page.getByTestId("gmail-filter-senders")).not.toContainText("ceo@corp.com");
});
+93
View File
@@ -0,0 +1,93 @@
// The HubSpot detail page (M3.6 Step 4, UX-DECISIONS §21): multi-portal with
// Default/Sandbox/access tags, the add-modal with One click (read | write
// consent radios) | Manual private-app pills, and the hidden-fields denylist.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
async function signIn(page) {
await page.getByTestId("account-row").click();
await page.getByTestId("account-sign-in").click();
await expect(page.getByTestId("account-row")).toContainText("Rohit", { timeout: 10_000 });
}
test("connect via modal: access radios pick the consent tier; tags reflect it", async ({
page,
}) => {
await openConnectors(page);
await signIn(page);
// Available row → Connect → the two-pill modal with the access radios
await page.getByTestId("connector-hubspot").getByRole("button", { name: "Connect" }).click();
const modal = page.getByTestId("add-connection-modal");
await expect(modal.getByTestId("hubspot-access-read")).toBeChecked(); // read-only default
await expect(modal).toContainText("never delete");
await modal.getByTestId("hubspot-access-write").check();
await modal.getByTestId("modal-connect-hubspot").click();
await page.keyboard.press("Escape");
// the mock connects instantly; the row moves to Connected and navigates
await expect(page.getByTestId("connector-hubspot")).toContainText("Acme Inc", {
timeout: 10_000,
});
await page.getByTestId("connector-hubspot").click();
const row = page.getByTestId("hubspot-portal-111");
await expect(row).toContainText("Default");
await expect(page.getByTestId("hubspot-access-tag-111")).toContainText("read & write");
});
test("manual pane offers the private-app token (no duplicated one-click)", async ({
page,
}) => {
await openConnectors(page);
await page.getByTestId("connector-hubspot").getByRole("button", { name: "Connect" }).click();
const modal = page.getByTestId("add-connection-modal");
await modal.getByTestId("modal-pane-manual").click();
await expect(modal.getByPlaceholder("pat-…")).toBeVisible();
await expect(modal.getByTestId("managed-connect")).toHaveCount(0); // one-click lives on the other pill
});
test("second portal: sandbox tag, make-default, disconnect repoints", async ({ page }) => {
await openConnectors(page);
await signIn(page);
await page.getByTestId("connector-hubspot").getByRole("button", { name: "Connect" }).click();
await page.getByTestId("modal-connect-hubspot").click();
await page.keyboard.press("Escape");
await expect(page.getByTestId("connector-hubspot")).toContainText("Acme Inc", { timeout: 10_000 });
await page.getByTestId("connector-hubspot").click();
// add the sandbox portal from the page's header button
await page.getByTestId("add-portal-btn").click();
await page.getByTestId("modal-connect-hubspot").click();
await page.keyboard.press("Escape");
const sandbox = page.getByTestId("hubspot-portal-222");
await expect(sandbox).toContainText("Sandbox", { timeout: 10_000 });
await page.getByTestId("hubspot-make-default-222").click();
await expect(sandbox).toContainText("Default");
await page.getByTestId("hubspot-disconnect-222").click();
await expect(page.getByTestId("hubspot-portal-222")).toHaveCount(0);
await expect(page.getByTestId("hubspot-portal-111")).toContainText("Default");
});
test("hidden fields round-trip and read back normalized", async ({ page }) => {
await openConnectors(page);
await signIn(page);
await page.getByTestId("connector-hubspot").getByRole("button", { name: "Connect" }).click();
await page.getByTestId("modal-connect-hubspot").click();
await page.keyboard.press("Escape");
await expect(page.getByTestId("connector-hubspot")).toContainText("Acme Inc", { timeout: 10_000 });
await page.getByTestId("connector-hubspot").click();
const row = page.getByTestId("hubspot-hidden-fields");
await row.getByRole("textbox").fill("Salary");
await row.getByRole("textbox").press("Enter");
await expect(row).toContainText("salary"); // normalized lowercase from the PATCH echo
await row.getByTitle("remove").click();
await expect(row).not.toContainText("salary");
});
+77
View File
@@ -0,0 +1,77 @@
import { test, expect } from "./fixtures";
// The Inbox (owner testing pass, 2026-07-03; §28 two-tab split 2026-07-12): Pending holds the
// kind chips (All/Approvals/Questions), persona filter chips (only with >1 persona holding
// items), and resolve-removes-card. Routing moved to the Configure tab (the former Connectors ▸
// Messaging routing page) — Pending's status line is read-only and links there; the old inline
// editor (the mirror setting's SECOND editor) is gone.
async function openInbox(page: import("@playwright/test").Page) {
await page.goto("/");
// §26: the fixtures seed pending items, so the account row's inbox chip is unlocked and
// pending — clicking it goes STRAIGHT to Inbox (the menu is the row's target, not the chip's).
await page.getByTestId("inbox-chip").click();
await expect(page.getByText("Approve: run_shell")).toBeVisible();
}
test("kind + persona filters narrow the pending list", async ({ page }) => {
await openInbox(page);
const question = "Which environment should I restart?";
await expect(page.getByText(question)).toBeVisible();
const filters = page.getByTestId("inbox-filters");
await filters.getByRole("button", { name: "Approvals" }).click();
await expect(page.getByText(question)).not.toBeVisible();
await expect(page.getByText("Approve: run_shell")).toBeVisible();
await filters.getByRole("button", { name: "Questions" }).click();
await expect(page.getByText("Approve: run_shell")).not.toBeVisible();
await expect(page.getByText(question)).toBeVisible();
// Persona chips render because two personas hold items; filtering to Ops hides the cowork item.
await filters.getByRole("button", { name: "All", exact: true }).click();
await filters.getByRole("button", { name: "Ops", exact: true }).click();
await expect(page.getByText("Approve: run_shell")).not.toBeVisible();
await expect(page.getByText(question)).toBeVisible();
});
test("resolving an approval removes its card; question options resolve on click", async ({ page }) => {
await openInbox(page);
await page.getByRole("button", { name: "Approve", exact: true }).click();
await expect(page.getByText("Approve: run_shell")).not.toBeVisible();
// Single-select question: clicking an option resolves immediately.
await page.getByRole("button", { name: "staging", exact: true }).click();
await expect(page.getByText("Which environment should I restart?")).not.toBeVisible();
await expect(page.getByText("Nothing pending.")).toBeVisible();
});
test("routing: Configure tab binds the mirror channel; Pending's status line follows", async ({
page,
}) => {
await openInbox(page);
const line = page.getByTestId("inbox-routing");
await expect(line).toContainText("Delivered here only");
// The status line is read-only — its Configure link lands on the Configure tab, which
// holds the ONE editor (the old inline editor was a duplicate of this card).
await page.getByTestId("inbox-route-configure").click();
const mirror = page.getByTestId("inbox-mirror-card");
await expect(mirror).toContainText("in-app Inbox only");
await mirror.getByPlaceholder("slack:C0123 or channel link").fill("C0777");
await mirror.getByRole("button", { name: "Set", exact: true }).click();
await expect(mirror).toContainText("slack:C0777");
// Back on Pending, the line reflects the new target immediately.
await page.getByTestId("inbox-tab-pending").click();
await expect(line).toContainText("slack:C0777");
await expect(line).toContainText("replies there resolve items here");
// Clearing (also on Configure) returns Pending to local-only delivery.
await page.getByTestId("inbox-tab-configure").click();
await mirror.getByRole("button", { name: "clear" }).click();
await expect(mirror).toContainText("in-app Inbox only");
await page.getByTestId("inbox-tab-pending").click();
await expect(line).toContainText("Delivered here only");
});
+71
View File
@@ -0,0 +1,71 @@
// MCP-backed connectors (UX-DECISIONS §42): monday/asana/jira connect through the
// vendor's hosted MCP server via a fully LOCAL OAuth flow — one-click without any
// cloud sign-in — and agents get only the PINNED tool subset, surfaced on the
// connector detail page like any other curated tool set.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
test("monday: one-click MCP connect without cloud sign-in; card flips connected", async ({
page,
}) => {
await openConnectors(page);
// Signed OUT (fixtures default) — the MCP one-click needs no OpenWorker account.
await page
.getByTestId("connector-monday")
.getByRole("button", { name: "Connect" })
.click();
const modal = page.getByTestId("add-connection-modal");
await expect(modal).toBeVisible();
// Single-mode: no One click | Manual pills, no cloud sign-in gate — just the button.
await expect(modal.getByTestId("modal-pane-manual")).toHaveCount(0);
await expect(modal.getByTestId("inline-cloud-sign-in")).toHaveCount(0);
await expect(modal.getByText("sign-in runs entirely on this computer")).toBeVisible();
await modal.getByTestId("modal-mcp-one-click").click();
await expect(modal.getByText("Check your browser…")).toBeVisible();
// The mock flow completes instantly; the modal's poll closes it and the card flips.
await expect(page.getByTestId("add-connection-modal")).toHaveCount(0, {
timeout: 10_000,
});
await expect(page.getByTestId("connector-monday")).toContainText("Connected");
});
test("jira: two modes — MCP one-click pane plus the manual token form", async ({
page,
}) => {
await openConnectors(page);
// jira sits past the available-list fold.
await page.getByRole("button", { name: "show all" }).click();
await page
.getByTestId("connector-jira")
.getByRole("button", { name: "Connect" })
.click();
const modal = page.getByTestId("add-connection-modal");
// One click pane is the MCP flow (no cloud sign-in gate).
await expect(modal.getByTestId("modal-pane-one")).toBeVisible();
await expect(modal.getByTestId("modal-mcp-one-click")).toBeVisible();
// Manual keeps the existing Atlassian token fields.
await modal.getByTestId("modal-pane-manual").click();
await expect(modal.getByText("Atlassian site URL")).toBeVisible();
await expect(modal.getByText("API token")).toBeVisible();
});
test("monday detail page shows the pinned tool subset with approval badges", async ({
page,
}) => {
await openConnectors(page);
await page.getByTestId("connector-monday").click();
await expect(page.getByText("2 tools this connector adds")).toBeVisible();
await page.getByText("View", { exact: true }).click();
await expect(page.getByText("Read board", { exact: true })).toBeVisible();
await expect(page.getByText("Create item", { exact: true })).toBeVisible();
});
+37
View File
@@ -0,0 +1,37 @@
// MCP OAuth quick-add (first server: Granola): the MCP tab offers a curated Connect
// card; connecting adds the server, kicks off the browser sign-in ("signing in…"),
// and the tab's poll flips the row to connected. Sign out returns it to needs_auth.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openMcpTab(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
await page.getByRole("button", { name: "MCP servers", exact: true }).click();
}
test("granola: quick-add card → sign-in flow → connected → sign out", async ({ page }) => {
await openMcpTab(page);
// Curated card renders while granola isn't configured.
const preset = page.getByTestId("mcp-preset-granola");
await expect(preset).toContainText("Granola");
await expect(preset).toContainText("Meeting notes");
// Connect: adds the server with OAuth pending and starts the browser flow.
await preset.getByRole("button", { name: "Connect" }).click();
await expect(page.getByTestId("mcp-preset-granola")).toHaveCount(0);
const row = page.locator(".space-y-2 > div").filter({ hasText: "granola" }).first();
await expect(row).toContainText("signing in…");
// The 2s status poll flips the mock to connected with its 6 tools.
await expect(row).toContainText("connected", { timeout: 10_000 });
await expect(row).toContainText("6 tools");
await expect(row).toContainText("oauth");
// Sign out forgets tokens; the row needs auth again and offers Sign in.
await row.getByTestId("mcp-signout-granola").click();
await expect(row).toContainText("needs auth");
await expect(row.getByTestId("mcp-signin-granola")).toBeVisible();
});
+51
View File
@@ -0,0 +1,51 @@
// Left-nav polish (§20): collapse (⌘B / brand button → reveal button docks it back) and the
// RECENT-header group/filter popover (Group by Persona↔Chronological, Filter by coworker).
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("collapse hides the sidebar and reclaims the width; reveal button docks it back", async ({
page,
}) => {
await page.goto("/");
const app = page.locator(".app");
await expect(page.locator(".sidebar")).toBeVisible();
// Collapse via the brand button.
await page.getByRole("button", { name: "Collapse sidebar" }).click();
await expect(app).toHaveClass(/nav-collapsed/);
// The floating reveal affordance appears; clicking it docks the nav back.
const reveal = page.getByRole("button", { name: "Show sidebar" });
await expect(reveal).toBeVisible();
await reveal.click();
await expect(app).not.toHaveClass(/nav-collapsed/);
});
test("⌘B toggles the sidebar collapse", async ({ page }) => {
await page.goto("/");
const app = page.locator(".app");
await page.keyboard.press("Meta+b");
await expect(app).toHaveClass(/nav-collapsed/);
await page.keyboard.press("Meta+b");
await expect(app).not.toHaveClass(/nav-collapsed/);
});
test("RECENT header group/filter popover: switch grouping + see coworker filters", async ({
page,
}) => {
await page.goto("/");
const header = page.getByTestId("recent-header");
await expect(header).toContainText("Recent");
await header.getByRole("button", { name: "Group and filter conversations" }).click();
const menu = page.getByTestId("group-filter-menu");
await expect(menu).toContainText("Group by");
await expect(menu).toContainText("Filter by coworker");
// Switch to Chronological → the persona accordion collapses into a flat list (the "OpenWorker"
// persona group header is no longer a row; sessions list directly).
await menu.getByText("Chronological").click();
await expect(menu.getByText("Chronological").locator("xpath=..")).toContainText("✓");
// Filter-by-coworker checkboxes are present (none checked by default → all shown).
await expect(menu).toContainText("None checked shows all.");
});
+156
View File
@@ -0,0 +1,156 @@
// First-run onboarding (UX-DECISIONS §24 → §29 → §39): model → your tools → go.
// §39: step 1 is a provider GALLERY (cards wear their own state; a card opens its key
// form inside a fixed-height swap region; Test verifies, SAVES, and returns) and step 2
// is a two-state tools page (why-paragraph + sign-in → mini connector gallery with live
// one-click connects). Entered here via the REPLAY path (Settings ▸ Appearance ▸ "Run
// setup again") — which is itself under test.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openOnboarding(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByTestId("account-menu").getByRole("button", { name: "Settings" }).click();
await page.getByRole("button", { name: "Run setup again" }).click();
await expect(page.getByTestId("ob-step-model")).toBeVisible();
}
test("provider gallery: cards wear their state; Next arms off stored credentials", async ({
page,
}) => {
await openOnboarding(page);
// Every card carries its own status with zero clicks (the 2026-07-16 confusion —
// "is OpenAI already connected?" — is answered by the gallery itself).
await expect(page.getByTestId("ob-provider-openai")).toContainText("✓ Connected");
await expect(page.getByTestId("ob-provider-anthropic")).toContainText("✓ Connected");
await expect(page.getByTestId("ob-provider-zai")).toContainText("Not set up");
await expect(page.getByTestId("ob-provider-ollama")).toContainText("No key needed");
// Recognition-first order: anthropic before openai before the OpenAI-compat tail.
const names = await page
.getByTestId("ob-provider-gallery")
.locator("[data-testid^=ob-provider-]")
.evaluateAll((els) => els.map((e) => e.getAttribute("data-testid")));
expect(names.indexOf("ob-provider-anthropic")).toBeLessThan(names.indexOf("ob-provider-openai"));
expect(names.indexOf("ob-provider-openai")).toBeLessThan(names.indexOf("ob-provider-zai"));
// A configured provider already arms Next — no form visit required.
await expect(page.getByTestId("ob-continue")).toBeEnabled();
await page.getByTestId("ob-continue").click();
await expect(page.getByTestId("ob-step-tools")).toBeVisible();
});
test("key form: Test verifies, saves, and returns to the gallery with the ✓", async ({
page,
}) => {
await openOnboarding(page);
await page.getByTestId("ob-provider-zai").click();
// The header stays put (§39 fixed frame): the welcome headline is still on screen.
await expect(page.getByRole("heading", { name: "Welcome to OpenWorker" })).toBeVisible();
// Optional endpoint is a quiet disclosure with no explainer copy (owner call 2026-07-18).
await expect(page.getByTestId("ob-field-base_url")).toHaveCount(0);
await page.getByTestId("ob-endpoint-link").click();
await expect(page.getByTestId("ob-field-base_url")).toHaveValue(/api\.z\.ai/);
// Bad key: the error is a line, not a navigation.
await page.getByTestId("ob-field-api_key").fill("bad-key");
await page.getByTestId("ob-test").click();
await expect(page.getByText("Invalid API key.")).toBeVisible();
// Good key: state lands IN the field ("✓ Tested & saved" pill), then the form
// auto-returns to the gallery where the Z AI card now wears its ✓.
await page.getByTestId("ob-field-api_key").fill("zk-good");
await page.getByTestId("ob-test").click();
await expect(page.getByTestId("ob-saved-pill")).toBeVisible();
await expect(page.getByTestId("ob-provider-zai")).toContainText("✓ Connected", {
timeout: 5_000,
});
await expect(page.getByTestId("ob-continue")).toBeEnabled();
});
test("key form: revisiting a connected provider shows the in-field saved state; drafts survive switching", async ({
page,
}) => {
await openOnboarding(page);
// Revisit a configured provider: green in-field pill + masked placeholder — the old
// empty-password-field-reads-as-not-set-up trap (owner complaint 2026-07-16) is gone.
await page.getByTestId("ob-provider-openai").click();
await expect(page.getByTestId("ob-saved-pill")).toBeVisible();
await expect(page.getByTestId("ob-field-api_key")).toHaveAttribute("placeholder", "••••••••");
// Typed-but-unsaved input survives a peek at another provider (drafts).
await page.getByTestId("ob-back").click();
await page.getByTestId("ob-provider-zai").click();
await page.getByTestId("ob-field-api_key").fill("zk-draft");
await page.getByTestId("ob-back").click();
await page.getByTestId("ob-provider-openai").click();
await expect(page.getByTestId("ob-saved-pill")).toBeVisible();
await page.getByTestId("ob-back").click();
await page.getByTestId("ob-provider-zai").click();
await expect(page.getByTestId("ob-field-api_key")).toHaveValue("zk-draft");
// Next from a dirty form auto-verifies and saves first (2026-07-12: no hidden
// Test-then-Continue two-step), then advances.
await page.getByTestId("ob-field-api_key").fill("zk-good");
await page.getByTestId("ob-continue").click();
await expect(page.getByTestId("ob-step-tools")).toBeVisible();
});
test("tools page: sign-in morphs the page into the connector gallery; a card connects one-click", async ({
page,
}) => {
await openOnboarding(page);
await page.getByTestId("ob-continue").click();
await expect(page.getByTestId("ob-step-tools")).toBeVisible();
// Pre-sign-in (§41): the benefit rows are already there (no Connect buttons yet),
// the combined Google row says Coming soon, the band asks for sign-in, and the one
// footer button is the quiet "Continue without sign-in".
await expect(page.getByText("Chat can only advise")).toBeVisible();
await expect(page.getByTestId("ob-tool-outlook")).toContainText("Stay on top of email");
await expect(page.getByTestId("ob-tool-outlook").getByRole("button")).toHaveCount(0);
await expect(page.getByTestId("ob-tool-attio")).toContainText("Track every relationship");
await expect(page.getByTestId("ob-tool-google-soon")).toContainText("Coming soon");
await expect(page.getByText("Sign in for one-click connections")).toBeVisible();
await expect(page.getByTestId("ob-tools-skip")).toContainText("Continue without sign-in");
// Sign-in lands out-of-band; the band's SLOT stays put and flips to the congrats
// (zero layout shift), and every row grows its Connect pill.
await page.getByTestId("ob-cloud-signin").click();
await expect(page.getByTestId("ob-tools-signedin")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("ob-tools-signedin")).toContainText("Youre signed in");
await expect(
page.getByTestId("ob-tool-attio").getByRole("button", { name: "Connect" }),
).toBeVisible();
await expect(page.getByTestId("ob-tool-google-soon").getByRole("button")).toHaveCount(0);
// One-click connect: the consent completes in the (mock) browser; the poll flips the
// row to ✓ Connected. Next was armed the whole time — connecting is optional.
await page.getByTestId("ob-tool-outlook").getByRole("button", { name: "Connect" }).click();
await expect(page.getByTestId("ob-tool-outlook")).toContainText("✓ Connected", {
timeout: 10_000,
});
await expect(page.getByTestId("ob-continue-tools")).toBeEnabled();
await page.getByTestId("ob-continue-tools").click();
// Done step: the automation CTA lands on the Automations quickstart.
await expect(page.getByTestId("ob-step-done")).toBeVisible();
await page.getByTestId("ob-cta-automation").click();
await expect(page.getByTestId("onboarding")).toHaveCount(0);
await expect(page.getByRole("heading", { name: "Automations" })).toBeVisible();
});
test("tools page skips cleanly; Start working lands in a session with the panel open", async ({
page,
}) => {
await openOnboarding(page);
await page.getByTestId("ob-continue").click();
await page.getByTestId("ob-tools-skip").click();
await expect(page.getByTestId("ob-step-done")).toBeVisible();
await page.getByTestId("ob-start").click();
await expect(page.getByTestId("onboarding")).toHaveCount(0);
// §32: "Start working" lands with the rail's Access section expanded (the drawer is gone).
await expect(page.getByRole("region", { name: "Session access" })).toBeVisible();
});
@@ -0,0 +1,87 @@
import { test, expect } from "./fixtures";
// Personas is launch-flagged off by default — this suite covers the flagged-on flows.
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "1"));
});
// Regression for the invisible-after-install bug (2026-07-03): enabling a persona in
// Settings ▸ Personas must surface it EVERYWHERE without a reload — the New-Session picker and
// the grouped sidebar — via the PERSONAS_CHANGED event (and backend enable-implies-surface).
test("enabling an installed persona surfaces it in picker + sidebar without reload", async ({
page,
}) => {
await page.goto("/");
const sidebar = page.locator(".sidebar");
// Disabled install: absent from the persona picker and the grouped sidebar.
await page.getByLabel("Choose a persona").click();
const menu = page.locator(".newsplit-menu");
await expect(menu).toBeVisible();
await expect(menu.getByText("Acme Notes")).toHaveCount(0);
await page.locator(".fixed.inset-0.z-20").click(); // close via backdrop
await expect(sidebar.getByText("Acme Notes")).toHaveCount(0);
// Enable it on the Personas page.
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Personas", exact: true }).click();
const row = page.locator(".divide-y > div").filter({ hasText: "Acme Notes" });
// Controlled checkbox: the DOM state flips only after the POST round-trip, so click + expect
// (a plain .check() asserts the state synchronously and fails).
const enabled = row.getByRole("checkbox", { name: "Enabled" });
await enabled.click();
await expect(enabled).toBeChecked();
// No reload: the sidebar group and the picker both pick it up via PERSONAS_CHANGED.
await expect(sidebar.getByText("Acme Notes")).toBeVisible();
await page.getByLabel("Choose a persona").click();
await expect(page.locator(".newsplit-menu").getByText("Acme Notes")).toBeVisible();
});
// Disable-archives (§18): disabling a persona archives its conversations, so the confirm must
// interpose when there's something to archive — and only then. The sidebar section disappears
// with the persona (its sessions are archived, so the never-orphan rule no longer holds it).
test("disabling a persona with conversations asks first, then archives them", async ({
page,
}) => {
await page.goto("/");
const sidebar = page.locator(".sidebar");
await expect(sidebar.getByText("Ops", { exact: true })).toBeVisible();
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Personas", exact: true }).click();
const row = page.locator(".divide-y > div").filter({ hasText: "Ops Coworker" });
const enabled = row.getByRole("checkbox", { name: "Enabled" });
// Unchecking only ARMS the confirm — the flag must not flip yet.
await enabled.click();
const warning = page.getByTestId("persona-disable-warning-ops");
await expect(warning).toContainText("archives its 1 conversation");
await expect(enabled).toBeChecked();
// Backing out leaves everything as it was.
await page.getByRole("button", { name: "Keep enabled" }).click();
await expect(warning).toHaveCount(0);
await expect(enabled).toBeChecked();
// Arm again and confirm: persona disables, its section leaves the sidebar without a reload.
await enabled.click();
await page.getByTestId("persona-disable-confirm-ops").click();
await expect(enabled).not.toBeChecked();
await expect(sidebar.getByText("Ops", { exact: true })).toHaveCount(0);
});
test("disabling a persona with no conversations skips the confirm", async ({ page }) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Personas", exact: true }).click();
const row = page.locator(".divide-y > div").filter({ hasText: "Code" });
const enabled = row.getByRole("checkbox", { name: "Enabled" });
await enabled.click();
await expect(page.getByTestId("persona-disable-warning-code")).toHaveCount(0);
await expect(enabled).not.toBeChecked();
});
+54
View File
@@ -0,0 +1,54 @@
// Settings ▸ Models key flows on the shared provider gallery (§39 components, UX-021 page):
// bad key fails in place, a passing Test auto-saves and slides home to the gallery where the
// card wears its ✓. Providers are seeded in three states (OpenAI configured+used, Anthropic
// configured-unused, Z AI unconfigured w/ a prefilled endpoint behind the disclosure). The
// mock's /verify fails on a key containing "bad"; POST /v1/providers flips `configured`.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openModels(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Models", exact: true }).click();
await expect(page.getByTestId("set-provider-openai")).toBeVisible();
}
test("Test with a bad key fails in place; a good key saves and returns to the gallery", async ({
page,
}) => {
await openModels(page);
await page.getByTestId("set-provider-zai").click();
await page.getByTestId("set-field-api_key").fill("sk-bad-key");
await page.getByTestId("set-test").click();
await expect(page.getByText("Invalid API key.")).toBeVisible();
// A good key: Test verifies AND saves (§39) — the in-field pill confirms, then the form
// slides home and the card wears its ✓.
await page.getByTestId("set-field-api_key").fill("sk-glm-realkey");
await page.getByTestId("set-test").click();
await expect(page.getByTestId("set-saved-pill")).toContainText("Tested & saved");
await expect(page.getByTestId("set-provider-zai")).toContainText("✓ Connected", {
timeout: 5_000,
});
// State-restore regression (owner catch 2026-07-19): revisiting the just-saved provider
// must show the masked placeholder + saved pill — never the typed key restored as a draft
// (the auto-return used to stash the saved key and replay it on the next open).
await page.getByTestId("set-provider-zai").click();
await expect(page.getByTestId("set-field-api_key")).toHaveValue("");
await expect(page.getByTestId("set-field-api_key")).toHaveAttribute("placeholder", "••••••••");
await expect(page.getByTestId("set-saved-pill")).toContainText("Tested & saved");
});
test("a configured provider's form opens with the saved state, no plaintext key", async ({
page,
}) => {
await openModels(page);
await page.getByTestId("set-provider-openai").click();
// Stored credentials show as the in-field saved pill + masked placeholder — never the key.
await expect(page.getByTestId("set-saved-pill")).toContainText("Tested & saved");
await expect(page.getByTestId("set-field-api_key")).toHaveValue("");
await expect(page.getByTestId("set-field-api_key")).toHaveAttribute("placeholder", "••••••••");
});
+51
View File
@@ -0,0 +1,51 @@
// Guards the per-session directory RO/RW gate (§ roots), which since §32 lives in the rail's
// Access section under "Folders" (folder access is standing session config, not per-message
// attachment — the composer's folder popover is gone). The section lists the primary writable
// workspace, and adding a folder is gated read-only by default with an explicit "Allow writes"
// opt-in.
import { test, expect } from "./fixtures";
test("working directories: add folders with the read-only / read-write gate", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
// Expand the rail's Access section.
await page.getByTestId("access-toggle").click();
const dirs = page.getByTestId("drawer-directories");
await expect(dirs.getByText("Folders")).toBeVisible();
// The primary is the writable scratch workspace (Cowork shows it as "Temporary space").
await expect(dirs.getByText("Temporary space")).toBeVisible();
// Add a folder — the gate defaults to read-only (Allow writes OFF). The Browse button works
// in the BROWSER too (sidecar-opened native picker; owner report 2026-07-04).
await dirs.getByRole("button", { name: "Give access to a folder" }).click();
await dirs.getByRole("button", { name: "Choose location" }).click();
await expect(dirs.getByPlaceholder(/Choose or paste a folder path/)).toHaveValue(
"/tmp/picked-folder",
);
const allowWrites = dirs.locator(".addfolder-write input[type=checkbox]");
await expect(allowWrites).not.toBeChecked();
await dirs.getByPlaceholder(/Choose or paste a folder path/).fill("/tmp/ro-data");
await dirs.getByRole("button", { name: "Add", exact: true }).click();
const roRow = dirs.locator(".root-row").filter({ hasText: "/tmp/ro-data" });
await expect(roRow.getByRole("button", { name: "Read-only" })).toBeVisible();
// Add another, this time opting into writes → it lands read-write.
await dirs.getByRole("button", { name: "Give access to a folder" }).click();
await dirs.getByPlaceholder(/Choose or paste a folder path/).fill("/tmp/rw-data");
await dirs.locator(".addfolder-write input[type=checkbox]").check();
await dirs.getByRole("button", { name: "Add", exact: true }).click();
const rwRow = dirs.locator(".root-row").filter({ hasText: "/tmp/rw-data" });
await expect(rwRow.getByRole("button", { name: "Read-write" })).toBeVisible();
// Flip the read-only one to read-write via its access button (upsert re-add).
await roRow.getByRole("button", { name: "Read-only" }).click();
await expect(roRow.getByRole("button", { name: "Read-write" })).toBeVisible();
// Remove a non-primary folder — the primary can't be removed.
await rwRow.getByTitle("Remove").click();
await expect(dirs.locator(".root-row").filter({ hasText: "/tmp/rw-data" })).toHaveCount(0);
});
+88
View File
@@ -0,0 +1,88 @@
// Start-screen template tasks (§27): three concrete rows, no icon tiles, no "Set me up" list.
// Sub-lines are outcome-voiced; connection state lives in the dots + the trailing action.
// Gated row (source not live for this session) → "Configure " expands the rail's Access
// section (§32); ready row → click prefills the composer with the template stem.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("three rows, no Set-me-up; gated rows show Configure and expand the rail's Access section", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByText("What should we produce?")).toBeVisible();
// Exactly the three template tasks; the old setup list is gone.
await expect(page.locator(".task-card")).toHaveCount(3);
await expect(page.getByText("Set me up (optional)")).toHaveCount(0);
await expect(page.getByText("Give me access to a folder")).toHaveCount(0);
// Fixture session state: slack + github live, hubspot not → the HubSpot row is gated,
// with the Configure affordance visible AT REST (no hover needed — it IS the row's action);
// the github+slack automation row has everything it needs.
const hs = page.getByTestId("intro-task-hubspot");
await expect(hs).toContainText("Configure ");
await expect(hs.locator(".task-card-act")).toHaveCSS("opacity", "1");
await expect(page.getByTestId("intro-task-github-slack")).toContainText("Start →");
// Sub-lines describe the task's outcome, never connection state.
await expect(hs).toContainText("Sources, stages, and who needs follow-up");
await expect(hs).not.toContainText(/connect/i);
// Configure → the rail's Access section expands (§32), not a bespoke setup surface.
await hs.click();
await expect(page.getByRole("region", { name: "Session access" })).toBeVisible();
// No composer prefill happened on the gated click.
await expect(page.getByPlaceholder(/Ask the coworker/)).toHaveValue("");
});
test("ready rows reveal Start → on hover and prefill the composer", async ({ page }) => {
// Make every source live for this session (registered after the fixture's routes → wins).
await page.route("**/v1/sessions/*/connections*", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
connected: [
{ connector: "hubspot", enabled: true, detail: "" },
{ connector: "github", enabled: true, detail: "" },
{ connector: "slack", enabled: true, detail: "" },
],
recommended: [],
attention: 0,
}),
}),
);
await page.goto("/");
const hs = page.getByTestId("intro-task-hubspot");
await expect(hs).toContainText("Start →");
// The action is hover-revealed on ready rows (hidden at rest).
await expect(hs.locator(".task-card-act")).toHaveCSS("opacity", "0");
await hs.hover();
await expect(hs.locator(".task-card-act")).toHaveCSS("opacity", "1");
await hs.click();
await expect(page.getByPlaceholder(/Ask the coworker/)).toHaveValue(/HubSpot leads/);
// Both sources live → the automation row is ready too; its prefill is the recipe stem.
const gh = page.getByTestId("intro-task-github-slack");
await expect(gh).toContainText("Start →");
await gh.click();
await expect(page.getByPlaceholder(/Ask the coworker/)).toHaveValue(/weekly progress report/);
});
test("folder task opens the inline add-folder form; adding a folder prefills the composer", async ({
page,
}) => {
await page.goto("/");
// No shared folder yet (the fixture root is the primary scratch) → the row expands the form.
await page.getByTestId("intro-task-folder").click();
const path = page.getByPlaceholder("Choose or paste a folder path…");
await expect(path).toBeVisible();
await path.fill("/Users/me/Reports");
await page.getByRole("button", { name: "Add", exact: true }).click();
await expect(page.getByPlaceholder(/Ask the coworker/)).toHaveValue(
/Analyze the files in this folder/,
);
});
+77
View File
@@ -0,0 +1,77 @@
// Session-screen cleanup (§22): the contextual top-left cluster ([sidebar][+][search], rendered
// ONLY while the sidebar is collapsed), the centered facts subtitle (persona · model — fixed
// facts replacing the locked-model pill and the topbar About-persona button), and the model
// picker's fresh-session-only placement.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("top-left cluster renders only while the sidebar is collapsed", async ({ page }) => {
await page.goto("/");
// Expanded sidebar owns those actions — no duplicate cluster.
await expect(page.locator(".sidebar")).toBeVisible();
await expect(page.getByTestId("topbar-cluster")).toHaveCount(0);
// Collapse → the cluster appears with all three actions; the floating reveal button does NOT
// double up on the session surface (the cluster's sidebar button replaces it).
await page.keyboard.press("Meta+b");
const cluster = page.getByTestId("topbar-cluster");
await expect(cluster).toBeVisible();
await expect(cluster.getByRole("button", { name: "Show sidebar" })).toBeVisible();
await expect(cluster.getByRole("button", { name: "New session" })).toBeVisible();
await expect(cluster.getByRole("button", { name: "Search" })).toBeVisible();
await expect(page.locator(".nav-reveal-btn")).toHaveCount(0);
// The cluster's search opens the command-palette overlay.
await cluster.getByRole("button", { name: "Search" }).click();
await expect(page.getByPlaceholder("Search chats")).toBeVisible();
await page.keyboard.press("Escape");
// The cluster's sidebar button docks the nav back — and the cluster leaves with it.
await cluster.getByRole("button", { name: "Show sidebar" }).click();
await expect(page.locator(".app")).not.toHaveClass(/nav-collapsed/);
await expect(page.getByTestId("topbar-cluster")).toHaveCount(0);
});
test("facts subtitle: absent on a fresh session, persona · model after the first turn; click → persona page", async ({
page,
}) => {
await page.goto("/");
// Fresh-ish (boot-resumed, no rendered history): no subtitle, no old About-persona button —
// and the model is a live PICKER in the composer (fresh sessions choose; nothing is locked yet).
await expect(page.getByTestId("session-subtitle")).toHaveCount(0);
await expect(page.getByRole("button", { name: "About this persona" })).toHaveCount(0);
await expect(page.locator(".dd").filter({ hasText: "Claude Opus 4.8" })).toBeVisible();
// First turn → the model chip leaves the composer; the facts move up to the subtitle.
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("hello");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText(/Echo: hello/)).toBeVisible();
const sub = page.getByTestId("session-subtitle");
await expect(sub).toContainText("Coworker · Claude Opus 4.8");
await expect(page.locator(".dd").filter({ hasText: "Claude Opus 4.8" })).toHaveCount(0);
// The subtitle is the session's fixed facts — clicking it opens the coworker (persona) page,
// replacing the old topbar sliders button.
await sub.click();
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
});
test("composer is three controls (+ attach · Mode · send); folder and branch chips are gone", async ({
page,
}) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
await expect(page.getByRole("button", { name: "Attach" })).toBeVisible();
await expect(page.getByRole("button", { name: "Mode", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Send" })).toBeVisible();
// The folder/roots popover trigger and the standalone Inbox control left the composer (§22).
await expect(page.getByTitle(/director(y|ies) the agent can use/)).toHaveCount(0);
await expect(page.getByTitle("Inbox routing")).toHaveCount(0);
await expect(page.locator(".wschip")).toHaveCount(0);
await expect(page.locator(".wsbranch")).toHaveCount(0);
});
+121
View File
@@ -0,0 +1,121 @@
import { test, expect } from "./fixtures";
// Guards the Settings-as-page refactor (§13, IA per UX-021): the ⚙ menu opens a full-page
// surface with a left sub-nav — General · Models · Voice input — and each section renders.
// Files is a card inside General; Personas is launch-flagged off.
test("Settings opens as a full page and navigates sections", async ({ page }) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
// Full-page: left sub-nav + the General section (no modal backdrop).
await expect(page.getByRole("heading", { name: "General" })).toBeVisible();
await expect(page.locator(".modal-backdrop")).toHaveCount(0);
for (const label of ["General", "Models", "Voice input"]) {
await expect(page.getByRole("button", { name: label, exact: true })).toBeVisible();
}
// Folded/hidden tabs: Files is a General card now; Personas is launch-flagged off.
await expect(page.getByRole("button", { name: "Files", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Personas", exact: true })).toHaveCount(0);
// The Files card lives inside General.
await expect(page.getByText("Each conversation gets its own folder")).toBeVisible();
await page.getByRole("button", { name: "Models", exact: true }).click();
await expect(page.getByTestId("set-provider-openai")).toBeVisible();
});
// The launch flag brings the Personas tab back (the gallery/persona suites rely on it).
test("Settings: Personas tab returns behind the launch flag", async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "1"));
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Personas", exact: true }).click();
await expect(page.getByText("Add personas")).toBeVisible();
});
// UX-021: Settings ▸ Models is the shared provider gallery (§39 components). Cards wear
// their own state (✓ Connected · used …); a vendor card opens the shared key form with the
// prefilled endpoint behind the disclosure; unconfigured providers preview their models.
test("Models: provider gallery states; vendor form previews models", async ({ page }) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Models", exact: true }).click();
// Card states from the fixtures: openai configured+used, anthropic configured, zai not.
await expect(page.getByTestId("set-provider-openai")).toContainText("✓ Connected · used 2h ago");
await expect(page.getByTestId("set-provider-anthropic")).toContainText("✓ Connected");
await expect(page.getByTestId("set-provider-zai")).toContainText("Not set up");
await expect(page.getByTestId("set-provider-ollama")).toContainText("No key needed");
// The composer-picker card lists the curated models with provider tags.
const picker = page.getByTestId("composer-picker");
await expect(picker).toContainText("In the composer's picker");
// Vendor form: blurb renders; the prefilled endpoint hides behind the disclosure.
await page.getByTestId("set-provider-zai").click();
await expect(page.getByText(/Uses Z AI's OpenAI-compatible API/)).toBeVisible();
await page.getByTestId("set-endpoint-link").click();
await expect(page.getByTestId("set-field-base_url")).toHaveValue("https://api.z.ai/api/paas/v4");
// Unconfigured providers still preview their curated models (read-only, matrix labels).
const preview = page.getByTestId("model-preview");
await expect(preview).toContainText("Included models");
await expect(preview).toContainText("GLM-5.2 · Z AI");
// Back to the gallery via the crumb.
await page.getByTestId("set-back").click();
await expect(page.getByTestId("set-provider-openai")).toBeVisible();
});
// UX-021: a configured provider's form shows the in-field saved state and the Remove key…
// affordance; removing reverts the card to "Not set up".
test("Models: Remove key reverts a configured provider", async ({ page }) => {
await page.goto("/");
page.on("dialog", (d) => d.accept());
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Models", exact: true }).click();
await page.getByTestId("set-provider-anthropic").click();
await expect(page.getByTestId("set-saved-pill")).toContainText("Tested & saved");
await page.getByTestId("set-remove-key").click();
// Back on the gallery, the card has forgotten its key.
await expect(page.getByTestId("set-provider-anthropic")).toContainText("Not set up");
});
// Token savings (owner ask 2026-07-17; moved under Models by UX-021): the card renders with
// the PDF fallback segmented control + attach thresholds, and edits POST through.
test("Settings: Token savings card edits PDF fallback and thresholds", async ({ page }) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Models", exact: true }).click();
const card = page.getByTestId("token-savings-card");
await expect(card).toBeVisible();
await expect(card.getByText("Token savings")).toBeVisible();
// Fallback mode: fixture says "text"; switching marks "Send page images" active.
const seg = page.getByTestId("pdf-fallback");
await expect(seg.getByRole("button", { name: "Extract text" })).toHaveClass(/active/);
const [req] = await Promise.all([
page.waitForRequest((r) => r.url().endsWith("/v1/settings/pdf") && r.method() === "POST"),
seg.getByRole("button", { name: "Send page images" }).click(),
]);
expect(req.postDataJSON()).toEqual({ pdf_fallback: "images" });
await expect(seg.getByRole("button", { name: "Send page images" })).toHaveClass(/active/);
// Thresholds: fixture starts at 2 pages / 10 MB; editing pages POSTs the clamped value.
await expect(card.getByTestId("pdf-max-pages")).toHaveValue("2");
await expect(card.getByTestId("pdf-max-mb")).toHaveValue("10");
const [req2] = await Promise.all([
page.waitForRequest((r) => r.url().endsWith("/v1/settings/pdf") && r.method() === "POST"),
card.getByTestId("pdf-max-pages").fill("30"),
]);
expect(req2.postDataJSON()).toEqual({ pdf_max_pages: 30 });
});
+61
View File
@@ -0,0 +1,61 @@
// The sidebar bottom is exactly ONE row — the account anchor (UX-DECISIONS §26).
// Contract under test: no "Settings & more", no standalone Inbox/Connectors rows; the
// inbox chip is state-driven (accent + count when pending) and clicks STRAIGHT to Inbox
// while the rest of the row opens the account menu, which always lists Inbox + Connectors.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("the bottom is one account row — the old rows are gone", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("account-row")).toBeVisible();
await expect(page.getByRole("button", { name: /Settings & more/i })).toHaveCount(0);
// No standalone sidebar Inbox row: outside the menu, "Inbox" exists only as the chip.
await expect(page.locator(".sidebar").getByRole("button", { name: "Inbox", exact: true })).toHaveCount(0);
});
test("pending items: the chip carries the count and goes straight to Inbox — no menu", async ({
page,
}) => {
await page.goto("/");
const chip = page.getByTestId("inbox-chip");
await expect(chip).toContainText(/\d/); // fixtures seed pending attention → accent count
await chip.click();
await expect(page.getByTestId("account-menu")).toHaveCount(0); // the chip never opens the menu
await expect(page.getByText("Approve: run_shell")).toBeVisible(); // Inbox opened directly
});
test("the account menu: Inbox + Connectors always listed; Settings carries the shortcut hint", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("account-row").click();
const menu = page.getByTestId("account-menu");
await expect(menu.getByRole("button", { name: "Inbox" })).toBeVisible();
await expect(menu.getByRole("button", { name: "Connectors", exact: true })).toBeVisible();
await expect(menu.getByRole("button", { name: /Settings/ })).toContainText("⌘");
await expect(menu.getByRole("button", { name: "Automations", exact: true })).toBeVisible();
await expect(menu.getByRole("button", { name: "Activity", exact: true })).toBeVisible();
});
test("Activity in the menu is the audit log; Unrouted lives under Inbox ▸ Configure", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByTestId("account-menu").getByRole("button", { name: "Activity", exact: true }).click();
await expect(page.getByRole("heading", { name: "Activity" })).toBeVisible();
// §28: Messaging routing left the Connectors sub-nav entirely (Connectors · MCP only)…
await page.getByTestId("account-row").click();
await page.getByTestId("account-menu").getByRole("button", { name: "Connectors", exact: true }).click();
await expect(page.getByRole("button", { name: "MCP servers" })).toBeVisible();
await expect(page.getByRole("button", { name: /Messaging routing/ })).toHaveCount(0);
// The old fourth sub-nav tab is gone — exactly one page is named Activity now.
await expect(page.getByRole("button", { name: "Activity", exact: true })).toHaveCount(0);
// …and Unrouted rides the Inbox's Configure tab.
await page.getByTestId("account-row").click();
await page.getByTestId("account-menu").getByRole("button", { name: "Inbox" }).click();
await page.getByTestId("inbox-tab-configure").click();
await expect(page.getByTestId("unrouted-section")).toBeVisible();
});
@@ -0,0 +1,72 @@
// UX-023: automations get sidebar presence — an "Automations" nav row under Search
// (aggregate unseen badge) and a "Scheduled" band with ONE entry per automation
// (name + cadence + unseen-runs badge). Opening an automation's detail marks it
// seen: the badge clears immediately via the AUTOMATIONS_CHANGED broadcast, and
// runs newer than the pre-open mark wear a "new" pill inside the detail.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("nav row + Scheduled band render with unseen badges; runs stay out of Recent", async ({
page,
}) => {
await page.goto("/");
// Nav row sits right under Search — no badge of its own (owner call: the
// Scheduled entry alone carries the count).
const nav = page.getByTestId("nav-automations");
await expect(nav).toBeVisible();
await expect(nav).toContainText("Automations");
await expect(nav).not.toContainText("2");
// Scheduled band: one entry PER AUTOMATION — never per run. The noisy task wears
// its badge; the quiet one shows none.
const band = page.getByTestId("scheduled-band");
await expect(band.getByTestId("scheduled-task-1")).toContainText("Daily AI News");
await expect(band.getByTestId("scheduled-task-1")).toContainText("2");
await expect(band.getByTestId("scheduled-task-2")).toContainText("Weekly CRM digest");
await expect(band.getByTestId("scheduled-task-2")).not.toContainText("2");
// Runs never appear as session rows (their sessions are __run__-prefixed and the
// server hides them) — the band's entries are the only automation presence.
await expect(page.getByTitle("__run__r1")).toHaveCount(0);
});
test("opening a Scheduled entry lands on the detail, marks seen, clears the badge", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("scheduled-task-1").click();
// The Automations surface opens ON that automation's detail…
await expect(page.getByRole("heading", { name: "Daily AI News" })).toBeVisible();
// …runs newer than the pre-open seen mark wear the "new" pill…
await expect(page.getByTestId("run-new").first()).toBeVisible();
// …and the entry's badge clears without waiting for any poll (mark-seen broadcast).
await expect(page.getByTestId("scheduled-task-1")).not.toContainText("2");
});
test("the nav row opens the Automations overview", async ({ page }) => {
await page.goto("/");
await page.getByTestId("nav-automations").click();
await expect(page.getByRole("heading", { name: "Automations" })).toBeVisible();
});
test("deleting an automation clears the band at once; nav re-entry lands on the list", async ({
page,
}) => {
await page.goto("/");
// Open the automation from the band, delete it from the detail.
await page.getByTestId("scheduled-task-2").click();
await expect(page.getByRole("heading", { name: "Weekly CRM digest" })).toBeVisible();
await page.getByRole("button", { name: /Delete/ }).click();
// The Scheduled band drops the entry immediately (broadcast, not the 15s poll)…
await expect(page.getByTestId("scheduled-task-2")).toHaveCount(0);
// …and after visiting a session, the nav row must land on the OVERVIEW — the
// remembered detail target for a deleted automation once left "Loading…" forever.
await page.getByTitle("Weekly plan 1").click();
await page.getByTestId("nav-automations").click();
await expect(page.getByRole("heading", { name: "Automations" })).toBeVisible();
await expect(page.getByText("Loading…")).toHaveCount(0);
});
+105
View File
@@ -0,0 +1,105 @@
import { test, expect } from "./fixtures";
// Sidebar session lifecycle (owner testing pass, 2026-07-03): the peek cap (sessions_peek=5 →
// "Show more (2)" with 7 sessions), reversible archive with the Archived disclosure, and the
// two-step delete (Delete arms → "Delete?" confirms). All row actions sit behind the per-row
// ⋮ kebab (FB-011), so each flow goes hover → kebab → menu item.
test("session list caps at the peek count with Show more", async ({ page }) => {
await page.goto("/");
// Boot resumes a cowork session, so the Coworker accordion body is expanded.
await expect(page.getByTitle("Weekly plan 1")).toBeVisible();
await expect(page.getByTitle("Weekly plan 5")).toBeVisible();
await expect(page.getByTitle("Weekly plan 6")).toHaveCount(0);
await page.getByRole("button", { name: "Show more (2)" }).click();
await expect(page.getByTitle("Weekly plan 6")).toBeVisible();
await expect(page.getByTitle("Weekly plan 7")).toBeVisible();
});
test("archive via the row menu is reversible via the Archived disclosure", async ({ page }) => {
await page.goto("/");
const row = page.getByTitle("Weekly plan 2");
await expect(row).toBeVisible();
await row.hover();
await row.getByTestId("row-menu").click();
await row.getByTestId("row-menu-archive").click();
// Gone from the main list; parked under the Archived disclosure.
await expect(page.getByTitle("Weekly plan 2")).toHaveCount(0);
await page.getByRole("button", { name: /Archived \(1\)/ }).click();
const archivedRow = page.getByTitle("Weekly plan 2");
await expect(archivedRow).toBeVisible();
// Unarchive (same menu slot on an archived row) brings it straight back; the disclosure
// disappears with its last item.
await archivedRow.hover();
await archivedRow.getByTestId("row-menu").click();
await expect(archivedRow.getByTestId("row-menu-archive")).toHaveText("Unarchive");
await archivedRow.getByTestId("row-menu-archive").click();
await expect(page.getByRole("button", { name: /Archived/ })).toHaveCount(0);
await expect(page.getByTitle("Weekly plan 2")).toBeVisible();
});
test("mention-spawned sessions collapse under From Slack with the platform icon (§31)", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTitle("Weekly plan 1")).toBeVisible();
// Collapsed by default with a count; the session row hidden until expanded…
const toggle = page.getByTestId("from-slack-toggle");
await expect(toggle).toContainText("From Slack (1)");
await expect(page.getByTitle("#general — check the deploy?")).toHaveCount(0);
await toggle.click();
const row = page.getByTitle("#general — check the deploy?");
await expect(row).toBeVisible();
// …wearing the Slack logo (hover-hidden cluster, so assert attachment not visibility)…
await expect(
page.getByTestId("from-slack-list").locator('[data-logo="slack"]'),
).toHaveCount(1);
// …and never duplicated into any other list.
await expect(page.getByTitle("#general — check the deploy?")).toHaveCount(1);
});
test("pin via the row menu moves the session to the Pinned band and back", async ({ page }) => {
await page.goto("/");
const row = page.getByTitle("Weekly plan 4");
await expect(row).toBeVisible();
await row.hover();
await row.getByTestId("row-menu").click();
await expect(row.getByTestId("row-menu-pin")).toHaveText("Pin");
await row.getByTestId("row-menu-pin").click();
// Pinned rows live ONLY in the cross-persona Pinned band — no duplicate in the body.
const pinnedBand = page.getByText("Pinned", { exact: true }).locator("..");
await expect(pinnedBand.getByTitle("Weekly plan 4")).toBeVisible();
await expect(page.getByTitle("Weekly plan 4")).toHaveCount(1);
const pinnedRow = pinnedBand.getByTitle("Weekly plan 4");
await pinnedRow.hover();
await pinnedRow.getByTestId("row-menu").click();
await expect(pinnedRow.getByTestId("row-menu-pin")).toHaveText("Unpin");
await pinnedRow.getByTestId("row-menu-pin").click();
await expect(pinnedBand.getByTitle("Weekly plan 4")).toHaveCount(0);
await expect(page.getByTitle("Weekly plan 4")).toHaveCount(1);
});
test("delete is two-step: the menu's Delete arms, Delete? confirms", async ({ page }) => {
await page.goto("/");
const row = page.getByTitle("Weekly plan 3");
await expect(row).toBeVisible();
await row.hover();
await row.getByTestId("row-menu").click();
await row.getByTestId("row-menu-delete").click();
// First click only ARMS — the menu stays open showing the confirm affordance, the row remains.
await expect(row.getByTestId("row-menu-delete")).toHaveText("Delete?");
await expect(page.getByTitle("Weekly plan 3")).toHaveCount(1);
await row.getByTestId("row-menu-delete").click();
await expect(page.getByTitle("Weekly plan 3")).toHaveCount(0);
});
+82
View File
@@ -0,0 +1,82 @@
// The Slack rosters: pick people from the workspace directory (instead of the
// park→approve-only flow) and resolve channel NAMES to ids in the channel picker.
// Both are reads on scopes every install already granted — no consent bump.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openSlackPage(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
await page.getByTestId("connector-slack").click();
}
test("people picker: type a name, pick it, chip lands with the display name", async ({
page,
}) => {
await openSlackPage(page);
// T1DL starts empty → the hint row carries the picker.
await page.getByTestId("add-person-T1DL").click();
const picker = page.getByTestId("person-picker");
await picker.getByPlaceholder("Type a name…").fill("ro");
await page.getByTestId("pick-person-U8ROHIT").click();
// The chip shows the display name immediately (no first message needed).
const group = page.getByTestId("slack-workspace-T1DL");
await expect(group).toContainText("Rohit Prasad");
await expect(page.getByTestId("person-picker")).toHaveCount(0);
// The other workspace is untouched.
await expect(page.getByTestId("slack-workspace-T2AC")).toContainText("No one allowed yet");
});
test("people picker: guests are tagged, allowed users drop out of the list", async ({
page,
}) => {
await openSlackPage(page);
await page.getByTestId("add-person-T1DL").click();
const picker = page.getByTestId("person-picker");
await expect(picker.getByTestId("pick-person-U7CAL")).toContainText("guest");
await picker.getByPlaceholder("Type a name…").fill("maya");
await picker.getByTestId("pick-person-U9MAYA").click();
await expect(page.getByTestId("slack-workspace-T1DL")).toContainText("Maya Chen");
// Reopen: Maya is allowed now, so she's no longer offered.
await page.getByTestId("add-person-T1DL").click();
await expect(page.getByTestId("person-picker")).toBeVisible();
await expect(page.getByTestId("pick-person-U9MAYA")).toHaveCount(0);
await expect(page.getByTestId("pick-person-U8ROHIT")).toBeVisible();
});
test("channel typeahead: a NAME resolves to the workspace's id-address", async ({
page,
}) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
await page.getByTestId("access-toggle").click();
await page.getByRole("button", { name: /Channels · 0/ }).click();
const input = page.getByPlaceholder("slack:C0123 or channel link");
await input.fill("launch");
// Two workspaces are connected → the hit is labeled with its workspace.
const hit = page.getByTestId("roster-channel-slack:T1DL/C9LAUNCH");
await expect(hit).toContainText("#launch-team");
await expect(hit).toContainText("deeplearning.ai");
await hit.click();
// Display = the NAME after a pick (owner catch 2026-07-11: raw ids leaked into the box);
// the raw address survives underneath — the tooltip carries it and Add subscribes by id.
await expect(input).toHaveValue("#launch-team");
await expect(input).toHaveAttribute("title", "slack:T1DL/C9LAUNCH");
await page.getByRole("button", { name: "Add", exact: true }).click();
await expect(page.getByText(/Subscribed channels · 1/)).toBeVisible();
});
test("channel typeahead: private and not-a-member states are honest", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
await page.getByTestId("access-toggle").click();
await page.getByRole("button", { name: /Channels · 0/ }).click();
await page.getByPlaceholder("slack:C0123 or channel link").fill("l");
await expect(page.getByTestId("roster-channel-slack:T1DL/C8LEADS")).toContainText("🔒");
await expect(page.getByTestId("roster-channel-slack:T1DL/C7LOBBY")).toContainText(
"invite @ocw",
);
});
+82
View File
@@ -0,0 +1,82 @@
// Slack connection health (M3.6 Step 2, UX-DECISIONS §21): the list chip and the
// detail status line surface three honest layers — cloud sign-in, the desktop↔relay
// socket, per-workspace bot tokens — and never a synthetic "Slack is down" claim.
// The fixture's /v1/connectors/slack/status reads live+signed-out by default; each
// state here is forced with a later page.route override (later routes match first).
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
function statusPayload(overrides: any = {}) {
return {
ok: true,
mode: "relay",
relay: { state: "live", reconnects: 0, last_event_at: 1751970000, last_error: "" },
signed_in: true,
teams: { T1DL: { token_ok: true }, T2AC: { token_ok: true } },
...overrides,
};
}
function forceStatus(page, overrides: any) {
return page.route("**/v1/connectors/slack/status", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(statusPayload(overrides)),
}),
);
}
test("signed out: chip and status line say Sign-in needed", async ({ page }) => {
await openConnectors(page);
await expect(page.getByTestId("connector-slack")).toContainText("Sign-in needed");
await page.getByTestId("connector-slack").click();
await expect(page.getByTestId("slack-mode-badge")).toContainText(
"Sign-in needed — relaying is paused",
);
});
test("signed in + live socket: Live everywhere", async ({ page }) => {
await forceStatus(page, {});
await openConnectors(page);
await expect(page.getByTestId("connector-slack")).toContainText("Live");
await page.getByTestId("connector-slack").click();
await expect(page.getByTestId("slack-mode-badge")).toContainText("Live · managed relay");
});
test("relay socket reconnecting: warn chip + status line", async ({ page }) => {
await forceStatus(page, {
relay: { state: "reconnecting", reconnects: 3, last_event_at: null, last_error: "boom" },
});
await openConnectors(page);
await expect(page.getByTestId("connector-slack")).toContainText("Reconnecting");
await page.getByTestId("connector-slack").click();
await expect(page.getByTestId("slack-mode-badge")).toContainText("Reconnecting to the relay");
});
test("relay unreachable: Offline, not a Slack-outage claim", async ({ page }) => {
await forceStatus(page, {
relay: { state: "offline", reconnects: 0, last_event_at: null, last_error: "unreachable" },
});
await openConnectors(page);
await expect(page.getByTestId("connector-slack")).toContainText("Offline");
await page.getByTestId("connector-slack").click();
await expect(page.getByTestId("slack-mode-badge")).toContainText("can't reach the relay");
});
test("one dead bot token: ⚠ chip + a warning on THAT workspace only", async ({ page }) => {
await forceStatus(page, {
teams: { T1DL: { token_ok: true }, T2AC: { token_ok: false } },
});
await openConnectors(page);
await expect(page.getByTestId("connector-slack")).toContainText("Token");
await page.getByTestId("connector-slack").click();
await expect(page.getByTestId("token-warn-T2AC")).toContainText("Token revoked");
await expect(page.getByTestId("token-warn-T1DL")).toHaveCount(0);
});
+89
View File
@@ -0,0 +1,89 @@
// The Slack detail page (M3.6, UX-DECISIONS §21): one group per workspace with
// People / Waiting / Listening rows, add-workspace via the header-button MODAL
// (One click | Manual), per-workspace disconnect (stop-relaying-only), and the
// manual Socket-Mode card so neither connect path regresses.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openSlackPage(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
await page.getByTestId("connector-slack").click();
}
test("lists every connected workspace as its own group", async ({ page }) => {
await openSlackPage(page);
await expect(page.getByTestId("slack-workspace-T1DL")).toContainText("deeplearning.ai");
await expect(page.getByTestId("slack-workspace-T2AC")).toContainText("acme-partners");
// The workspace domain is the visible differentiator (ids demote to hover).
await expect(page.getByTestId("slack-workspace-T1DL")).toContainText("· dlaiteam");
await expect(page.getByTestId("slack-workspace-T2AC")).toContainText("· acmehq");
// the workspace with people/parked shows the People row; the quiet one shows the hint
await expect(page.getByTestId("slack-workspace-T1DL")).toContainText("People");
await expect(page.getByTestId("slack-workspace-T2AC")).toContainText("No one allowed yet");
});
test("Add workspace opens the modal; signed out shows the sign-in hint, signed in installs", async ({
page,
}) => {
await openSlackPage(page);
await page.getByTestId("add-workspace-btn").click();
const modal = page.getByTestId("add-connection-modal");
await expect(modal).toContainText("Sign in to OpenWorker Cloud"); // signed out
// Manual pane is right there too — both modes, one entry point
await modal.getByTestId("modal-pane-manual").click();
await expect(modal.getByPlaceholder("Bot token · xoxb-…")).toBeVisible();
await page.keyboard.press("Escape");
// sign in from the list's cloud strip, then install one-click
await page.getByTestId("connectors-breadcrumb").click();
await page.getByTestId("account-row").click();
await page.getByTestId("account-sign-in").click();
await expect(page.getByTestId("account-row")).toContainText("Rohit", { timeout: 10_000 });
await page.getByTestId("connector-slack").click();
await page.getByTestId("add-workspace-btn").click();
await page.getByTestId("modal-add-to-slack").click();
// the mock completes the browser install instantly; the page's poll shows it
await expect(page.getByTestId("slack-workspace-T3NEW")).toContainText("new-workspace", {
timeout: 10_000,
});
await expect(page.getByTestId("slack-workspace-T1DL")).toBeVisible(); // existing ones stay
});
test("disconnect removes one workspace and keeps the rest relaying", async ({ page }) => {
await openSlackPage(page);
await page.getByTestId("disconnect-workspace-T2AC").click();
await expect(page.getByTestId("slack-workspace-T2AC")).toHaveCount(0);
await expect(page.getByTestId("slack-workspace-T1DL")).toBeVisible();
});
test("manual Socket Mode: one card with the flat allow-list (no regression)", async ({
page,
}) => {
// Override the connectors payload AFTER mockApi so this test sees a manual-mode Slack
// (routes registered later match first).
await page.route("**/v1/connectors", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
connectors: [
{
name: "slack", title: "Slack", icon: "#", blurb: "Two-way Slack messaging.",
auth: "bot_token", two_way: true, available: true, brand_color: "#611f69",
logo: "slack", fields: [], instructions: [], connected: true, account: "acme",
enabled: true, allowed_users: ["U0OK"], allowed_user_names: { U0OK: "Rohit" },
tools: [], managed: true, managed_profile: false, mode: "", workspaces: [],
unauthorized: [],
},
],
}),
}),
);
await openSlackPage(page);
await expect(page.getByTestId("slack-mode-badge")).toContainText("Socket Mode");
const card = page.getByTestId("slack-manual-card");
await expect(card).toContainText("acme");
await expect(card).toContainText("Rohit"); // flat allow-list chip, named
});
+10
View File
@@ -0,0 +1,10 @@
import { test, expect } from "./fixtures";
test("app loads with the persona nav and composer", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("OpenWorker").first()).toBeVisible();
// New session + Search are the fixed top nav.
await expect(page.getByRole("button", { name: /New session/i })).toBeVisible();
// The persona groups render from /v1/personas.
await expect(page.getByText("Ops", { exact: true })).toBeVisible();
});
+100
View File
@@ -0,0 +1,100 @@
import { test, expect } from "./fixtures";
// Guards the per-session Slack channels drill-down (§14, hosted in the rail's Access section
// since §32): the "Channels" affordance is gated to two-way connectors, opens an inline child
// view, and add/remove round-trip through the subscribe APIs.
test("Slack channels drill-down: gating, add (auto-prefixed), remove", async ({ page }) => {
await page.goto("/");
// Open the pinned cowork session, then expand the rail's Access section.
await page.getByText("Draft the launch note").first().click();
await page.getByTestId("access-toggle").click();
const body = page.getByRole("region", { name: "Session access" });
await expect(body.getByText("Slack", { exact: true })).toBeVisible();
// Gating: only the two-way connector (Slack) gets a Channels affordance — not Browser.
await expect(page.getByRole("button", { name: /Channels ·/ })).toHaveCount(1);
await expect(page.getByRole("button", { name: /Channels · 0/ })).toBeVisible();
// Drill in.
await page.getByRole("button", { name: /Channels · 0/ }).click();
await expect(page.getByText("Slack channels")).toBeVisible();
await expect(page.getByText(/Not listening to any Slack channel yet/)).toBeVisible();
// Add a bare channel id — the panel scopes it to the connector (→ "slack:C0123").
await page.getByPlaceholder("slack:C0123 or channel link").fill("C0123");
await page.getByRole("button", { name: "Add", exact: true }).click();
await expect(page.getByText("slack:C0123", { exact: true })).toBeVisible();
await expect(page.getByText(/Subscribed channels · 1/)).toBeVisible();
// Remove it → back to the empty state.
await page.getByTitle("Stop listening").click();
await expect(page.getByText(/Not listening to any Slack channel yet/)).toBeVisible();
// Back returns to the Sources list.
await page.getByRole("button", { name: "Back to sources" }).click();
await expect(body.getByText("Slack", { exact: true })).toBeVisible();
});
// The recent-channels dropdown is a hand-rolled popover (NOT a <datalist> — WKWebView renders
// none), fed by /v1/channels/recent: focus opens it, typing filters, picking fills the input.
test("recent channels popover: opens on focus, filters, picks", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
await page.getByTestId("access-toggle").click();
await page.getByRole("button", { name: /Channels · 0/ }).click();
const input = page.getByPlaceholder("slack:C0123 or channel link");
await input.click();
const pop = page.getByTestId("channel-suggestions");
// Named channels show "#name" with the address as a sub-label; unnamed fall back to the address.
await expect(pop.getByText("#ocw-test")).toBeVisible();
await expect(pop.getByText("slack:C0AAA111")).toBeVisible();
await expect(pop.getByText("bob: deploy failed")).toBeVisible();
// Typing part of the channel NAME filters too…
await input.fill("ocw");
await expect(pop.getByText("#ocw-test")).toBeVisible();
await expect(pop.getByText("slack:C0BBB222")).toHaveCount(0);
await input.fill("");
// Typing filters (matches address or message text)…
await input.fill("deploy");
await expect(pop.getByText("slack:C0AAA111")).toHaveCount(0);
await expect(pop.getByText("slack:C0BBB222")).toBeVisible();
// …and picking fills the input and closes the popover.
await pop.getByText("slack:C0BBB222").click();
await expect(input).toHaveValue("slack:C0BBB222");
await expect(page.getByTestId("channel-suggestions")).toHaveCount(0);
await page.getByRole("button", { name: "Add", exact: true }).click();
await expect(page.getByText(/Subscribed channels · 1/)).toBeVisible();
});
// Address-form fixes: a pasted Copy-link URL resolves to the id; a bare #name is rejected
// with the paste-the-ID hint instead of storing a dead subscription.
test("channel add: link URLs resolve, bare #names are rejected with a hint", async ({
page,
}) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
await page.getByTestId("access-toggle").click();
await page.getByRole("button", { name: /Channels · 0/ }).click();
const input = page.getByPlaceholder("slack:C0123 or channel link");
await input.fill("#general");
await page.getByRole("button", { name: "Add", exact: true }).click();
await expect(page.getByTestId("channel-add-error")).toContainText(
"paste the channel ID",
);
await expect(page.getByText(/Subscribed channels · 1/)).toHaveCount(0);
await input.fill("https://acme.slack.com/archives/C0123ABC");
// Typing again clears the rejection.
await expect(page.getByTestId("channel-add-error")).toHaveCount(0);
await page.getByRole("button", { name: "Add", exact: true }).click();
await expect(page.getByText("slack:C0123ABC")).toBeVisible();
await expect(page.getByText(/Subscribed channels · 1/)).toBeVisible();
});
@@ -0,0 +1,94 @@
import { test, expect } from "./fixtures";
// Standing scoped approvals (UX-DECISIONS §25): the creation consent card renders the agent's
// proposed permission set (reads = disclosure, writes = grants); a recurring run's approval card
// offers the task-persistent "Allow every time" (in-app, run context only); and the automation's
// detail page lists granted rules with per-rule Revoke.
async function openTaskDetail(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Automations", exact: true }).click();
await page.getByText("Daily AI News").first().click();
await expect(page.getByRole("button", { name: /Run now/ })).toBeVisible();
}
test("creation consent card renders writes as grants and reads as disclosure", async ({ page }) => {
await page.goto("/");
const box = page.getByPlaceholder(/Ask the coworker/);
await expect(box).toBeVisible();
await box.fill("please create an automation for the weekly digest");
await page.getByRole("button", { name: "Send" }).click();
// The approve-at-creation card carries the proposal instead of dumping raw JSON args.
const grants = page.getByTestId("approval-grants");
await expect(grants).toBeVisible();
await expect(grants).toContainText("slack:T1/C1");
await expect(grants).toContainText("always allowed once you approve");
await expect(grants).toContainText("rohit/agent-platform");
await expect(grants).toContainText("read-only");
// Creation is minting surface #1 — there is no "Allow every time" here.
await expect(page.getByRole("button", { name: "Allow every time" })).toHaveCount(0);
await page.getByRole("button", { name: "Allow once" }).last().click();
await expect(page.getByText("Done via create_scheduled_task [decision=once]")).toBeVisible();
});
test("a run session's approval card offers Allow every time and sends always_task", async ({
page,
}) => {
await openTaskDetail(page);
await page.getByRole("button", { name: /Run now/ }).click();
await expect(page.getByTestId("run-banner")).toBeVisible();
// The manual run auto-sends the task prompt; wait for that turn to finish (the composer
// re-arms) before driving the approval flow.
await expect(page.getByText(/Echo: .*Fetch the latest AI news/)).toBeVisible();
// An eligible gated write inside the run (the event carries the pinnable target).
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("post the digest");
await page.getByRole("button", { name: "Send" }).click();
const allowEvery = page.getByRole("button", { name: "Allow every time" });
await expect(allowEvery).toBeVisible();
// The task-persistent grant replaces the session-scoped Always-allow in run context.
await expect(page.getByRole("button", { name: "Always allow", exact: true })).toHaveCount(0);
await allowEvery.click();
// The decision that rode the socket is the task-persistent one.
await expect(page.getByText("Done via send_message [decision=always_task]")).toBeVisible();
});
test("a plain session never offers Allow every time, even for an eligible call", async ({
page,
}) => {
await page.goto("/");
const box = page.getByPlaceholder(/Ask the coworker/);
await expect(box).toBeVisible();
await box.fill("post the digest");
await page.getByRole("button", { name: "Send" }).click();
// Same tool, same target — but without a run context the standing grant isn't offered;
// the session-scoped Always-allow remains.
await expect(page.getByRole("button", { name: "Allow once" }).last()).toBeVisible();
await expect(page.getByRole("button", { name: "Allow every time" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Always allow", exact: true }).last()).toBeVisible();
});
test("task detail lists standing rules under 'Allowed without asking'; Revoke removes one", async ({
page,
}) => {
await openTaskDetail(page);
const grants = page.getByTestId("task-grants");
await expect(page.getByText("Allowed without asking")).toBeVisible();
await expect(grants).toContainText("send_message");
await expect(grants).toContainText("slack:T1/C1");
await grants.getByRole("button", { name: "Revoke" }).click();
// The last rule is gone → the whole section disappears (nothing is allowed anymore).
await expect(page.getByTestId("task-grants")).toHaveCount(0);
await expect(page.getByText("Allowed without asking")).toHaveCount(0);
});
@@ -0,0 +1,83 @@
// FB-004/FB-005: the transcript follows a streaming turn only while the reader is at the
// bottom — scrolling up PINS the viewport (reading must never be yanked away) and surfaces
// a jump-to-latest pill; bubbles grow hover affordances (copy + timestamp) that reveal
// without shifting layout. Driven against the fixtures' slow "stream the epic" turn.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
// The copy test asserts real clipboard writes — grant instead of relying on defaults.
test.use({ permissions: ["clipboard-write"] });
const scrollerState = `(() => {
const el = document.querySelector(".main-scroll");
return el ? { top: el.scrollTop, height: el.scrollHeight, client: el.clientHeight } : null;
})()`;
test("scrolling up mid-stream pins the viewport; jump-to-latest re-engages", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("stream the epic");
await box.press("Enter");
// Let the stream outgrow the viewport, then read something "above".
await page.waitForFunction(
() => {
const el = document.querySelector(".main-scroll");
return !!el && el.scrollHeight > el.clientHeight + 400;
},
{ timeout: 10_000 },
);
await page.locator(".main-scroll").evaluate((el) => (el.scrollTop = 0));
// The stream keeps growing below…
const h1 = (await page.evaluate(scrollerState))!.height;
await page.waitForFunction(
(prev) => {
const el = document.querySelector(".main-scroll");
return !!el && el.scrollHeight > prev;
},
h1,
{ timeout: 5_000 },
);
// …but the viewport stays where the reader put it (the old behavior yanked to bottom
// on every delta), and the pill offers the way back.
const pinned = (await page.evaluate(scrollerState))!;
expect(pinned.top).toBeLessThan(50);
await expect(page.getByTestId("jump-to-latest")).toBeVisible();
await page.getByTestId("jump-to-latest").click();
await page.waitForFunction(
() => {
const el = document.querySelector(".main-scroll");
return !!el && el.scrollHeight - el.scrollTop - el.clientHeight < 80;
},
{ timeout: 5_000 },
);
await expect(page.getByTestId("jump-to-latest")).toHaveCount(0);
// Re-engaged: the follow survives the rest of the stream to the turn's end.
await expect(page.getByText("The epic concludes.").first()).toBeVisible({ timeout: 10_000 });
const done = (await page.evaluate(scrollerState))!;
expect(done.height - done.top - done.client).toBeLessThan(80);
});
test("bubbles carry hover copy + timestamp without layout shift", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("hello meta");
await box.press("Enter");
await expect(page.getByText("Echo: hello meta", { exact: false }).first()).toBeVisible();
// Live items are stamped client-side, so both bubbles expose the affordance strip.
const userBubble = page.locator(".bubble-user").last();
await userBubble.hover();
const meta = page.getByTestId("bubble-copy");
await expect(meta.first()).toBeVisible();
await expect(page.getByTestId("bubble-ts").first()).toBeVisible();
// Copy actually copies (the fixture page runs with clipboard permission in Chromium).
await meta.first().click();
await expect(page.getByText("Copied").first()).toBeVisible();
});
+113
View File
@@ -0,0 +1,113 @@
// Unattended mode (item 8) — the "Send approvals to Inbox" toggle and its effect on approvals.
// Since §22 the toggle lives at the BOTTOM of the composer's Mode menu (who approves, and when —
// one mental model; the standalone InboxControl left the row). When a session is unattended, an
// approval PARKS to the Inbox instead of surfacing an inline card (the app suppresses the live
// card; the Inbox list itself is covered by inbox.spec.ts). The mocked /v1/sessions/:id/unattended
// is stateful so the toggle persists across a reload.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
// The toggle sits inside the composer's Mode menu (§22).
async function openModeMenu(page) {
await page.getByRole("button", { name: "Mode", exact: true }).click();
await expect(page.getByTestId("mode-menu")).toBeVisible();
}
test("attended (default): a tool request surfaces the inline approval card", async ({ page }) => {
await page.goto("/");
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("please run a tool");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText("The coworker wants to run a command.").first()).toBeVisible();
});
test("Send-to-Inbox toggle (in the Mode menu) flips and persists across a reload", async ({
page,
}) => {
await page.goto("/");
await openModeMenu(page);
const sw = page.getByRole("switch", { name: "Send approvals to the Inbox" });
await expect(sw).toHaveAttribute("aria-checked", "false");
await sw.click();
await expect(sw).toHaveAttribute("aria-checked", "true");
// Reload: the stateful endpoint returns the saved flag, so the toggle reads back on.
await page.reload();
await openModeMenu(page);
await expect(page.getByRole("switch", { name: "Send approvals to the Inbox" })).toHaveAttribute(
"aria-checked",
"true",
);
});
test("unattended: a tool request parks (no inline approval card)", async ({ page }) => {
await page.goto("/");
await openModeMenu(page);
await page.getByRole("switch", { name: "Send approvals to the Inbox" }).click();
// The menu's full-screen overlay closes it on any outside click.
await page.mouse.click(5, 5);
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("please run a tool");
await page.getByRole("button", { name: "Send", exact: true }).click();
// The turn still starts, but the live approval card is suppressed — the prompt is parked to the
// Inbox instead. Give the (suppressed) card a beat to NOT appear.
await expect(page.getByText("Echo:").first()).toBeVisible().catch(() => {});
await expect(page.getByText("The coworker wants to run a command.")).toHaveCount(0);
});
test("answering the live approval never re-flashes its parked Inbox mirror", async ({ page }) => {
// Every live approval is ALSO parked as a per-session Inbox item (reconnect/remote resolution).
// Tester catch 2026-07-12: after "Allow once", the polled sessionInbox copy was still pending
// for up to a poll cycle, so the docked answer-in-context card flashed the SAME request again.
// Simulate the mirror: any per-session inbox fetch for the live session returns one pending
// approval until the decision lands (the fixtures' fixed items belong to other sessions).
// The real server resolves the mirror synchronously with the decision — only the CLIENT's
// polled copy is stale, which is exactly what this test pins.
let mirrorResolved = false;
await page.route(/\/v1\/inbox\?/, async (route) => {
const q = new URL(route.request().url()).searchParams;
const sid = q.get("session_id");
if (!sid || sid === "wp-3" || sid === "ops-1") return route.fallback();
return route.fulfill({
contentType: "application/json",
body: JSON.stringify({
items: mirrorResolved
? []
: [
{
id: "mirror-1",
session_id: sid,
kind: "approval",
title: "Run `run_shell`?",
body: "requires approval",
state: "pending",
resolution: null,
inbox: "default",
created_at: "2026-07-12 10:00:00",
resolved_at: null,
},
],
}),
});
});
await page.goto("/");
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("please run a tool");
await page.getByRole("button", { name: "Send", exact: true }).click();
await expect(page.getByText("The coworker wants to run a command.").first()).toBeVisible();
mirrorResolved = true; // server side resolves with the decision; the stale client copy is the bug
await page.getByRole("button", { name: "Allow once" }).last().click();
// "Never appears" semantics: pre-fix the stale mirror rendered within a frame of the click and
// self-cleared a poll later — so a plain toHaveCount(0) would blink green. Watch the window.
const flashed = await page
.getByText("Run `run_shell`?")
.waitFor({ state: "visible", timeout: 700 })
.then(() => true)
.catch(() => false);
expect(flashed).toBe(false);
await expect(page.getByText("The command ran; 1 file found.")).toBeVisible();
});