Files
openworker/surfaces/gui/e2e/fixtures.ts
T

2165 lines
107 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test as base, expect, type Page } from "@playwright/test";
// The app-wide /ws/events socket each page opened (UX-026 toast et al.) — specs
// push server events through it via sendAppEvent below.
const eventSockets = new WeakMap<Page, { send: (data: string) => void }>();
/** Push an app-wide event exactly as the server would over /ws/events. Waits for
* the GUI to have connected its socket first. */
export async function sendAppEvent(page: Page, obj: unknown): Promise<void> {
for (let i = 0; i < 50 && !eventSockets.get(page); i++) await page.waitForTimeout(100);
const ws = eventSockets.get(page);
if (!ws) throw new Error("the app never opened /ws/events");
ws.send(JSON.stringify(obj));
}
// Hermetic API mock. Every /v1 request the GUI makes is fulfilled from the fixtures below (shapes
// mirrored from the real backend), and the event WebSocket is a SCRIPTED FAKE AGENT (ready on
// connect; user_message → turn_start/deltas/assistant_message/turn_done; "run a tool" triggers the
// approval flow), so specs run with no Python server and never touch real state. Mutations
// (sessions, personas, inbox, routing, channel subscriptions) are held in per-test in-memory state
// so add/remove/toggle reflect through the real UI on re-fetch.
const HEALTH = { status: "ok", default_workspace: null, model: "anthropic:claude-opus-4-8" };
const SETTINGS = {
provider: "openai",
model: "anthropic:claude-opus-4-8",
models: ["anthropic:claude-opus-4-8", "gpt-5.5", "gpt-4o", "gpt-4o-mini", "o3-mini"],
has_key: true,
model_ready: true,
source: "store",
onboarded: true,
experimental_connectors: false,
surfaces: { cowork: true, chat: false, code: true },
nav_layout: "grouped",
scratch_base: "~/OpenWorker",
secrets_path: "/Users/test/.config/coworker/secrets.json",
sessions_peek: 5,
// Token savings (PDF attachments): 2-page limit keeps the composer threshold test's
// fixture PDF small; the real default is 20.
pdf_fallback: "text",
pdf_max_pages: 2,
pdf_max_mb: 10,
// Curated-matrix display names (subset — mirrors /v1/settings.model_labels).
model_labels: {
"anthropic:claude-opus-4-8": "Claude Opus 4.8 · Anthropic",
"zai:glm-5.2": "GLM-5.2 · Z AI",
"ark:dola-seed-evolving-latest-version": "Dola Seed Evolving · BytePlus Ark",
"ark:dola-seed-2-1-turbo-260628": "Dola Seed 2.1 Turbo · BytePlus Ark",
"ark-agent-plan-cn:doubao-seed-evolving": "Doubao Seed Evolving · Volcengine Agent Plan",
"ark-agent-plan-cn:doubao-seed-2.1-turbo": "Doubao Seed 2.1 Turbo · Volcengine Agent Plan",
},
// Context windows (subset — mirrors /v1/settings.model_context_windows); drives the
// composer usage chip's context-fill meter.
model_context_windows: {
"anthropic:claude-opus-4-8": 200_000,
},
};
// UX-035 lineup: Chat is gone; Code ships disabled; the security bundles group under
// "Security"; ops is ships:false (visible here because the mock plays an internal build).
const PERSONAS = {
internal: true,
personas: [
{ id: "cowork", name: "OpenWorker", icon: "cowork", tagline: "Produce a deliverable — research, analysis, scripts", requires_folder: false, builtin: true, tools: ["files", "search"], enabled: true, surfaced: true, default: true, ships: true, group: "general" },
{ id: "code", name: "Code", icon: "code", tagline: "Work in a codebase — files, git, shell", requires_folder: true, builtin: true, tools: ["code_files", "git"], enabled: false, surfaced: false, default: false, ships: true, group: "general" },
{ id: "security", name: "Security Coworker", icon: "shield", tagline: "Find and fix security issues — scan, triage, PR", requires_folder: true, builtin: true, tools: ["code_files", "git", "shell"], enabled: true, surfaced: true, default: false, ships: true, group: "security" },
{ id: "ops", name: "Ops Coworker", icon: "wrench", tagline: "Operate and investigate — runbooks, logs, infrastructure", requires_folder: false, builtin: true, tools: ["files", "shell"], enabled: true, surfaced: true, default: false, ships: false, group: "general" },
// A non-builtin install (disabled pending consent — invisible to picker specs) so the
// Personas page's delete/enable affordances have a target.
{ id: "acme-notes", name: "Acme Notes", icon: "pencil", tagline: "Acme's note-taking coworker", requires_folder: false, builtin: false, tools: ["files"], enabled: false, surfaced: false, default: false, ships: true, group: "general" },
],
};
// The boot-resume target (most recent updated_at) — existing specs open it by title.
const PINNED_SESSION = {
session_id: "pinned-cowork-1",
title: "Draft the launch note",
workspace: "/Users/test/OpenWorker/launch-note",
agent: "cowork",
model: "anthropic:claude-opus-4-8",
mode: "interactive",
updated_at: "2026-07-01 09:00:00",
messages: 2,
pinned: true,
archived: false,
attention: 0,
liveness: "idle",
subscriptions: [],
};
// Seven unpinned Coworker sessions: enough to exercise the sidebar peek cap (5) + "Show more (2)".
// wp-3 carries the pending Inbox approval below (attention badge parity). All OLDER than the
// pinned session so boot-resume stays deterministic.
const EXTRA_SESSIONS = Array.from({ length: 7 }, (_, i) => ({
session_id: `wp-${i + 1}`,
title: `Weekly plan ${i + 1}`,
workspace: "",
agent: "cowork",
model: "anthropic:claude-opus-4-8",
mode: "interactive",
updated_at: `2026-06-2${8 - Math.min(i, 7)} 10:00:00`,
messages: 3,
pinned: false,
archived: false,
attention: i + 1 === 3 ? 1 : 0,
liveness: "idle",
subscriptions: [],
}));
// One Ops session (older than everything above so boot-resume stays deterministic) — the
// target for the disable-archives-conversations confirm flow on the Personas page.
const OPS_SESSION = {
session_id: "ops-1",
title: "Ops triage",
workspace: "/Users/test/OpenWorker/ops-triage",
agent: "ops",
model: "anthropic:claude-opus-4-8",
mode: "interactive",
updated_at: "2026-06-15 10:00:00",
messages: 4,
pinned: false,
archived: false,
attention: 0,
liveness: "idle",
subscriptions: [],
};
// §31: a mention-spawned session — lives in the sidebar's collapsed "From Slack" group, never
// in Recent. Older than everything else so boot-resume stays deterministic.
const SLACK_SESSION = {
session_id: "slack-thread-1",
title: "#general — check the deploy?",
workspace: "",
agent: "cowork",
model: "anthropic:claude-opus-4-8",
mode: "interactive",
updated_at: "2026-06-10 10:00:00",
messages: 2,
pinned: false,
archived: false,
attention: 0,
liveness: "idle",
subscriptions: [],
origin: "slack",
origin_label: "#general · T0AB",
};
const CONNECTORS = {
connectors: [
{ name: "browser", title: "Browser", icon: "B", blurb: "Headless browser.", auth: "none", two_way: false, channels: false, available: true, brand_color: "#6b7280", logo: "", fields: [], instructions: [], connected: true, account: null, enabled: true, allowed_users: [], tools: [], managed: false, managed_profile: false },
{ name: "telegram", title: "Telegram", icon: "T", blurb: "Two-way Telegram messaging.", auth: "bot_token", two_way: true, channels: true, available: true, brand_color: "#229ed9", logo: "telegram", fields: [{ key: "bot_token", label: "Bot token", secret: true, required: true, help: "", placeholder: "123456:ABC…" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: false, managed_profile: false },
// Managed-capable connector (one-click via cloud when signed in; manual paste otherwise).
// Carries pre-connect detail copy (§38): about + access + tools drive available-detail.spec.ts.
{ name: "gmail", title: "Gmail", icon: "✉", blurb: "Search, summarize, draft, and send email.", about: "Search, summarize, and send over your Gmail.", access: ["Reads and searches your mail.", "Sends email as you.", "Never deletes mail or changes account settings."], auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#ea4335", logo: "gmail", fields: [{ key: "access_token", label: "OAuth access token", secret: true, required: true, help: "", placeholder: "" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [{ name: "gmail_search", label: "Search mail", kind: "read", description: "Search messages.", enabled: true, requires_approval: false }, { name: "gmail_send", label: "Send email", kind: "write", description: "Send a message.", enabled: true, requires_approval: true }], managed: true, managed_profile: false },
{ name: "google_calendar", title: "Google Calendar", icon: "◷", blurb: "Read availability, summarize schedules, and create events.", auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#4285f4", logo: "google_calendar", fields: [{ key: "access_token", label: "OAuth access token", secret: true, required: true, help: "", placeholder: "" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: true, managed_profile: false },
// Two-mode connector: one-click with access radios (read | write) OR a private-app token.
{ name: "hubspot", title: "HubSpot", icon: "⊚", blurb: "Search CRM records; log notes and tasks, update records. No deletes.", auth: "token", two_way: false, channels: false, available: true, brand_color: "#ff7a59", logo: "hubspot", fields: [{ key: "token", label: "Private app token", secret: true, required: true, help: "", placeholder: "pat-…" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: true, managed_profile: false },
// Generic multi-account connector (accounts.py layer): one-click OR integration token.
{ name: "notion", title: "Notion", icon: "◰", blurb: "Search pages, read content, query databases, create pages.", auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#1f2328", logo: "", fields: [{ key: "access_token", label: "Integration secret", secret: true, required: true, help: "", placeholder: "ntn_…" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: true, managed_profile: false },
// Managed email-keyed multi-account connector (outlook) — drives the onboarding tools gallery.
{ name: "outlook", title: "Outlook", icon: "◎", blurb: "Microsoft 365 mail and calendar: search, draft, and send email; manage events and respond to invites.", aliases: ["calendar", "email", "mail", "microsoft", "office"], auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#0078d4", logo: "outlook", fields: [{ key: "access_token", label: "OAuth access token", secret: true, required: true, help: "", placeholder: "" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: true, managed_profile: false },
// Sixth active card in the onboarding gallery (promoted 2026-07-19 to even the grid).
{ name: "attio", title: "Attio", icon: "▣", blurb: "Search and read Attio CRM records; log notes.", auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#2d6ae0", logo: "attio", fields: [{ key: "access_token", label: "OAuth access token", secret: true, required: true, help: "", placeholder: "" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: true, managed_profile: false },
// MCP-BACKED connectors (§42): vendor-hosted MCP + local OAuth, pinned tool subset.
// monday is one-click ONLY (no manual fields); jira also has a manual token path
// (two-mode modal). Neither needs cloud sign-in.
{ name: "monday", title: "monday.com", icon: "▦", blurb: "Read boards and items, track work, create items and post updates.", aliases: ["project management", "tasks", "boards"], auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#6161ff", logo: "monday", mcp: true, fields: [], instructions: ["One click connects via monday.com sign-in in your browser.", "Sign-in is fully local — tokens stay on this computer."], connected: false, account: null, enabled: false, allowed_users: [], tools: [{ name: "mcp__monday__get_board_info", label: "Read board", kind: "read", description: "Read a board's columns and groups.", enabled: true, requires_approval: false }, { name: "mcp__monday__create_item", label: "Create item", kind: "write", description: "Create an item on a board.", enabled: true, requires_approval: true }], managed: false, managed_profile: false },
{ name: "jira", title: "Jira", icon: "◆", blurb: "Search, summarize, create, and update issues.", aliases: ["issues", "tickets", "atlassian"], auth: "api_token", two_way: false, channels: false, available: true, brand_color: "#0052cc", logo: "jira", mcp: true, fields: [{ key: "base_url", label: "Atlassian site URL", secret: false, required: true, help: "", placeholder: "" }, { key: "email", label: "Account email", secret: false, required: true, help: "", placeholder: "" }, { key: "api_token", label: "API token", secret: true, required: true, help: "", placeholder: "" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: false, managed_profile: false },
],
};
// Two pending items across two personas: drives the Inbox kind tabs, the persona filter chips
// (which only render with >1 persona), and resolve-removes-card. The question's session is NOT in
// the sessions list on purpose — the Inbox must be self-contained (server-joined context fields).
const INBOX_ITEMS = [
{
id: "inb-approval-1",
session_id: "wp-3",
kind: "approval",
title: "Approve: run_shell",
body: "rm -rf build/",
state: "pending",
resolution: null,
inbox: "default",
created_at: "2026-07-01 08:00:00",
resolved_at: null,
session_title: "Weekly plan 3",
session_agent: "cowork",
session_workspace: "",
session_exists: true,
},
{
id: "inb-question-1",
session_id: "ops-1",
kind: "question",
title: "Which environment should I restart?",
body: "",
options: ["staging", "production"],
allow_text: true,
multi: false,
state: "pending",
resolution: null,
inbox: "default",
created_at: "2026-07-01 08:05:00",
resolved_at: null,
session_title: "Investigate alerts",
session_agent: "ops",
session_workspace: "",
session_exists: true,
},
];
// Mutable cloud sign-in state: POST /v1/cloud/login flips it (the real flow
// goes through the browser; the mock completes instantly), logout flips back.
export const CLOUD_STATE = {
signed_in: false,
account: "",
user_id: "",
telemetry_enabled: true,
};
const GALLERY_PERSONAS = [
{
slug: "sales",
version: 1,
name: "Sales Coworker",
icon: "chart",
tagline: "Research accounts, prep meetings, draft follow-ups",
description: "A sales-focused coworker.",
family: "knowledge",
workspace: "deliverable",
publisher: "OpenWorker",
recommended_connectors: ["hubspot", "gmail"],
risk_summary: "Declarative manifest; no executable code.",
featured: true,
},
{
slug: "recruiter",
version: 1,
name: "Recruiter",
icon: "search",
tagline: "Sourcing summaries and scheduling loops",
description: "A recruiting coworker.",
family: "knowledge",
workspace: "deliverable",
publisher: "OpenWorker",
recommended_connectors: ["gmail"],
risk_summary: "Declarative manifest; no executable code.",
featured: false,
},
];
// Persona detail (GET /v1/personas/:id) — SourcesDrawer/PersonaView read `recommends` and
// `default_connections` as arrays, so these must be present (not the catch-all {}).
const PERSONA_DETAIL = {
id: "cowork",
name: "OpenWorker",
icon: "cowork",
tagline: "Produce a deliverable — research, analysis, scripts",
description: "",
enabled: true,
tools: ["files", "search"],
recommended_models: ["anthropic:claude-opus-4-8"],
default_permission_mode: "interactive",
workspace: "deliverable",
recommends: [],
default_connections: [],
};
const CONNECTIONS = {
connected: [
{ connector: "browser", enabled: true, detail: "Browser" },
{ connector: "slack", enabled: true, detail: "Slack" },
// two_way WITHOUT channels (relay mentions, no subscriptions) — pins the
// "GitHub shows Channels" regression (owner report 2026-07-13).
{ connector: "github", enabled: true, detail: "GitHub" },
],
recommended: [
{ connector: "gmail", reason: "email context for morning summaries", tier: "core", connected: false },
],
attention: 1,
};
// One scheduled automation with a running run: its session id uses the real `__run__` convention
// so the session view's run banner (detection is id-based) can be exercised end-to-end.
const AUTOMATION = {
id: "task-1",
title: "Daily AI News",
instructions: "Fetch the latest AI news and produce an HTML+Tailwind presentation.",
schedule: "Every day at ~5:40 PM",
schedule_raw: { kind: "cron", cron: "40 17 * * *", fire_at: null, timezone: "local" },
workspace: "",
agent: "cowork",
enabled: true,
next_run: Math.floor(Date.now() / 1000) + 3600,
last_run: Math.floor(Date.now() / 1000) - 60,
last_status: "running",
run_count: 1,
notify_on_completion: false,
// One standing scoped approval (§25) so the detail page's revoke list has content.
always_allowed: [
{ entry: "send_message slack:T1/C1", tool: "send_message", target: "slack:T1/C1" },
],
// UX-023 sidebar badges: two unopened runs, the newest of them failed.
unseen_runs: 2,
unseen_failed: true,
seen_runs_at: 0,
};
// A second, quiet automation so the Scheduled band shows badge-less rows too.
const AUTOMATION_CLEAN = {
...AUTOMATION,
id: "task-2",
title: "Weekly CRM digest",
schedule: "Every Monday at ~9:00 AM",
last_status: "ok",
unseen_runs: 0,
unseen_failed: false,
always_allowed: [],
};
const AUTOMATION_RUNS = [
{
run_id: "r1",
task_id: "task-1",
session_id: "__run__r1",
started_at: Math.floor(Date.now() / 1000) - 60,
finished_at: null,
status: "running",
result_text: null,
artifacts: [],
error: null,
trigger: "schedule",
},
];
const PRIMARY_ROOT = { path: "/Users/test/OpenWorker/launch-note", writable: true, label: "scratch", primary: true, exists: true };
const baseName = (p: string) => p.split("/").filter(Boolean).pop() || p;
const PROVIDERS = [
// openai: configured + used (drives the "Last used" sub-line and the status dot).
{ name: "openai", title: "OpenAI", needs_key: true, fields: [{ key: "api_key", label: "OpenAI API key", secret: true, required: true, help: "", placeholder: "sk-…" }], configured: true, values: {}, suggested_models: ["gpt-5.5"], key_set_at: "2026-06-12", last_used_at: Math.floor(Date.now() / 1000) - 7200 },
// anthropic: configured but never used ("Not used yet").
{ name: "anthropic", title: "Claude (Anthropic)", needs_key: true, fields: [{ key: "api_key", label: "API key", secret: true, required: true, help: "", placeholder: "sk-…" }], configured: true, values: {}, suggested_models: ["claude-opus-4-8"], key_set_at: null, last_used_at: null },
// zai: an OpenAI-compatible vendor — unconfigured, with a prefilled editable endpoint + blurb.
{ name: "zai", title: "Z AI (GLM)", needs_key: true, blurb: "Uses Z AI's OpenAI-compatible API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "Z AI API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "Prefilled with Z AI's international endpoint.", placeholder: "https://api.z.ai/api/paas/v4", default: "https://api.z.ai/api/paas/v4" }], configured: false, values: {}, suggested_models: ["glm-5.2"], key_set_at: null, last_used_at: null },
// Ark uses two provider identities: BytePlus pay-as-you-go and Volcengine Agent Plan CN
// have independent credentials, endpoints, and strict curated model lists.
{ name: "ark", title: "BytePlus Ark", needs_key: true, blurb: "Uses BytePlus Ark's OpenAI-compatible Responses API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "BytePlus Ark API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "BytePlus Ark's Asia Pacific endpoint.", placeholder: "https://ark.ap-southeast.bytepluses.com/api/v3", default: "https://ark.ap-southeast.bytepluses.com/api/v3" }], configured: false, values: {}, suggested_models: ["dola-seed-evolving-latest-version", "dola-seed-2-1-turbo-260628"], key_set_at: null, last_used_at: null },
{ name: "ark-agent-plan-cn", title: "Volcengine Ark Agent Plan", needs_key: true, blurb: "Uses Volcengine Ark Agent Plan's OpenAI-compatible Responses API — the endpoint is prefilled, just add your key.", fields: [{ key: "api_key", label: "Volcengine Ark Agent Plan API key", secret: true, required: true, help: "", placeholder: "" }, { key: "base_url", label: "Endpoint", secret: false, required: false, help: "Volcengine Ark Agent Plan's China (Beijing) endpoint.", placeholder: "https://ark.cn-beijing.volces.com/api/plan/v3", default: "https://ark.cn-beijing.volces.com/api/plan/v3" }], configured: false, values: {}, suggested_models: ["doubao-seed-evolving", "doubao-seed-2.1-turbo"], key_set_at: null, last_used_at: null },
// ollama: keyless local provider — "configured" without proving anything runs; the
// onboarding gallery shows "No key needed" and its form is endpoint + Detect (§39).
{ name: "ollama", title: "Ollama (local models)", needs_key: false, fields: [{ key: "base_url", label: "Endpoint", secret: false, required: false, help: "", placeholder: "http://127.0.0.1:11434", default: "http://127.0.0.1:11434" }], configured: true, values: {}, suggested_models: ["qwen3-coder:30b"], key_set_at: null, last_used_at: null },
// openai-codex: the subscription OAuth provider — no key form; the gallery card and
// form render sign-in state instead (auth: "oauth"). Starts signed out.
{ name: "openai-codex", title: "ChatGPT subscription", needs_key: false, auth: "oauth", signed_in: false, account: null, authorizing: false, last_error: null, blurb: "Sign in with your ChatGPT plan and run OpenAI models through your subscription — no API key. Tokens stay on this machine.", fields: [], configured: false, values: {}, suggested_models: ["gpt-5.6-sol"], key_set_at: null, last_used_at: null },
];
/** Install the API + WebSocket mocks on a page. Returns handles for assertions/seed data. */
export async function mockApi(page: import("@playwright/test").Page) {
// The rail defaults to HIDDEN (UX-038 follow-up). Existing specs were written
// against a visible rail, so run them in the "user opened it" state; the
// default + persistence themselves are pinned by rail-default.spec.ts.
await page.addInitScript(() => {
try {
if (!localStorage.getItem("ocw-e2e-rail-default")) {
localStorage.setItem("coworker:rail-hidden:v1", "0");
}
} catch { /* ignore */ }
});
const subscriptions: any[] = [
// One existing subscription (a non-pinned session) so the Slack page's per-workspace
// "Listening" row has an entry. Relay-mode channels are team-qualified (slack:T…/C…).
{ session_id: "wp-1", session_title: "Weekly plan 1", agent: "cowork", channel: "slack:T1DL/C0AAA111", channel_name: "ocw-test", routing_target: null, collision: false },
];
// Parked unauthorized messages (§19) — mutable so Allow/Dismiss round-trip through the UI.
// The relay is multi-workspace: parked items carry their team so the Slack page files them
// under the right workspace card.
const parked: any[] = [
{ id: "pk1", platform: "slack", chat_id: "C0AAA111", chat_name: "#ocw-test", user_id: "U0NEW", user_name: "Maya", chat_type: "channel", text: "hey ocw, can you summarize this thread?", ts: Date.now() / 1000 - 120, team_id: "T1DL" },
];
// Slack connector — PER-TEST state (managed relay, two workspaces) so allow/disconnect
// mutations never leak across tests sharing a worker. Backend parity: `workspaces` mirrors
// the slack:team:* profiles, each with its OWN allow-list.
const slackState = {
connected: true,
mode: "relay" as "" | "relay",
account: "deeplearning.ai",
allowed_users: [] as string[], // flat list (manual Socket Mode only)
approval_owner_ids: [] as string[],
workspaces: [
// T1DL mirrors a managed install: the installer (authed_user) was pre-added
// to the allow-list on connect (UX-027) — keys the "you" chip + setup card.
{ team_id: "T1DL", account: "deeplearning.ai", domain: "dlaiteam", allowed_users: ["U_ME"] as string[], allow_all: false, allowed_user_names: {} as Record<string, string | null>, approval_owner_ids: ["U_ME"] as string[], approval_owner_names: { U_ME: "Rohit Prasad" } as Record<string, string | null>, installer_user_id: "U_ME", installer_name: "Rohit Prasad" },
{ team_id: "T2AC", account: "acme-partners", domain: "acmehq", allowed_users: [] as string[], allow_all: false, allowed_user_names: {} as Record<string, string | null>, approval_owner_ids: [] as string[], approval_owner_names: {} as Record<string, string | null>, installer_user_id: "", installer_name: "" },
],
};
const slackConnector = () => ({
name: "slack", title: "Slack", icon: "#", blurb: "Two-way Slack messaging.",
auth: "bot_token", two_way: true, channels: true, available: true, brand_color: "#611f69", logo: "slack",
fields: [], instructions: [], connected: slackState.connected,
account: slackState.account, enabled: slackState.connected,
allowed_users: [...slackState.allowed_users],
approval_owner_ids: [...slackState.approval_owner_ids],
tools: [], managed: true,
managed_profile: slackState.mode === "relay", mode: slackState.mode,
workspaces: slackState.workspaces.map((w) => ({ ...w, allowed_users: [...w.allowed_users] })),
unauthorized: parked.map((x) => ({ ...x })),
});
// GitHub — PER-TEST multi-installation state (managed relay, one installation +
// one parked mention) mirroring the backend's github:install:<id> profiles.
const githubParked: any[] = [
{ id: "gh-pk1", platform: "github", chat_id: "acme/site#7", chat_name: "acme/site#7", user_id: "maya-dev", user_name: "maya-dev", chat_type: "channel", text: "@ocw please take a look at this flaky test", ts: Date.now() / 1000 - 90, team_id: "101" },
];
const githubState = {
connected: true,
mode: "relay" as "" | "relay",
installations: [
{ installation_id: "101", account_login: "acme", account_type: "Organization", repo_selection: "selected", github_login: "rohit-dev", allowed_users: ["rohit-dev"], allow_all: false },
],
};
const githubConnector = () => ({
name: "github", title: "GitHub", icon: "⌘", blurb: "Work with issues, pull requests, repository files, and CI status.",
auth: "token", two_way: true, channels: false, available: true, brand_color: "#1f2328", logo: "github",
fields: [{ key: "token", label: "Personal access token", secret: true, required: true, help: "", placeholder: "" }],
instructions: [], connected: githubState.connected,
account: githubState.installations[0]?.account_login ?? null,
enabled: githubState.connected, allowed_users: [], tools: [], managed: true,
managed_profile: githubState.mode === "relay", mode: githubState.mode,
installations: githubState.installations.map((i) => ({ ...i, allowed_users: [...i.allowed_users] })),
unauthorized: githubParked.map((x) => ({ ...x })),
});
// Gmail — PER-TEST multi-account state (starts disconnected; managed connects add
// mailboxes instantly, mirroring the backend's gmail:account:<email> profiles).
// NOTE: the real server currently sends managed_paused: true for the Google trio
// (CASA pending). The fixture keeps gmail UNPAUSED because the cloud-machinery specs
// use its one-click as their subject; the paused UI is covered by google-paused.spec.ts
// via a per-test connectors override.
const gmailState = {
accounts: [] as {
email: string; default: boolean; managed: boolean; scopes: string; needs_reauth: boolean;
}[],
filters: { senders: [] as string[], labels: [] as string[] },
};
const GMAIL_NEXT = ["rohit@gmail.com", "work@dlai.com", "third@x.com"];
const gmailConnector = () => {
const base = CONNECTORS.connectors.find((c: any) => c.name === "gmail");
return {
...base,
connected: gmailState.accounts.length > 0,
enabled: gmailState.accounts.length > 0,
account: gmailState.accounts.find((a) => a.default)?.email ?? null,
accounts: gmailState.accounts.map((a) => ({ ...a })),
filters: { senders: [...gmailState.filters.senders], labels: [...gmailState.filters.labels] },
};
};
// Google Calendar — PER-TEST multi-account state (gmail's shape, no filters).
const gcalState = {
accounts: [] as {
email: string; default: boolean; managed: boolean; scopes: string; needs_reauth: boolean;
}[],
};
const GCAL_NEXT = ["rohit@gmail.com", "work@dlai.com", "third@x.com"];
const gcalConnector = () => {
const base = CONNECTORS.connectors.find((c: any) => c.name === "google_calendar");
return {
...base,
connected: gcalState.accounts.length > 0,
enabled: gcalState.accounts.length > 0,
account: gcalState.accounts.find((a) => a.default)?.email ?? null,
accounts: gcalState.accounts.map((a) => ({ ...a })),
};
};
// Notion — PER-TEST generic multi-account state (accounts.py layer: AccountRow shape).
const notionState = {
accounts: [] as { account_id: string; name: string; default: boolean; managed: boolean }[],
};
const NOTION_NEXT = [
{ account_id: "ws-1", name: "Rohit's Workspace" },
{ account_id: "ws-2", name: "Ops Space" },
];
const notionConnector = () => {
const base = CONNECTORS.connectors.find((c: any) => c.name === "notion");
return {
...base,
connected: notionState.accounts.length > 0,
enabled: notionState.accounts.length > 0,
account: notionState.accounts.find((a) => a.default)?.name ?? null,
accounts: notionState.accounts.map((a) => ({ ...a })),
};
};
// Outlook — email-keyed managed accounts (mirrors outlook:account:<email> profiles).
const outlookState = {
accounts: [] as { account_id: string; name: string; default: boolean; managed: boolean }[],
};
// MCP-backed connectors (§42) — per-test connect state; the mock "browser flow"
// completes instantly so the modal's poll picks it up.
const mcpState = { monday: false, jira: false };
const mcpConnector = (name: "monday" | "jira") => {
const base = CONNECTORS.connectors.find((c: any) => c.name === name);
return {
...base,
connected: mcpState[name],
enabled: mcpState[name],
mode: mcpState[name] ? "mcp" : "",
};
};
const outlookConnector = () => {
const base = CONNECTORS.connectors.find((c: any) => c.name === "outlook");
return {
...base,
connected: outlookState.accounts.length > 0,
enabled: outlookState.accounts.length > 0,
account: outlookState.accounts.find((a) => a.default)?.name ?? null,
accounts: outlookState.accounts.map((a) => ({ ...a })),
};
};
// HubSpot — PER-TEST multi-portal state (starts disconnected; managed connects add
// portals instantly, mirroring the backend's hubspot:portal:<hub_id> profiles).
const hubspotState = {
portals: [] as {
hub_id: string; name: string; sandbox: boolean; default: boolean;
managed: boolean; access: string;
}[],
hidden_fields: [] as string[],
nextAccess: "read", // captured from the last connect-managed body
};
const HUBSPOT_NEXT = [
{ hub_id: "111", name: "Acme Inc", sandbox: false },
{ hub_id: "222", name: "Acme Sandbox", sandbox: true },
];
const hubspotConnector = () => {
const base = CONNECTORS.connectors.find((c: any) => c.name === "hubspot");
return {
...base,
connected: hubspotState.portals.length > 0,
enabled: hubspotState.portals.length > 0,
account: hubspotState.portals.find((p) => p.default)?.name ?? null,
portals: hubspotState.portals.map((p) => ({ ...p })),
hidden_fields: [...hubspotState.hidden_fields],
};
};
// Installed personas — mutable so enable/surface/delete round-trip through the UI.
const personas: any[] = PERSONAS.personas.map((p) => ({ ...p }));
// Sessions — mutable so archive (PATCH), rename (PATCH), and delete round-trip.
const sessions: any[] = [
{ ...PINNED_SESSION },
...EXTRA_SESSIONS.map((s) => ({ ...s })),
{ ...OPS_SESSION },
{ ...SLACK_SESSION },
];
// Inbox items + the outbound routing binding — mutable for resolve + the inline Slack config.
const inbox: any[] = INBOX_ITEMS.map((i) => ({ ...i }));
const routing: { name: string; channel: string | null; target: string } = {
name: "default",
channel: null,
target: "",
};
// Session roots — the primary (writable, non-removable) scratch plus any added folders. Mutable so
// the RO/RW add/toggle round-trips through the real UI. POST upserts by path (a toggle re-adds).
const roots: any[] = [{ ...PRIMARY_ROOT }];
// Session connections — PER-TEST copy so the Access section's mute toggle (POST) can flip
// `enabled` without leaking into sibling tests.
const connections = {
connected: CONNECTIONS.connected.map((c) => ({ ...c })),
recommended: CONNECTIONS.recommended.map((r) => ({ ...r })),
attention: CONNECTIONS.attention,
};
// Providers — mutable so save (POST) flips `configured` and stamps key_set_at, matching the
// backend's set_provider. verify (POST) never mutates: it's a live read-only credential check.
const providers: any[] = PROVIDERS.map((p) => ({ ...p }));
// Automations — mutable so Run now appends a run, enable/disable toggles, and delete removes.
const automations: any[] = [{ ...AUTOMATION }, { ...AUTOMATION_CLEAN }];
// MCP servers (empty by default; the granola OAuth quick-add test populates it).
const mcpServers: any[] = [];
const automationRuns: any[] = AUTOMATION_RUNS.map((r) => ({ ...r }));
// Per-session unattended flag — mutable so the composer's "Send to Inbox" toggle persists and
// the app reads it back (which is what gates parking approvals to the Inbox vs an inline card).
const unattended: Record<string, boolean> = {};
// Skills (SKILLS-SPEC) — mutable folder-is-truth mirror: Settings CRUD, the enabled flag,
// staged uploads (stage → preview → confirm), and the composer's per-session menu all
// round-trip through this one list.
const skills: any[] = [
{ name: "weekly-report", description: "Monday status report", instructions: "1. Collect updates\n2. Write it up", scope: "global", source: "local", enabled: true, path: "/state/skills/weekly-report", files: 0 },
{ name: "html-to-markdown", description: "Convert an HTML document or fragment to clean markdown.", instructions: "Convert the given HTML to markdown, preserving structure.", scope: "global", source: "uploaded", enabled: true, path: "/state/skills/html-to-markdown", files: 2 },
];
let stagedSkill: any = null;
// Agent teams (OPE-96): the session's board — empty until a test opts in by sending
// "plan the work" (the fake agent then files items; no draft state — the board only
// holds accepted work). Mutable so transitions round-trip through the real endpoints.
const boardItems: any[] = [];
// # team chat log — seeded with one lead question so mention highlighting renders.
const chatMessages: any[] = [
{
seq: 1,
ts: new Date().toISOString(),
author: "lead",
author_role: "lead",
text: "@nia does the api assume the assets bucket is public? quick check before you write it up.",
mentions: ["nia"],
},
];
// Pure notes added from the detail pane (never change state) — appended to
// the item's timeline so the pane reflects them after reload.
const itemNotes: Record<number, any[]> = {};
const seedBoard = () => {
if (boardItems.length) return;
boardItems.push(
{ id: 1, title: "Code security review — api", description: "", criteria: "every finding triaged with file:line evidence", state: "open", assignee: "", creator: "lead", refs: [], links: [] },
{ id: 2, title: "Secrets — git history, both repos", description: "", criteria: "every hit dismissed-with-reason or rotation-instructed", state: "open", assignee: "", creator: "lead", refs: [], links: [] },
{ id: 3, title: "Dependency audit — lockfiles", description: "", criteria: "reachable vs theoretical separated; upgrade branch green", state: "in_progress", assignee: "dep-audit", creator: "lead", refs: [], links: [] },
{ id: 4, title: "Cloud posture — infra", description: "", criteria: "trivy config clean or findings triaged", state: "blocked", assignee: "cloud-posture", creator: "lead", refs: [], links: [], blocker: "need tfvars for staging" },
{ id: 5, title: "Report rollup", description: "", criteria: "one report, all sections", state: "review", assignee: "security", creator: "lead", refs: [`attachment://${"a".repeat(64)}.png#rendered-page.png`], links: [] },
);
};
const boardPayload = () =>
boardItems.length
? { space: "/Users/test/OpenWorker/launch-note", name: "launch-note", items: boardItems }
: { space: null, name: "", items: [] };
// Fresh cloud sign-in state per test (module state outlives a page).
Object.assign(CLOUD_STATE, {
signed_in: false,
account: "",
user_id: "",
telemetry_enabled: true,
});
// The scripted fake agent behind the session WebSocket. Speaks the real event protocol
// ({type, data}), so the full send → stream → render loop and the approval round-trip run
// through the production code paths:
// · on connect: `ready`
// · user_message: turn_start (with input, exercising the foreground dedupe) → two
// assistant_deltas → assistant_message "Echo: <text>" → turn_done
// · a message containing "run a tool": tool_proposed + permission_required, then the turn
// SUSPENDS until the client's approval decision arrives (deny → skipped; else → ran)
// App-wide event stream: register the socket so sendAppEvent can push into it.
await page.routeWebSocket(/\/ws\/events$/, (ws) => {
eventSockets.set(page, ws);
});
await page.routeWebSocket(/\/ws\/session\//, (ws) => {
const send = (type: string, data: Record<string, unknown> = {}) =>
ws.send(JSON.stringify({ type, data }));
// The page's session id, from the socket URL — team approval stamps THIS session
// as the lead (the active conversation IS the lead; workers hang off it).
const sid = ws.url().split("/ws/session/")[1]?.split("?")[0] || "sess-lead";
send("ready");
let pendingTool = "run_shell"; // which proposal the next approval decision resolves
let epicTimer: ReturnType<typeof setInterval> | null = null; // the slow stream, stoppable via interrupt
let hadTurn = false; // a user_message landed — set_model is now a mid-session switch
ws.onMessage((raw) => {
const msg = JSON.parse(String(raw));
if (msg.type === "user_message") {
hadTurn = true;
// Force-run (SKILLS-SPEC §6): like the real server, TURN_START ships the user's
// literal "/name …" line as `display` so the client dedupes on what the user sees.
send("turn_start", {
input: msg.text,
...(msg.skill ? { display: `/${msg.skill}${msg.text ? ` ${msg.text}` : ""}` } : {}),
});
if (/run a tool/i.test(msg.text)) {
pendingTool = "run_shell";
send("tool_proposed", { name: "run_shell", arguments: { command: "ls" } });
send("permission_required", {
name: "run_shell",
arguments: { command: "ls" },
reason: "The coworker wants to run a command.",
readonly_ok: true, // `ls` classifies read-only server-side
});
return; // suspended on the approval
}
// Agent teams: the decomposition gate — the lead proposes work items and
// SUSPENDS until the items_response verdict arrives (approval creates them).
// A board wake arriving on this session: the digest rides `source` with
// structured rows — the BoardWakeCard renders collapsed by default.
if (/board wake/i.test(msg.text)) {
send("turn_start", {
source: {
connector: "board",
kind: "channel",
channel_id: "/Users/test/OpenWorker/launch-note",
channel_name: "Team board",
sender_id: "board",
sender_name: "Board",
ts: Date.now() / 1000,
text: "⏰ Board wake — your team needs decisions:\n- #2 moved to review by webb",
board: {
rows: [
{
kind: "moved",
item: 2,
title: "Statements page",
actor: "webb",
to: "review",
note: "Ready for review on feat/customer-statements, commit 029f9f7. Build verified; final verdict stays with the tester.",
},
{ kind: "filed", item: 5, title: "Follow-up: rate limit", actor: "nia" },
],
},
},
});
send("assistant_message", { text: "Reviewing the hand-off now." });
send("turn_done");
return;
}
if (/propose the split/i.test(msg.text)) {
send("items_proposed", {
items: [
{ title: "Statement API endpoint", criteria: "returns opening/closing balances over the chosen range; 8 endpoint tests green; malformed, missing, and reversed date ranges return 400; draft invoices are excluded from issued totals; inclusive boundaries verified end to end" },
{ title: "Statements dashboard page", criteria: "renders seeded data for Ada / Northgate; empty + error states covered" },
{ title: "Statement totals reconcile", criteria: "running balance matches invoices minus payments for the range" },
{ title: "Verification pass", criteria: "tester confirms page renders with live API data" },
],
note: "Shared journal case: statements.",
});
return; // suspended on the items decision
}
// Agent teams (OPE-97): the staffing gate — the lead proposes a roster and
// SUSPENDS until the team_response verdict arrives.
if (/staff the team/i.test(msg.text)) {
send("team_proposed", {
members: [
{ persona: "swe-worker", name: "nia", model: "anthropic:claude-opus-4-8", reason: "implementation" },
{ persona: "design-worker", name: "webb", reason: "UI polish" },
{ persona: "test-worker", name: "checks", reason: "verifies against acceptance criteria" },
],
enable_chat: false,
note: "Three workers cover the plan; checks verifies before anything closes.",
});
return; // suspended on the staffing decision
}
// Agent teams (OPE-96): a decomposition turn — the plan was approved in
// conversation (plan-approval flow); the agent files the items and the
// board rail appears on the next board fetch.
if (/plan the work/i.test(msg.text)) {
seedBoard();
send("assistant_message", {
text: "Plan approved — filed 5 work items on the board.",
});
send("turn_done");
return;
}
// A deliverable turn ending in an artifact chip (§34) — for the chip-open flow.
if (/show the report/i.test(msg.text)) {
send("assistant_message", {
text: "Done — [Security review](artifact:reports/security-review.html)",
});
send("turn_done");
return;
}
// The pre-fix payload shape (owner-hit 2026-08-14): no installable/version/summary
// — an older sidecar, or any surface that forgets the field. Must render NOT
// installable, never a guessed Install offer.
if (/request an unpinned tool/i.test(msg.text)) {
send("tool_requested", {
name: "somescanner",
reason: "scan the Terraform for misconfigurations",
});
return; // suspended on the tool request
}
// OPE-85: the agent hits a missing scanner and asks instead of skipping the check.
if (/scan for secrets/i.test(msg.text)) {
send("tool_requested", {
name: "gitleaks",
reason: "scan the git history for committed secrets",
installable: true,
version: "8.30.1",
summary: "scans git history and the working tree for committed secrets",
source: "github.com/gitleaks",
});
return; // suspended on the tool request
}
// §35 compact row: a routine workspace write (content rides in the args).
if (/write a file/i.test(msg.text)) {
pendingTool = "write_file";
const args = {
path: "src/fetch_data.py",
content: "import json\nimport urllib.request\n\ncompanies = [\"NVDA\", \"AMD\"]\nprint(len(companies))\ndone = True",
};
send("tool_proposed", { name: "write_file", arguments: args });
send("permission_required", { name: "write_file", arguments: args, reason: "" });
return; // suspended on the approval
}
// A one-paragraph digest with NO newlines — the owner-repro shape that once
// ballooned the card to full-transcript height (char clamp, 2026-07-15).
if (/post the long digest/i.test(msg.text)) {
pendingTool = "send_message";
const args = {
target: "slack:T1/C1",
text:
"aisuite — last 24 hours of work (through Jul 15): 5 PRs merged covering chat-completion streaming with unified chunks across providers, multimodal input conversion, Slack collaboration improvements, human attribution for outbound posts, and repo-wide formatting. ".repeat(
6,
),
};
send("tool_proposed", { name: "send_message", arguments: args });
send("permission_required", { name: "send_message", arguments: args, reason: "", category: "messaging" });
return;
}
// Standing scoped approvals (§25): an eligible connector-ish write — the event
// carries the pinnable target, exactly like the real engine computes it.
if (/post the digest/i.test(msg.text)) {
pendingTool = "send_message";
send("tool_proposed", {
name: "send_message",
arguments: { target: "slack:T1/C1", text: "Weekly digest ready" },
});
send("permission_required", {
name: "send_message",
arguments: { target: "slack:T1/C1", text: "Weekly digest ready" },
reason: "",
category: "messaging",
standing_target: "slack:T1/C1",
});
return;
}
// §25 consent card: the agent proposes the automation's permission set on the
// gated create call; the existing approval card renders disclosure/grant lines.
if (/create an automation/i.test(msg.text)) {
pendingTool = "create_scheduled_task";
send("tool_proposed", { name: "create_scheduled_task", arguments: {} });
send("permission_required", {
name: "create_scheduled_task",
arguments: {
title: "Weekly digest",
instructions: "Summarize the week and post it.",
cron: "0 9 * * 1",
permissions: [
{ tool: "send_message", target: "slack:T1/C1", access: "write" },
{ tool: "github_list_commits", target: "rohit/agent-platform", access: "read" },
],
},
reason: "",
category: "automation",
});
return;
}
// A reasoning model's turn: thinking deltas tick in slowly, then the answer —
// the assistant_message carries the full trace like the real engine's payload.
if (/think hard/i.test(msg.text)) {
const thoughts = ["Weighing options. ", "Comparing tradeoffs. ", "Settling it. "];
let tick = 0;
const timer = setInterval(() => {
if (tick < thoughts.length) {
send("reasoning_delta", { text: thoughts[tick] });
tick += 1;
return;
}
clearInterval(timer);
send("assistant_delta", { text: "Decision made." });
send("assistant_message", {
text: "Decision made.",
reasoning: thoughts.join(""),
});
send("turn_done");
}, 120);
return;
}
// Auto-compaction (OPE-27): the server signals `compacting` (the transient
// spinner label), summarizes for a beat, then emits the marker and the turn
// continues normally — the divider must render inline.
if (/compact the context/i.test(msg.text)) {
send("compacting", {});
setTimeout(() => {
send("compacted", { text: "Context compacted — earlier turns were summarized" });
send("assistant_message", { text: "Still on it — continuing where I left off." });
send("turn_done");
}, 400);
return;
}
// A turn that dies on a provider error; the follow-up {type:"retry"} recovers.
if (/fail the turn/i.test(msg.text)) {
send("error", { error: "model unreachable" });
send("turn_done");
return;
}
// A deliberately SLOW multi-second stream (~40 ticks × 120ms) so specs can
// interact mid-turn — the follow/pin scroll contract (FB-004) is untestable
// against the instant echo below.
if (/stream the epic/i.test(msg.text)) {
let ticks = 0;
const line = "The epic scrolls ever onward, line upon line upon line. ";
epicTimer = setInterval(() => {
ticks += 1;
send("assistant_delta", { text: line.repeat(3) + "\n\n" });
if (ticks >= 40) {
clearInterval(epicTimer!);
epicTimer = null;
send("assistant_message", { text: ("The epic concludes. " + line).repeat(20) });
send("turn_done");
}
}, 120);
return;
}
send("assistant_delta", { text: "Echo: " });
send("assistant_delta", { text: msg.text });
// Echo the model the message carried — pins the model-per-message contract (the
// composer's visible model must ride on every user_message; 2026-07-04 fix).
// Same for `skill`: the force-run pick must ride as its OWN FIELD, never as text.
// `usage` mirrors the real engine's assistant_message sidecar (OPE-42): fixed
// counts per turn so the usage-chip specs can assert exact accumulation.
send("assistant_message", {
text: `Echo: ${msg.text} [model=${msg.model || "none"}]${msg.skill ? ` [skill=${msg.skill}]` : ""}`,
usage: {
model: msg.model || "anthropic:claude-opus-4-8",
input: 1_000,
output: 200,
cache_read: 8_000,
cache_write: 800,
},
});
send("turn_done");
} else if (msg.type === "approval") {
if (pendingTool === "run_shell") {
if (msg.decision === "deny") {
send("tool_finished", { name: "run_shell", status: "denied" });
send("assistant_message", { text: "Understood — skipped the command." });
} else {
send("tool_finished", { name: "run_shell", status: "done", result_preview: "README.md" });
send("assistant_message", { text: "The command ran; 1 file found." });
}
} else if (msg.decision === "deny") {
send("tool_finished", { name: pendingTool, status: "denied" });
send("assistant_message", { text: "Understood — skipped it." });
} else {
send("tool_finished", { name: pendingTool, status: "done", result_preview: "ok" });
// The decision echoes back so specs can pin what rode the wire (e.g. always_task).
send("assistant_message", { text: `Done via ${pendingTool} [decision=${msg.decision}]` });
}
send("turn_done");
} else if (msg.type === "items_response") {
if (msg.approved) {
seedBoard(); // "created on the board" — the board fetch now shows them
send("assistant_message", {
text: "Items created on the board — [Board · 5 items](board:) if you want to watch. Staffing next.",
});
} else {
send("assistant_message", { text: "Understood — reworking the split." });
}
send("turn_done");
} else if (msg.type === "team_response") {
if (msg.approved) {
// Server-side create_team pre-spawned the workers. The ACTIVE session IS
// the lead (seventeenth pass): stamp it in the sessions list — RECENT keeps
// this ONE entry — and hang the workers off it for the drawer's Team panel.
let lead = sessions.find((s) => s.session_id === sid);
if (!lead) {
lead = {
session_id: sid,
workspace: "/Users/test/OpenWorker/launch-note",
agent: "cowork",
model: "m",
mode: "interactive",
messages: 2,
};
sessions.unshift(lead);
}
lead.title = "Build the statements page";
lead.updated_at = new Date().toISOString();
lead.team = {
role: "lead",
team_id: "t1",
chat_enabled: !!msg.enable_chat,
chat_unread: msg.enable_chat ? 1 : 0,
};
// The lead sets its check-in timer after staffing — it shows as sleeping.
lead.liveness = "sleeping";
lead.sleeping_until = new Date(Date.now() + 4 * 60_000).toISOString();
for (const [actor, persona, status, item] of [
["nia", "swe-worker", "in_progress", "#1 in progress"],
["webb", "design-worker", "idle", "idle"],
["checks", "test-worker", "blocked", "#4 blocked"],
] as const) {
sessions.push({
session_id: `sess-${actor}`,
title: actor,
workspace: "/Users/test/OpenWorker/launch-note",
agent: persona,
model: "m",
mode: "interactive",
updated_at: new Date().toISOString(),
messages: 0,
team: {
role: "worker",
team_id: "t1",
lead_session: sid,
actor,
status,
current_item: item,
},
});
}
send("assistant_message", {
text: "Team created — nia, webb and checks are standing by. Assigning items now.",
});
} else {
send("assistant_message", { text: "Understood — tell me how to change the roster." });
}
send("turn_done");
} else if (msg.type === "tool_response") {
// Either way the turn continues — the point of the contract is that declining
// degrades the report openly instead of dropping the check.
if (msg.approved) {
send("assistant_message", {
text: "Installed gitleaks 8.30.1 — scanned history, no secrets found.",
});
} else {
send("assistant_message", {
text: "Skipped gitleaks. Coverage: history secret sweep done by hand instead.",
});
}
send("turn_done");
} else if (msg.type === "interrupt") {
// Stop mid-stream: like the real engine, end the turn with `interrupted` and
// NO assistant_message — the client owns promoting the partial into the transcript.
if (epicTimer) {
clearInterval(epicTimer);
epicTimer = null;
}
send("interrupted", {});
send("turn_done");
} else if (msg.type === "set_model") {
// Mid-session switch: the server applies it and broadcasts the persisted marker.
// Like the real server, the FIRST bind (fresh session) is silent.
if (hadTurn)
send("model_changed", {
model: msg.model,
text: `Model switched to ${msg.model}`,
});
} else if (msg.type === "retry") {
// Like the real engine: re-runs with NO new user message (turn_start input is empty).
send("turn_start", { input: "" });
send("assistant_message", { text: "Recovered after retry." });
send("turn_done");
}
});
});
await page.route("**/v1/**", async (route) => {
const req = route.request();
const p = new URL(req.url()).pathname;
const m = req.method();
const json = (body: unknown, status = 200) =>
route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
// session-scoped (id-agnostic — any session resolves to the same fixture).
// POST = the per-session mute override (§32 Access toggles) — flip the shared state so
// the section's reload sees the change.
if (/\/v1\/sessions\/[^/]+\/connections$/.test(p)) {
if (m === "POST") {
const b = req.postDataJSON() || {};
const row = connections.connected.find((c) => c.connector === b.connector);
if (row) row.enabled = !!b.enabled;
return json({ ok: true });
}
return json(connections);
}
if (/\/v1\/sessions\/[^/]+\/roots$/.test(p)) {
if (m === "POST") {
const b = req.postDataJSON();
const existing = roots.find((r) => r.path === b.path);
if (existing) existing.writable = !!b.writable;
else roots.push({ path: b.path, writable: !!b.writable, label: baseName(b.path), primary: false, exists: true });
return json({ ok: true, roots });
}
if (m === "DELETE") {
const rp = new URL(req.url()).searchParams.get("path");
const i = roots.findIndex((r) => r.path === rp && !r.primary);
if (i >= 0) roots.splice(i, 1);
return json({ ok: true, roots });
}
return json({ roots });
}
// Artifacts (OPE-91): one HTML report whose content actively probes the sandbox —
// an inline script that renders proof-of-execution, a parent-window escape attempt,
// and an external subresource that must be CSP-blocked.
if (/\/v1\/sessions\/[^/]+\/artifacts\/read$/.test(p)) {
const reqPath = new URL(req.url()).searchParams.get("path") || "";
// UX-037 Files: a session root (or subfolder) reads as a folder listing.
const rootHit = roots.find((r) => reqPath === r.path);
if (rootHit) {
return json({
ok: true,
path: reqPath,
kind: "folder",
entries: [
{ name: "reports", dir: true, size: 0 },
{ name: "notes.md", dir: false, size: 128 },
],
});
}
if (reqPath.endsWith("/reports")) {
return json({
ok: true,
path: reqPath,
kind: "folder",
entries: [{ name: "security-review.html", dir: false, size: 2048 }],
});
}
if (reqPath.endsWith("notes.md")) {
return json({ ok: true, path: reqPath, kind: "markdown", content: "# Notes\n\nhello from the explorer" });
}
return json({
ok: true,
path: "reports/security-review.html",
kind: "html",
content: [
"<h1>Security review</h1>",
'<div id="probe">script did not run</div>',
"<script>",
' document.getElementById("probe").textContent = "script ran in sandbox";',
" try { window.parent.document.title = 'ESCAPED'; } catch (e) {",
' document.getElementById("probe").textContent += " · parent blocked";',
" }",
"</script>",
'<img src="https://evil.example/exfil.png" onerror="document.getElementById(\'probe\').textContent += \' · network blocked\'">',
].join("\n"),
});
}
if (/\/v1\/sessions\/[^/]+\/artifacts\/reveal$/.test(p)) return json({ ok: true });
// Item detail (merged event timeline + attachments) for the detail pane.
if (/\/v1\/sessions\/[^/]+\/board\/item$/.test(p)) {
const id = Number(new URL(req.url()).searchParams.get("id"));
const item = boardItems.find((i) => i.id === id);
if (!item) return json({ error: "no such item" });
const at = new Date().toISOString();
const timeline =
id === 5
? [
{ seq: 30, ts: at, actor: "lead", kind: "created" },
{ seq: 31, ts: at, actor: "lead", kind: "assigned", assignee: "security" },
{ seq: 32, ts: at, actor: "security", kind: "moved", to: "in_progress" },
{
seq: 41,
ts: at,
actor: "security",
kind: "comment",
body: "Rolled all four sections into report.md — balances reconcile against the seeded rows.",
},
{
seq: 42,
ts: at,
actor: "security",
kind: "comment",
body: "attached the rendered page",
refs: [`attachment://${"a".repeat(64)}.png#rendered-page.png`],
},
{
seq: 43,
ts: at,
actor: "security",
kind: "moved",
to: "review",
body: "Ready — balances verified against seeded rows.",
},
]
: [{ seq: 30, ts: at, actor: "lead", kind: "created" }];
return json({ ...item, timeline: timeline.concat(itemNotes[id] || []) });
}
if (/\/v1\/sessions\/[^/]+\/board\/comment$/.test(p) && m === "POST") {
const b = req.postDataJSON() || {};
const id = Number(b.item);
(itemNotes[id] = itemNotes[id] || []).push({
seq: 90 + (itemNotes[id]?.length || 0),
ts: new Date().toISOString(),
actor: "user",
kind: "comment",
body: String(b.body || ""),
});
return json({ ok: true });
}
if (/\/v1\/sessions\/[^/]+\/board\/attachment$/.test(p)) {
// A real 1x1 PNG so the <img> actually loads (the spec asserts it renders).
return route.fulfill({
status: 200,
contentType: "image/png",
body: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
),
});
}
// Agent teams (OPE-96): board reads + the user-side mutations.
if (/\/v1\/sessions\/[^/]+\/board\/transition$/.test(p)) {
const b = req.postDataJSON() || {};
const item = boardItems.find((i) => i.id === Number(b.item));
if (!item) return json({ error: "no such item" });
item.state = String(b.to);
return json(item);
}
if (/\/v1\/sessions\/[^/]+\/board$/.test(p)) return json(boardPayload());
// # team chat (OPE-99): one group, message log, user posts append.
if (/\/v1\/teams\/[^/]+\/chat$/.test(p)) {
if (m === "POST") {
const b = req.postDataJSON() || {};
chatMessages.push({
seq: chatMessages.length + 1,
ts: new Date().toISOString(),
author: "user",
author_role: "user",
text: String(b.text || ""),
mentions: ["nia", "webb", "checks", "lead"].filter((h) =>
String(b.text || "").includes(`@${h}`),
),
});
return json(chatMessages[chatMessages.length - 1]);
}
return json({
enabled: true,
team_id: "t1",
members: [
{ name: "nia", persona: "swe-worker", role: "worker" },
{ name: "webb", persona: "design-worker", role: "worker" },
{ name: "checks", persona: "test-worker", role: "worker" },
{ name: "lead", persona: "swe-lead", role: "lead" },
],
messages: chatMessages,
});
}
if (p.endsWith("/v1/teams/journal")) {
return json({
cases: boardItems.length
? [{ case: "findings", entries: 12, last_ts: new Date().toISOString() }]
: [],
});
}
if (/\/v1\/sessions\/[^/]+\/artifacts$/.test(p)) {
return json({
artifacts: [
{
path: "reports/security-review.html",
abs_path: "/Users/test/OpenWorker/launch-note/reports/security-review.html",
name: "security-review.html",
kind: "html",
size: 2048,
modified_at: Math.floor(Date.now() / 1000) - 60,
},
],
});
}
if (/\/v1\/sessions\/[^/]+\/messages$/.test(p)) return json({ messages: [] });
if (/\/v1\/sessions\/[^/]+\/unattended$/.test(p)) {
const id = decodeURIComponent(p.split("/").slice(-2)[0]);
if (m === "POST") {
unattended[id] = !!req.postDataJSON().unattended;
return json({ ok: true, unattended: unattended[id] });
}
return json({ unattended: !!unattended[id] });
}
if (/\/v1\/sessions\/[^/]+$/.test(p)) {
const id = decodeURIComponent(p.split("/").pop()!);
const i = sessions.findIndex((s) => s.session_id === id);
if (m === "PATCH") {
if (i >= 0) Object.assign(sessions[i], req.postDataJSON());
return json({ ok: true });
}
if (m === "DELETE") {
if (i >= 0) sessions.splice(i, 1);
return json({ ok: true });
}
return json(i >= 0 ? sessions[i] : PINNED_SESSION);
}
// Skills (SKILLS-SPEC §5/§6). Order matters: upload/confirm before the {name} regexes.
if (/\/v1\/sessions\/[^/]+\/skills$/.test(p)) {
// The composer's live menu: every Settings-enabled skill (§4 — disabled = invisible).
return json({
skills: skills
.filter((s) => s.enabled)
.map((s) => ({ name: s.name, description: s.description, scope: s.scope, enabled: true })),
});
}
if (p.endsWith("/v1/skills/upload/confirm") && m === "POST") {
const b = req.postDataJSON() || {};
if (!stagedSkill || b.token !== stagedSkill.token)
return json({ ok: false, error: "Upload expired — pick the file again." });
skills.push({
name: stagedSkill.name, description: stagedSkill.description,
instructions: stagedSkill.instructions, scope: "global", source: "uploaded",
enabled: true, path: `/state/skills/${stagedSkill.name}`, files: stagedSkill.files.length,
});
stagedSkill = null;
return json({ ok: true });
}
if (p.endsWith("/v1/skills/upload") && m === "POST") {
// Stage → preview; nothing lands until confirm. Fixed parse (the mock reads no zips).
stagedSkill = {
token: "stage-1", name: "greet", description: "says hello",
instructions: "Say hello warmly.", files: ["notes.txt"],
};
return json({ ok: true, ...stagedSkill });
}
{
const mr = p.match(/\/v1\/skills\/([^/]+)\/reveal$/);
if (mr && m === "POST") {
return json(
skills.some((s) => s.name === decodeURIComponent(mr[1]))
? { ok: true }
: { ok: false, error: `Unknown skill: ${decodeURIComponent(mr[1])}` },
);
}
}
if (/\/v1\/skills\/[^/]+$/.test(p) && (m === "PATCH" || m === "DELETE")) {
const name = decodeURIComponent(p.split("/").pop()!);
const i = skills.findIndex((s) => s.name === name);
if (i < 0) return json({ ok: false, error: `Unknown skill: ${name}` });
if (m === "DELETE") {
skills.splice(i, 1);
return json({ ok: true });
}
const b = req.postDataJSON() || {};
if (typeof b.enabled === "boolean") skills[i].enabled = b.enabled;
if (typeof b.description === "string") skills[i].description = b.description;
if (typeof b.instructions === "string") skills[i].instructions = b.instructions;
return json({ ok: true });
}
if (p.endsWith("/v1/skills") && m === "POST") {
const b = req.postDataJSON() || {};
if (!b.name || !(b.instructions || "").trim())
return json({ ok: false, error: "Skill name and instructions are required." });
if (skills.some((s) => s.name === b.name))
return json({ ok: false, error: `A skill named '${b.name}' already exists in that scope.` });
skills.push({
name: b.name, description: b.description || "", instructions: b.instructions,
scope: "global", source: "local", enabled: true, path: `/state/skills/${b.name}`, files: 0,
});
return json({ ok: true });
}
if (p.endsWith("/v1/skills")) return json({ skills });
if (p.endsWith("/v1/health")) return json(HEALTH);
if (p.endsWith("/v1/settings")) return json(SETTINGS);
if (p.endsWith("/v1/settings/context-bar") && m === "POST") {
Object.assign(SETTINGS, req.postDataJSON());
return json({ ok: true, context_bar: SETTINGS.context_bar });
}
if (p.endsWith("/v1/settings/pdf") && m === "POST") {
Object.assign(SETTINGS, req.postDataJSON());
return json({
ok: true,
pdf_fallback: SETTINGS.pdf_fallback,
pdf_max_pages: SETTINGS.pdf_max_pages,
pdf_max_mb: SETTINGS.pdf_max_mb,
});
}
if (p.endsWith("/v1/attachments/inspect-pdf") && m === "POST") {
// Page count for the composer threshold check: the tests encode it in the PDF body
// as "%%pages=N" (the mock doesn't parse real PDFs).
const data = String(req.postDataJSON()?.data_url || "");
const match = /%%pages=(\d+)/.exec(atob(data.split(",")[1] || "") || "");
return json({ ok: true, pages: match ? Number(match[1]) : 1, bytes: data.length });
}
if (p.endsWith("/v1/workspaces/recent")) return json({ workspaces: [] });
if (p.endsWith("/v1/workspaces/pick") && m === "POST") {
return json({ ok: true, path: "/tmp/picked-folder" });
}
if (p.endsWith("/v1/workspaces/open") && m === "POST") {
const b = req.postDataJSON();
return json({ ok: true, path: b.path, git_branch: "main" });
}
if (p.endsWith("/v1/workspaces/temp") && m === "POST") {
// UX-029: "Start in a temporary folder" — created at send time, git-ready.
const b = req.postDataJSON();
return json({ ok: true, path: `/tmp/ow-temp/${b.session_id}`, git: b.git !== false });
}
if (/\/v1\/sessions\/[^/]+\/save-as-project$/.test(p) && m === "POST") {
const b = req.postDataJSON();
return json({ ok: true, path: b.path });
}
if (/\/v1\/personas\/[^/]+\/export$/.test(p) && m === "POST") {
// Sharing v1: export the bundle zip into the chosen folder.
const id = p.split("/").slice(-2)[0];
const b = req.postDataJSON();
return json({ ok: true, path: `${b.dir}/${id}-coworker-v1.zip` });
}
// must precede the /v1/personas/{id} catch-all (install matches it too)
if (p.endsWith("/v1/personas/install") && m === "POST") {
const b = req.postDataJSON();
if (b.zip_b64) {
// Sharing v1: a bundle zip import — consent with version + replaces + recommends.
const imported = {
id: "team-sec", name: "Team Security Coworker", icon: "shield",
tagline: "Our security playbook", requires_folder: true, builtin: false,
tools: ["code_files", "search", "shell"],
enabled: false, surfaced: false, default: false, version: "2",
};
if (!personas.some((x) => x.id === "team-sec")) personas.push(imported);
return json({
ok: true,
personas,
consent: [{
id: "team-sec", name: "Team Security Coworker",
description: "Reviews code the way our team does.",
tools: ["code_files", "search", "shell"],
risk: ["read", "write_local", "exec"],
connectors: true, mcp: [], messaging: false,
recommended_mode: "interactive", recommended_models: [],
recommends: [{ kind: "connector", ref: "github", reason: "open fix PRs", tier: "core" }],
version: "2",
replaces: { version: "1", installed_at: "2026-08-01", capabilities_grew: true },
source: "/tmp/team-sec.zip", builtin: false,
}],
});
}
if (b.gallery_slug) {
return json(
CLOUD_STATE.signed_in
? { ok: true, consent: [{ id: b.gallery_slug }], personas }
: { ok: false, error: "gallery requires cloud sign-in" },
);
}
return json({ ok: false, error: "unsupported in mock" });
}
if (/\/v1\/personas\/[^/]+$/.test(p) && m === "POST") {
// Persona flag update (enabled/surfaced/default). Backend parity: enabling implies
// surfacing (registry.set_enabled sets surfaced — the PM-invisible bug fix).
const id = p.split("/").pop();
const t = personas.find((x) => x.id === id);
if (!t) return json({ ok: false, error: `unknown persona: ${id}` });
const b = req.postDataJSON();
if (b.default) personas.forEach((x) => (x.default = x.id === id));
let archivedCount = 0;
if (typeof b.enabled === "boolean") {
t.enabled = b.enabled;
if (b.enabled) t.surfaced = true;
// Backend parity (disable-archives, §18): disabling archives the persona's real
// sessions server-side, so its sidebar section disappears with it.
if (!b.enabled) {
for (const s of sessions) {
if (s.agent === id && !s.archived && !s.session_id.startsWith("__")) {
s.archived = true;
archivedCount++;
}
}
}
}
if (typeof b.surfaced === "boolean") t.surfaced = b.surfaced;
return json({ ok: true, personas, archived_sessions: archivedCount });
}
if (/\/v1\/personas\/[^/]+$/.test(p) && m === "DELETE") {
const id = p.split("/").pop();
const i = personas.findIndex((x) => x.id === id && !x.builtin);
if (i < 0) return json({ ok: false, error: `unknown persona: ${id}` });
personas.splice(i, 1);
return json({ ok: true, personas });
}
if (/\/v1\/personas\/[^/]+$/.test(p)) {
// Detail merges the live list row over the static shape, so enable/surface/default
// state and builtin-ness track the same mutable array the list serves.
const id = decodeURIComponent(p.split("/").pop() || "");
const base = personas.find((x) => x.id === id);
return json({
...PERSONA_DETAIL,
media: [],
surfaced: true,
default: false,
builtin: true,
group: "general",
...(base || {}),
recommends: PERSONA_DETAIL.recommends,
default_connections: PERSONA_DETAIL.default_connections,
});
}
if (p.endsWith("/v1/personas")) return json({ internal: PERSONAS.internal, personas });
if (p.endsWith("/v1/sessions")) return json({ sessions });
if (/\/v1\/connectors\/slack\/unauthorized\/[^/]+$/.test(p) && m === "POST") {
const id = p.split("/").pop();
const i = parked.findIndex((x) => x.id === id);
if (i < 0) return json({ ok: false, error: "unknown item" });
const b = req.postDataJSON();
const item = parked.splice(i, 1)[0];
// Backend parity: allowing routes to the item's OWN workspace's list (ids are
// workspace-scoped); a team-less item lands on the flat list (manual mode).
if (b.action === "allow" || b.action === "allow_deliver") {
const pool = item.team_id
? slackState.workspaces.find((w) => w.team_id === item.team_id)?.allowed_users
: slackState.allowed_users;
if (pool && !pool.includes(item.user_id)) pool.push(item.user_id);
}
return json({ ok: true });
}
// Per-workspace allow/disallow (team_id in the body) + the flat manual list without it.
if (/\/v1\/connectors\/slack\/(allow|disallow)$/.test(p) && m === "POST") {
const b = req.postDataJSON();
const add = p.endsWith("/allow");
const ws = b.team_id
? slackState.workspaces.find((w) => w.team_id === b.team_id)
: null;
const pool = b.team_id ? ws?.allowed_users : slackState.allowed_users;
if (!pool) return json({ ok: false, error: "workspace not connected" });
const i = pool.indexOf(b.user_id);
if (add && i < 0) pool.push(b.user_id);
if (!add && i >= 0) pool.splice(i, 1);
// Directory picks carry the display name — backend seeds the people directory.
if (add && b.name && ws) ws.allowed_user_names[b.user_id] = b.name;
return json({ ok: true, allowed_users: [...pool], team_id: b.team_id ?? null });
}
if (p.endsWith("/v1/connectors/slack/approval-owners/add") && m === "POST") {
const b = req.postDataJSON();
if (!slackState.approval_owner_ids.includes(b.user_id))
slackState.approval_owner_ids.push(b.user_id);
if (!slackState.allowed_users.includes(b.user_id))
slackState.allowed_users.push(b.user_id);
return json({
ok: true,
approval_owner_ids: [...slackState.approval_owner_ids],
allowed_users: [...slackState.allowed_users],
});
}
if (p.endsWith("/v1/connectors/slack/approval-owners/remove") && m === "POST") {
const b = req.postDataJSON();
const i = slackState.approval_owner_ids.indexOf(b.user_id);
if (i >= 0) slackState.approval_owner_ids.splice(i, 1);
return json({ ok: true, approval_owner_ids: [...slackState.approval_owner_ids] });
}
// Workspace rosters for the pickers (users.list / conversations.list, mocked).
if (/\/v1\/connectors\/slack\/workspaces\/[^/]+\/directory$/.test(p) && m === "GET") {
const q = (new URL(req.url()).searchParams.get("q") || "").toLowerCase();
const members = [
{ id: "U9MAYA", name: "Maya Chen", handle: "maya", guest: false },
{ id: "U8ROHIT", name: "Rohit Prasad", handle: "rohit", guest: false },
{ id: "U7CAL", name: "Contractor Cal", handle: "cal", guest: true },
].filter((mem) => !q || mem.name.toLowerCase().includes(q) || mem.handle.includes(q));
return json({ ok: true, members });
}
if (/\/v1\/connectors\/slack\/workspaces\/[^/]+\/channels$/.test(p) && m === "GET") {
const team = decodeURIComponent(p.split("/workspaces/")[1].split("/")[0]);
const q = (new URL(req.url()).searchParams.get("q") || "").toLowerCase();
const channels = [
{ id: "C9LAUNCH", name: "launch-team", is_private: false, is_member: true },
{ id: "C8LEADS", name: "leads", is_private: true, is_member: true },
{ id: "C7LOBBY", name: "lobby", is_private: false, is_member: false },
].filter((c) => !q || c.name.includes(q));
return json({ ok: true, channels, team });
}
// Slack health, three layers (M3.6 Step 2): socket live + all tokens good by
// default; sign-in mirrors CLOUD_STATE. Specs force reconnecting/offline/dead
// tokens by registering a later page.route override (later routes match first).
if (p.endsWith("/v1/connectors/slack/status"))
return json({
ok: true,
mode: slackState.mode,
relay: { state: "live", reconnects: 0, last_event_at: Date.now() / 1000 - 30, last_error: "" },
signed_in: CLOUD_STATE.signed_in,
teams: Object.fromEntries(
slackState.workspaces.map((w) => [w.team_id, { token_ok: true }]),
),
});
// Stop relaying one workspace; removing the last flips the connector off (backend parity).
if (/\/v1\/connectors\/slack\/workspaces\/[^/]+\/disconnect$/.test(p) && m === "POST") {
const teamId = decodeURIComponent(p.split("/").slice(-2)[0]);
const i = slackState.workspaces.findIndex((w) => w.team_id === teamId);
if (i < 0) return json({ ok: false, error: "workspace not connected" });
slackState.workspaces.splice(i, 1);
if (slackState.workspaces.length === 0) {
slackState.connected = false;
slackState.mode = "";
}
return json({ ok: true, remaining_workspaces: slackState.workspaces.length });
}
// GitHub relay (github-relay-spec §8): per-installation allow/disallow, parked
// resolution, status, per-installation disconnect.
if (/\/v1\/connectors\/github\/unauthorized\/[^/]+$/.test(p) && m === "POST") {
const id = p.split("/").pop();
const i = githubParked.findIndex((x) => x.id === id);
if (i < 0) return json({ ok: false, error: "unknown item" });
const b = req.postDataJSON();
const item = githubParked.splice(i, 1)[0];
if (b.action === "allow" || b.action === "allow_deliver") {
const pool = githubState.installations.find(
(x) => x.installation_id === item.team_id,
)?.allowed_users;
if (pool && !pool.includes(item.user_id)) pool.push(item.user_id);
}
return json({ ok: true });
}
if (/\/v1\/connectors\/github\/(allow|disallow)$/.test(p) && m === "POST") {
const b = req.postDataJSON();
const pool = githubState.installations.find(
(x) => x.installation_id === b.team_id,
)?.allowed_users;
if (!pool) return json({ ok: false, error: "installation not connected" });
const add = p.endsWith("/allow");
const i = pool.indexOf(b.user_id);
if (add && i < 0) pool.push(b.user_id);
if (!add && i >= 0) pool.splice(i, 1);
return json({ ok: true, allowed_users: [...pool], team_id: b.team_id ?? null });
}
if (p.endsWith("/v1/connectors/github/status"))
return json({
ok: true,
mode: githubState.mode,
relay: { state: "live", reconnects: 0, last_event_at: Date.now() / 1000 - 30, last_error: "" },
signed_in: CLOUD_STATE.signed_in,
installs: Object.fromEntries(
githubState.installations.map((x) => [x.installation_id, { token_ok: true }]),
),
missed: {},
});
if (/\/v1\/connectors\/github\/installations\/[^/]+\/disconnect$/.test(p) && m === "POST") {
const iid = decodeURIComponent(p.split("/").slice(-2)[0]);
const i = githubState.installations.findIndex((x) => x.installation_id === iid);
if (i < 0) return json({ ok: false, error: "installation not connected" });
githubState.installations.splice(i, 1);
if (githubState.installations.length === 0) {
githubState.connected = false;
githubState.mode = "";
}
return json({ ok: true, remaining_installs: githubState.installations.length });
}
// Gmail multi-account management (M3.6 Step 3): per-account disconnect/default
// + the "Never show agents" filter lists.
if (/\/v1\/connectors\/gmail\/accounts\/[^/]+\/disconnect$/.test(p) && m === "POST") {
const email = decodeURIComponent(p.split("/").slice(-2)[0]);
const i = gmailState.accounts.findIndex((a) => a.email === email);
if (i < 0) return json({ ok: false, error: "account not connected" });
const wasDefault = gmailState.accounts[i].default;
gmailState.accounts.splice(i, 1);
if (wasDefault && gmailState.accounts[0]) gmailState.accounts[0].default = true;
return json({ ok: true, remaining_accounts: gmailState.accounts.length });
}
if (/\/v1\/connectors\/google_calendar\/accounts\/[^/]+\/disconnect$/.test(p) && m === "POST") {
const email = decodeURIComponent(p.split("/accounts/")[1].split("/")[0]);
const i = gcalState.accounts.findIndex((a) => a.email === email);
if (i < 0) return json({ ok: false, error: "account not connected" });
const wasDefault = gcalState.accounts[i].default;
gcalState.accounts.splice(i, 1);
if (wasDefault && gcalState.accounts[0]) gcalState.accounts[0].default = true;
return json({ ok: true, remaining_accounts: gcalState.accounts.length });
}
if (/\/v1\/connectors\/google_calendar\/accounts\/[^/]+\/default$/.test(p) && m === "POST") {
const email = decodeURIComponent(p.split("/accounts/")[1].split("/")[0]);
if (!gcalState.accounts.some((a) => a.email === email))
return json({ ok: false, error: "account not connected" });
for (const a of gcalState.accounts) a.default = a.email === email;
return json({ ok: true, default_account: email });
}
if (/\/v1\/connectors\/gmail\/accounts\/[^/]+\/default$/.test(p) && m === "POST") {
const email = decodeURIComponent(p.split("/").slice(-2)[0]);
if (!gmailState.accounts.some((a) => a.email === email))
return json({ ok: false, error: "account not connected" });
for (const a of gmailState.accounts) a.default = a.email === email;
return json({ ok: true, default_account: email });
}
if (p.endsWith("/v1/connectors/gmail/filters") && m === "PATCH") {
const b = req.postDataJSON() || {};
if (Array.isArray(b.senders)) gmailState.filters.senders = b.senders;
if (Array.isArray(b.labels)) gmailState.filters.labels = b.labels;
return json({ ok: true, filters: { ...gmailState.filters } });
}
// Generic multi-account management (accounts.py layer; notion in fixtures).
if (/\/v1\/connectors\/notion\/accounts\/[^/]+\/disconnect$/.test(p) && m === "POST") {
const id = decodeURIComponent(p.split("/accounts/")[1].split("/")[0]);
const i = notionState.accounts.findIndex((a) => a.account_id === id);
if (i < 0) return json({ ok: false, error: "account not connected" });
const wasDefault = notionState.accounts[i].default;
notionState.accounts.splice(i, 1);
if (wasDefault && notionState.accounts[0]) notionState.accounts[0].default = true;
return json({ ok: true, remaining_accounts: notionState.accounts.length });
}
if (/\/v1\/connectors\/notion\/accounts\/[^/]+\/default$/.test(p) && m === "POST") {
const id = decodeURIComponent(p.split("/accounts/")[1].split("/")[0]);
if (!notionState.accounts.some((a) => a.account_id === id))
return json({ ok: false, error: "account not connected" });
for (const a of notionState.accounts) a.default = a.account_id === id;
return json({ ok: true, default_account: id });
}
// HubSpot multi-portal management (M3.6 Step 4).
if (/\/v1\/connectors\/hubspot\/portals\/[^/]+\/disconnect$/.test(p) && m === "POST") {
const hub = decodeURIComponent(p.split("/").slice(-2)[0]);
const i = hubspotState.portals.findIndex((x) => x.hub_id === hub);
if (i < 0) return json({ ok: false, error: "portal not connected" });
const wasDefault = hubspotState.portals[i].default;
hubspotState.portals.splice(i, 1);
if (wasDefault && hubspotState.portals[0]) hubspotState.portals[0].default = true;
return json({ ok: true, remaining_portals: hubspotState.portals.length });
}
if (/\/v1\/connectors\/hubspot\/portals\/[^/]+\/default$/.test(p) && m === "POST") {
const hub = decodeURIComponent(p.split("/").slice(-2)[0]);
if (!hubspotState.portals.some((x) => x.hub_id === hub))
return json({ ok: false, error: "portal not connected" });
for (const x of hubspotState.portals) x.default = x.hub_id === hub;
return json({ ok: true, default_portal: hub });
}
if (p.endsWith("/v1/connectors/hubspot/hidden-fields") && m === "PATCH") {
const b = req.postDataJSON() || {};
if (Array.isArray(b.hidden_fields))
hubspotState.hidden_fields = b.hidden_fields.map((f: string) => f.trim().toLowerCase());
return json({ ok: true, hidden_fields: [...hubspotState.hidden_fields] });
}
if (p.endsWith("/v1/connectors"))
return json({
connectors: [
slackConnector(),
githubConnector(),
...CONNECTORS.connectors.map((c: any) =>
c.name === "gmail"
? gmailConnector()
: c.name === "google_calendar"
? gcalConnector()
: c.name === "hubspot"
? hubspotConnector()
: c.name === "notion"
? notionConnector()
: c.name === "outlook"
? outlookConnector()
: c.name === "monday" || c.name === "jira"
? mcpConnector(c.name)
: { ...c },
),
],
});
if (p.endsWith("/v1/cloud/status")) return json({ ...CLOUD_STATE });
if (p.endsWith("/v1/cloud/login") && m === "POST") {
Object.assign(CLOUD_STATE, { signed_in: true, account: "rohit@openworker.com", user_id: "usr_e2e" });
return json({ ok: true });
}
if (p.endsWith("/v1/cloud/telemetry") && m === "POST") {
CLOUD_STATE.telemetry_enabled = !!req.postDataJSON().enabled;
return json({ ok: true, telemetry_enabled: CLOUD_STATE.telemetry_enabled });
}
if (p.endsWith("/v1/cloud/logout") && m === "POST") {
Object.assign(CLOUD_STATE, { signed_in: false, account: "", user_id: "" });
return json({ ok: true, signed_in: false });
}
if (/\/v1\/connectors\/[^/]+\/mcp-connect$/.test(p) && m === "POST") {
// Local MCP OAuth flow — no cloud sign-in required; completes instantly here.
const name = p.match(/\/v1\/connectors\/([^/]+)\/mcp-connect$/)?.[1] as
| "monday"
| "jira";
if (name in mcpState) {
mcpState[name] = true;
return json({ ok: true, started: true });
}
return json({ ok: false, error: `${name} has no MCP connect path` });
}
if (/\/v1\/connectors\/[^/]+\/connect-managed$/.test(p) && m === "POST") {
if (!CLOUD_STATE.signed_in) return json({ ok: false, error: "not signed in" });
// Slack managed install = add a workspace. The real flow completes in the system
// browser; the mock installs instantly so the page's poll picks it up.
if (p.includes("/connectors/slack/")) {
slackState.workspaces.push({ team_id: "T3NEW", account: "new-workspace", domain: "new-workspace", allowed_users: ["U_ME"], allow_all: false, allowed_user_names: { U_ME: "Rohit Prasad" }, approval_owner_ids: ["U_ME"], approval_owner_names: { U_ME: "Rohit Prasad" }, installer_user_id: "U_ME", installer_name: "Rohit Prasad" });
slackState.connected = true;
slackState.mode = "relay";
}
// GitHub managed connect = install on the next account (instant, like Slack).
if (p.includes("/connectors/github/")) {
githubState.installations.push({
installation_id: "202", account_login: "hooli", account_type: "Organization",
repo_selection: "all", github_login: "rohit-dev", allowed_users: ["rohit-dev"], allow_all: false,
});
githubState.connected = true;
githubState.mode = "relay";
}
// Gmail managed connect = add the next mailbox; the first becomes default.
if (p.includes("/connectors/gmail/")) {
const email = GMAIL_NEXT[gmailState.accounts.length] || `acct${gmailState.accounts.length}@x.com`;
gmailState.accounts.push({
email, default: gmailState.accounts.length === 0, managed: true,
scopes: "gmail.readonly gmail.send", needs_reauth: false,
});
}
// Google Calendar managed connect = add the next account (gmail's flow).
if (p.includes("/connectors/google_calendar/")) {
const email = GCAL_NEXT[gcalState.accounts.length] || `acct${gcalState.accounts.length}@x.com`;
gcalState.accounts.push({
email, default: gcalState.accounts.length === 0, managed: true,
scopes: "calendar", needs_reauth: false,
});
}
// Outlook managed connect = add the next mailbox (email-keyed accounts).
if (p.includes("/connectors/outlook/")) {
outlookState.accounts.push({
account_id: `mbx${outlookState.accounts.length + 1}@openworker.com`,
name: `mbx${outlookState.accounts.length + 1}@openworker.com`,
default: outlookState.accounts.length === 0,
managed: true,
});
}
// Notion managed connect = add the next workspace (generic accounts layer).
if (p.includes("/connectors/notion/")) {
const next = NOTION_NEXT[notionState.accounts.length] || {
account_id: `ws-${notionState.accounts.length + 1}`, name: "extra",
};
notionState.accounts.push({
...next, default: notionState.accounts.length === 0, managed: true,
});
}
// HubSpot managed connect = add the next portal at the requested access tier.
if (p.includes("/connectors/hubspot/")) {
const access = (req.postDataJSON() || {}).access || "read";
const next = HUBSPOT_NEXT[hubspotState.portals.length] || {
hub_id: `9${hubspotState.portals.length}`, name: "extra", sandbox: false,
};
hubspotState.portals.push({
...next, default: hubspotState.portals.length === 0, managed: true, access,
});
}
return json({ ok: true });
}
if (p.endsWith("/v1/cloud/gallery")) {
return json(
CLOUD_STATE.signed_in
? { ok: true, personas: GALLERY_PERSONAS }
: { ok: false, error: "gallery requires cloud sign-in", personas: [] },
);
}
if (/\/v1\/cloud\/gallery\/[^/]+$/.test(p)) {
if (!CLOUD_STATE.signed_in) return json({ ok: false, error: "gallery requires cloud sign-in" });
const slug = p.split("/").pop();
const cardBase = GALLERY_PERSONAS.find((g) => g.slug === slug) ?? GALLERY_PERSONAS[0];
return json({
ok: true,
card: { ...cardBase, pitch_markdown: "**Walk into every call already knowing the account.**" },
capabilities: {
tools: ["files", "search", "todo"],
risk: [],
connectors: true,
mcp: [],
messaging: true,
recommended_mode: "interactive",
recommended_models: [],
},
recommends: [
{ kind: "connector", ref: "hubspot", reason: "read deals and contacts", tier: "core" },
],
});
}
// provider credential check (read-only) — an api_key containing "bad" fails, else ok.
if (p.endsWith("/v1/providers/verify") && m === "POST") {
const key = String(req.postDataJSON()?.fields?.api_key || "");
return /bad/i.test(key)
? json({ ok: false, error: "Invalid API key." })
: json({ ok: true });
}
// save a provider key — flips `configured`, stamps key_set_at (backend set_provider parity).
if (p.endsWith("/v1/providers") && m === "POST") {
const b = req.postDataJSON();
const prov = providers.find((x) => x.name === b.name);
if (!prov) return json({ ok: false, error: `unknown provider: ${b.name}` });
if (b.fields?.api_key) {
prov.configured = true;
prov.key_set_at = "2026-07-05";
}
// Backend parity: non-secret fields merge into `values` (empty clears them).
for (const [k, v] of Object.entries(b.fields || {})) {
if (k === "api_key") continue;
if (v) prov.values = { ...prov.values, [k]: v };
else if (prov.values) delete prov.values[k];
}
return json({ ok: true, provider: b.name, recommended_model: null });
}
// forget a provider's stored config (Settings ▸ Models "Remove key…").
if (/\/v1\/providers\/[^/]+$/.test(p) && m === "DELETE") {
const name = p.split("/").pop()!;
const prov = providers.find((x) => x.name === name);
if (!prov) return json({ ok: false, error: `unknown provider: ${name}` });
prov.configured = !prov.needs_key; // keyless (ollama) stays "configured"
prov.key_set_at = null;
return json({ ok: true, provider: name });
}
// Subscription OAuth sign-in (openai-codex): the flow "completes" instantly —
// signin flips the row to signed in; status mirrors it; signout clears it.
if (p.endsWith("/v1/providers/openai-codex/signin") && m === "POST") {
const prov = providers.find((x) => x.name === "openai-codex");
if (prov) {
prov.signed_in = true;
prov.configured = true;
prov.account = "rohit@example.com";
}
return json({ ok: true, started: true });
}
if (p.endsWith("/v1/providers/openai-codex/status")) {
const prov = providers.find((x) => x.name === "openai-codex");
return json({
signed_in: !!prov?.signed_in,
account: prov?.account || null,
authorizing: false,
last_error: null,
authorize_url: null,
});
}
if (p.endsWith("/v1/providers/openai-codex/signout") && m === "POST") {
const prov = providers.find((x) => x.name === "openai-codex");
if (prov) {
prov.signed_in = false;
prov.configured = false;
prov.account = null;
}
return json({ ok: true, had_tokens: true });
}
if (p.endsWith("/v1/providers")) return json(providers);
if (p.endsWith("/v1/channels/recent"))
return json({
channels: [
{ channel: "slack:C0AAA111", name: "ocw-test", last_from: "amy", last_text: "standup at 10" },
{ channel: "slack:C0BBB222", last_from: "bob", last_text: "deploy failed" },
],
});
// inbox: pending items + the outbound routing binding (inline Slack config)
if (/\/v1\/inbox\/[^/]+\/resolve$/.test(p) && m === "POST") {
const id = decodeURIComponent(p.split("/")[p.split("/").length - 2]);
const it = inbox.find((x) => x.id === id);
if (it) {
it.state = "resolved";
it.resolution = req.postDataJSON().resolution;
}
return json({ ok: true });
}
if (p.endsWith("/v1/inbox/routing/binding") && m === "POST") {
const b = req.postDataJSON();
routing.channel = b.channel;
routing.target = b.target;
return json({ ok: true, bindings: [{ ...routing }] });
}
if (p.endsWith("/v1/inbox/routing")) return json({ bindings: [{ ...routing }] });
if (p.endsWith("/v1/inbox")) {
const q = new URL(req.url()).searchParams;
const sid = q.get("session_id");
const state = q.get("state");
return json({
items: inbox.filter(
(i) => (!sid || i.session_id === sid) && (!state || i.state === state),
),
});
}
// automations: one scheduled task with a running run (drives the Automations detail page
// and the run-session banner + Back-to-runs flow). Mutable: Run now appends a run and opens
// its live session; the enable toggle (PATCH) and delete (DELETE) round-trip through the UI.
if (/\/v1\/automations\/[^/]+\/seen$/.test(p) && m === "POST") {
const id = p.split("/").slice(-2)[0];
const task = automations.find((t) => t.id === id);
if (task) {
task.unseen_runs = 0;
task.unseen_failed = false;
task.seen_runs_at = Math.floor(Date.now() / 1000);
}
return json({ ok: !!task });
}
if (/\/v1\/automations\/[^/]+\/run$/.test(p) && m === "POST") {
const id = p.split("/").slice(-2)[0];
const task = automations.find((t) => t.id === id);
if (!task) return json({ ok: false, error: "unknown task" });
const runId = `r${automationRuns.length + 1}`;
automationRuns.unshift({
run_id: runId,
task_id: id,
session_id: `__run__${runId}`,
started_at: Math.floor(Date.now() / 1000),
finished_at: null,
status: "running",
result_text: null,
artifacts: [],
error: null,
trigger: "manual",
});
return json({
ok: true,
run_id: runId,
session_id: `__run__${runId}`,
workspace: task.workspace,
agent: task.agent,
prompt: task.instructions,
});
}
if (/\/v1\/automations\/[^/]+$/.test(p) && m === "GET") {
const id = p.split("/").pop();
const task = automations.find((t) => t.id === id) ?? automations[0];
return json({ task, runs: automationRuns.filter((r) => r.task_id === task?.id) });
}
if (/\/v1\/automations\/[^/]+$/.test(p) && m === "PATCH") {
const id = p.split("/").pop();
const task = automations.find((t) => t.id === id);
const body = req.postDataJSON() ?? {};
if (task && body.revoke) {
// Standing-rule revocation (§25): remove the entry; `revoke` is a command,
// not a field to Object.assign onto the task.
task.always_allowed = (task.always_allowed || []).filter(
(r: any) => r.entry !== body.revoke,
);
return json({ ok: true, task });
}
if (task) Object.assign(task, body);
return json({ ok: true, task });
}
if (/\/v1\/automations\/[^/]+$/.test(p) && m === "DELETE") {
const id = p.split("/").pop();
const i = automations.findIndex((t) => t.id === id);
if (i >= 0) automations.splice(i, 1);
return json({ ok: true });
}
if (p.endsWith("/v1/automations") && m === "POST") {
// GUI/onboarding-recipe create (§24) — mirrors the server: title+instructions+cron
// required; §25 permissions become always_allowed entries (write grants only).
const body = req.postDataJSON() || {};
if (!body.title || !body.instructions || !(body.cron || body.fire_at))
return json({ ok: false, error: "missing fields" });
const grants = (body.permissions || [])
.filter((g: any) => g && g.access === "write" && g.tool && g.target)
.map((g: any) => ({ entry: `${g.tool} ${g.target}`, tool: g.tool, target: g.target }));
const task = {
...AUTOMATION,
id: `task-ob-${automations.length}`,
title: body.title,
instructions: body.instructions,
schedule: body.cron || body.fire_at,
always_allowed: grants,
run_count: 0,
};
automations.push(task);
return json({ ok: true, task });
}
if (p.endsWith("/v1/automations")) return json({ tasks: automations });
if (p.endsWith("/v1/settings/onboarded") && m === "POST") {
return json({ ok: true, onboarded: !!(req.postDataJSON() || {}).value });
}
// MCP servers — mutable so the OAuth quick-add (granola) flow reflects through the
// UI: add → needs_auth, connect → authorizing, next poll → connected (6 tools).
if (p.endsWith("/v1/mcp") && m === "GET") {
for (const s2 of mcpServers) {
if (s2.status === "authorizing" && s2._flip) {
// Servers named locked-* simulate a guarded remote: the anonymous
// probe 401s (→ needs sign-in) until the entry is switched to oauth.
if (s2.name.startsWith("locked") && s2.auth !== "oauth") {
s2.status = "error";
s2.auth_hint = true;
s2.last_error = "authentication required — sign in to connect";
} else {
s2.status = "connected";
s2.tool_count = 6;
s2.last_test_at = 1700000000; // the successful probe stamps the row
}
}
if (s2.status === "authorizing") s2._flip = true;
}
return json({ servers: mcpServers.map(({ _flip, ...s2 }) => s2) });
}
if (p.endsWith("/v1/mcp") && m === "POST") {
const b = req.postDataJSON();
mcpServers.push({
name: b.name,
enabled: true,
transport: b.config?.url ? "http" : "stdio",
requires_approval: true,
auth: b.config?.auth === "oauth" ? "oauth" : null,
status: b.config?.auth === "oauth" ? "needs_auth" : "configured",
auth_hint: false,
last_test_at: null,
last_error: null,
tool_count: null,
config: b.config || {},
});
return json({ ok: true, name: b.name });
}
{
const mc = p.match(/\/v1\/mcp\/([^/]+)\/connect$/);
if (mc && m === "POST") {
const s2 = mcpServers.find((x) => x.name === decodeURIComponent(mc[1]));
if (s2) {
s2.status = "authorizing";
s2.auth_hint = false;
s2.last_error = null;
s2._flip = false;
}
return json({ ok: true, started: true });
}
const ms = p.match(/\/v1\/mcp\/([^/]+)\/signout$/);
if (ms && m === "POST") {
const s2 = mcpServers.find((x) => x.name === decodeURIComponent(ms[1]));
if (s2) {
s2.status = "needs_auth";
s2.tool_count = null;
s2._flip = false;
}
return json({ ok: true });
}
const mp = p.match(/\/v1\/mcp\/([^/]+)$/);
if (mp && m === "PATCH") {
const s2 = mcpServers.find((x) => x.name === decodeURIComponent(mp[1]));
const b = req.postDataJSON() || {};
if (s2) {
if (b.enabled !== undefined) s2.enabled = b.enabled;
if (b.auth === "oauth") {
// The needs-sign-in fix: entry switches to oauth; the follow-up
// connect runs the browser flow.
s2.auth = "oauth";
s2.auth_hint = false;
s2.status = "needs_auth";
}
s2.config = { ...s2.config, ...b };
}
return json({ ok: !!s2, name: mp[1] });
}
const md = p.match(/\/v1\/mcp\/([^/]+)$/);
if (md && m === "DELETE") {
const i = mcpServers.findIndex((x) => x.name === decodeURIComponent(md[1]));
if (i >= 0) mcpServers.splice(i, 1);
return json({ ok: i >= 0 });
}
}
if (p.endsWith("/v1/unrouted")) return json([]);
// channel subscriptions — mutable so add/remove reflect through the UI
if (p.endsWith("/v1/subscriptions") && m === "GET") return json({ subscriptions });
if (p.endsWith("/v1/subscriptions") && m === "POST") {
const b = req.postDataJSON();
// Backend parity with resolve_channel: Copy-link URLs resolve to the id; bare #names
// can't be looked up and are rejected with the same hint the server gives.
const raw = String(b.channel || "").trim();
if (raw.startsWith("#"))
return json({
ok: false,
error:
"Channel names can't be looked up — paste the channel ID (channel name ▸ About) or the channel's Copy-link URL.",
});
const link = raw.match(/slack\.com\/archives\/([A-Za-z0-9]+)/);
const channel = link ? `slack:${link[1].toUpperCase()}` : raw;
subscriptions.push({ session_id: b.session_id, session_title: "", agent: "", channel, routing_target: null, collision: false });
return json({ ok: true, channel });
}
if (p.endsWith("/v1/subscriptions/remove") && m === "POST") {
const b = req.postDataJSON();
const i = subscriptions.findIndex((s) => s.session_id === b.session_id && s.channel === b.channel);
if (i >= 0) subscriptions.splice(i, 1);
return json({ ok: true });
}
// Anything else: an empty-but-valid body. GET list endpoints read `?? []`/`?? {}` fallbacks.
return json({});
});
}
/** Seed a replayed transcript for one session. The shared mock answers every
* GET /v1/sessions/{id}/messages with `[]`, so reopening a session always starts blank;
* this registers a LATER route (later routes win) that stages rich history for that one
* session — replayed tool calls with results, connector-sourced messages, notices,
* reasoning — so specs can assert the reopen path (itemsFromMessages) directly instead
* of driving every turn live through the fake agent. Call after the page has the mock
* (any time before the session is opened). */
export async function seedSessionMessages(
page: Page,
sessionId: string,
messages: Record<string, unknown>[],
): Promise<void> {
await page.route(new RegExp(`/v1/sessions/${sessionId}/messages$`), (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ messages }),
}),
);
}
// A `test` whose page has the API mocked before navigation.
export const test = base.extend({
page: async ({ page }, use) => {
await mockApi(page);
await use(page);
},
});
export { expect };