From a9882c43905f45dd843f6a93f6997aa4b817f436 Mon Sep 17 00:00:00 2001
From: shuntianyifang <2016860013@qq.com>
Date: Tue, 8 Sep 2026 18:29:26 +0800
Subject: [PATCH 1/2] fix(web): align story creation permission and verify
workspace lifecycle
---
.github/workflows/ci.yml | 11 ++
.gitignore | 2 +
apps/docs/docs/contributors/testing.md | 26 +++
.../projects/[projectId]/stories/page.tsx | 2 +-
apps/web/e2e/api-fixture.mjs | 147 +++++++++++++++
apps/web/e2e/fonts.cjs | 5 +
apps/web/e2e/workspace-lifecycle.spec.ts | 122 ++++++++++++
apps/web/e2e/workspace-real-api.spec.ts | 65 +++++++
apps/web/package.json | 5 +-
apps/web/playwright.config.ts | 33 ++++
apps/web/playwright.integration.config.ts | 28 +++
apps/web/test/story-start-permission.test.ts | 47 +++++
apps/web/vitest.config.ts | 3 +-
pnpm-lock.yaml | 43 ++++-
services/api/test/fixtures/browser-api.ts | 177 ++++++++++++++++++
15 files changed, 711 insertions(+), 5 deletions(-)
create mode 100644 apps/web/e2e/api-fixture.mjs
create mode 100644 apps/web/e2e/fonts.cjs
create mode 100644 apps/web/e2e/workspace-lifecycle.spec.ts
create mode 100644 apps/web/e2e/workspace-real-api.spec.ts
create mode 100644 apps/web/playwright.config.ts
create mode 100644 apps/web/playwright.integration.config.ts
create mode 100644 apps/web/test/story-start-permission.test.ts
create mode 100644 services/api/test/fixtures/browser-api.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1bab11fa..49a3a378 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -83,6 +83,17 @@ jobs:
- name: Release-shaped verification
run: pnpm verify
+ - name: Install browser acceptance runtime
+ run: pnpm --filter @facility/web exec playwright install --with-deps chromium
+
+ - name: Verify workspace lifecycle through the web UI
+ env:
+ DATABASE_URL: postgres://facility:facility@127.0.0.1:5461/facility_test
+ run: |
+ pnpm --filter @facility/web test:e2e
+ docker compose -f docker-compose.dev.yml up -d --wait postgres
+ pnpm --filter @facility/web test:e2e:integration
+
# Keep the compatibility floor honest without duplicating the integration
# and deployment suites already exercised by the Node 24 acceptance jobs.
minimum-node:
diff --git a/.gitignore b/.gitignore
index 1df7c18a..ceb7454d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,8 @@ node_modules/
# Next.js build output
.next/
+test-results/
+playwright-report/
.docusaurus/
build/
dist/
diff --git a/apps/docs/docs/contributors/testing.md b/apps/docs/docs/contributors/testing.md
index 17f14cc5..886652cf 100644
--- a/apps/docs/docs/contributors/testing.md
+++ b/apps/docs/docs/contributors/testing.md
@@ -73,6 +73,32 @@ the same policy and records path-based skips only when execution boundaries are
## UI and browser verification
+The workspace lifecycle has two Chromium suites. Install the browser once with
+`pnpm --filter @facility/web exec playwright install chromium`, then run:
+
+```bash
+pnpm --filter @facility/sdk build
+pnpm --filter @facility/web test:e2e
+pnpm exec turbo run build --filter='@facility/api^...'
+DATABASE_URL=postgres://facility:facility@127.0.0.1:5461/facility_test \
+pnpm --filter @facility/web test:e2e:integration
+```
+
+The first suite starts the real Next app and a loopback HTTP fixture for permission-based
+visibility, creation, continuation after suspension, archive/restore, deletion confirmation,
+and failed-operation retries. The second uses the real API, session authorization, PostgreSQL,
+and `FakeWorkspaceRuntime`: it checks on-disk untracked files and Claude/Codex session markers
+before and after UI operations. Catalog and manifest reads and turn execution are deterministic
+fakes; this does not prove hosted engine resume or the 14-day retention soak tracked by #284.
+
+Use a disposable local `facility_test` or `facility_ws` database; the integration fixture refuses
+other database names and non-loopback hosts. It migrates/seeds that database and adds isolated
+fixture users/projects. Run the suites sequentially because both use the web app's `.next` output.
+Ports 3491/4491 and 3492/4492 must be free. The suites start their own servers, do not reuse a live
+development session, and need no provider credentials or internet access after dependencies and
+Chromium are installed. CI runs both in the verification job. Failure traces are in
+`apps/web/test-results`; inspect them with `pnpm --filter @facility/web exec playwright show-trace`.
+
Type checking and rendering tests do not prove a user flow. For a UI change, start `pnpm dev` and
exercise the changed path in a browser. Cover loading, empty, error, denied, and successful states
that the change can produce. For story and preview work, use a disposable repository and the actual
diff --git a/apps/web/app/(app)/projects/[projectId]/stories/page.tsx b/apps/web/app/(app)/projects/[projectId]/stories/page.tsx
index e45d607a..a84eed81 100644
--- a/apps/web/app/(app)/projects/[projectId]/stories/page.tsx
+++ b/apps/web/app/(app)/projects/[projectId]/stories/page.tsx
@@ -38,7 +38,7 @@ export default async function ProjectStoriesPage({
if (!storiesResult.ok && storiesResult.offline) return ;
const stories = storiesResult.ok ? storiesResult.data.stories : [];
const agents = agentsResult.ok ? agentsResult.data.agents : [];
- const canStart = me.ok && can(me.data.permissions, "runs:execute");
+ const canStart = me.ok && can(me.data.permissions, "workspaces:execute");
return (
diff --git a/apps/web/e2e/api-fixture.mjs b/apps/web/e2e/api-fixture.mjs
new file mode 100644
index 00000000..5abd4270
--- /dev/null
+++ b/apps/web/e2e/api-fixture.mjs
@@ -0,0 +1,147 @@
+import { createServer } from "node:http";
+
+// Stateful HTTP boundary for the real Next pages and /api proxy. This fixture
+// tests UI wiring, not authorization or durable storage implementation; those
+// remain covered by the PostgreSQL/FakeWorkspaceRuntime integration suites.
+const project = { id: "proj_ui", name: "Lifecycle fixture", slug: "lifecycle" };
+const base = `/v1/projects/${project.id}/workspace-stories`;
+const timestamp = "2026-09-01T00:00:00.000Z";
+let permissions;
+let bundle;
+let messages;
+let requests;
+let failure;
+const agent = { name: "builder", enabled: true, engine: "codex", model: "fixture-model" };
+
+function reset(options = {}) {
+ permissions = options.permissions ?? [
+ "projects:read",
+ "workspaces:read",
+ "workspaces:execute",
+ "projects:write",
+ ];
+ bundle = {
+ story: {
+ id: "story_ui",
+ projectId: project.id,
+ provider: "manual",
+ externalId: "ui-fixture",
+ title: "Persistent UI story",
+ status: "ready",
+ deletedAt: null,
+ activeAgentName: null,
+ branch: "fixture/story",
+ pullRequestUrl: null,
+ },
+ workspace: {
+ id: "ws_ui",
+ provider: "fake",
+ state: "running",
+ volumeRef: "fixture-volume",
+ lastActivityAt: timestamp,
+ environment: { image: "fixture", ports: [] },
+ },
+ attention: [],
+ timeline: [],
+ turns: [],
+ artifacts: [],
+ needs_attention: false,
+ };
+ messages = [];
+ requests = [];
+ failure = null;
+}
+reset();
+
+createServer(async (req, res) => {
+ const path = new URL(req.url, "http://127.0.0.1").pathname;
+ let raw = "";
+ for await (const part of req) raw += part;
+ const body = raw ? JSON.parse(raw) : undefined;
+ const reply = (data, status = 200) => {
+ res.writeHead(status, { "content-type": "application/json" });
+ res.end(JSON.stringify(data));
+ };
+ if (path === "/health") return reply({ ok: true });
+ if (path === "/__reset" && req.method === "POST") {
+ reset(body);
+ return reply({ ok: true });
+ }
+ if (path === "/__fail" && req.method === "POST") {
+ failure = body;
+ return reply({ ok: true });
+ }
+ if (path === "/__state") return reply({ bundle, messages, requests });
+ if (path === "/v1/me")
+ return reply({
+ principal: { email: "fixture@example.test" },
+ org: { name: "Fixture" },
+ permissions,
+ });
+ if (path === "/v1/projects") return reply([project]);
+ if (path === `/v1/projects/${project.id}`) return reply(project);
+ if (path === `/v1/projects/${project.id}/story-agents`) return reply({ agents: [agent] });
+ if (req.method === "GET") {
+ if (path === base) return reply({ stories: [bundle.story] });
+ if (path === `${base}/story_ui`) return reply(bundle);
+ if (path === `${base}/story_ui/conversation`) return reply({ messages });
+ if (path === `${base}/story_ui/environment`)
+ return reply({
+ workspace: bundle.workspace,
+ events: [],
+ inspection: { state: bundle.workspace.state },
+ services: [],
+ metrics: {
+ create_time_ms: null,
+ wake_time_ms: null,
+ active_compute: bundle.workspace.state === "running",
+ retained_storage: bundle.workspace.state !== "destroyed",
+ cost: { active_compute_cents: null, retained_storage_cents: null },
+ },
+ });
+ }
+ requests.push({
+ method: req.method,
+ path,
+ body,
+ surface: req.headers["x-facility-surface"],
+ key: req.headers["idempotency-key"],
+ });
+ if (failure?.path === path) {
+ const status = failure.status ?? 500;
+ failure = null;
+ return reply({ error: { message: "Fixture operation failed; retry is safe." } }, status);
+ }
+ if (req.method === "POST" && (path === base || path === `${base}/story_ui/messages`)) {
+ if (path === base) bundle.story.title = body.title;
+ messages.push({
+ id: `message_${messages.length}`,
+ role: "user",
+ body: body.message,
+ createdAt: timestamp,
+ actor: { id: "fixture" },
+ });
+ bundle.workspace.state = "running";
+ return reply(bundle, 202);
+ }
+ if (req.method === "POST" && path === `${base}/story_ui/suspend`) {
+ bundle.workspace.state = "sleeping";
+ return reply(bundle);
+ }
+ if (req.method === "POST" && path === `${base}/story_ui/archive`) {
+ bundle.story.status = "archived";
+ bundle.workspace.state = "sleeping";
+ return reply(bundle);
+ }
+ if (req.method === "POST" && path === `${base}/story_ui/restore`) {
+ bundle.story.status = "ready";
+ return reply(bundle);
+ }
+ if (req.method === "DELETE" && path === `${base}/story_ui/workspace` && body?.confirm === true) {
+ bundle.story.status = "archived";
+ bundle.story.deletedAt = timestamp;
+ bundle.workspace.state = "destroyed";
+ return reply(bundle);
+ }
+ return reply({ error: { message: `Unexpected fixture request: ${req.method} ${path}` } }, 404);
+}).listen(4491, "127.0.0.1");
diff --git a/apps/web/e2e/fonts.cjs b/apps/web/e2e/fonts.cjs
new file mode 100644
index 00000000..8c08ce23
--- /dev/null
+++ b/apps/web/e2e/fonts.cjs
@@ -0,0 +1,5 @@
+// Next's font loader receives local CSS; browser acceptance never fetches Google Fonts.
+module.exports = new Proxy(
+ {},
+ { get: () => "@font-face { font-family: 'Fixture'; src: local('Arial'); }" },
+);
diff --git a/apps/web/e2e/workspace-lifecycle.spec.ts b/apps/web/e2e/workspace-lifecycle.spec.ts
new file mode 100644
index 00000000..56273330
--- /dev/null
+++ b/apps/web/e2e/workspace-lifecycle.spec.ts
@@ -0,0 +1,122 @@
+import { expect, test } from "@playwright/test";
+
+const api = "http://127.0.0.1:4491";
+const list = "/projects/proj_ui/stories";
+const story = `${list}/story_ui`;
+const endpoint = "/v1/projects/proj_ui/workspace-stories/story_ui";
+
+test.beforeEach(async ({ request, context }) => {
+ await request.post(`${api}/__reset`);
+ await context.route("**/*", (route) => {
+ const host = new URL(route.request().url()).hostname;
+ return host === "127.0.0.1" ? route.continue() : route.abort();
+ });
+});
+
+test("a workspace executor can create, suspend, continue, archive and restore through the UI", async ({
+ page,
+ request,
+}) => {
+ await page.goto(list);
+ await page.getByLabel("Story title").fill("UI lifecycle journey");
+ await page.getByLabel("First message").fill("Keep the shared conversation");
+ await page.getByRole("button", { name: "start story", exact: true }).click();
+ await expect(page).toHaveURL(story);
+ await expect(page.getByRole("heading", { name: "UI lifecycle journey" })).toBeVisible();
+ await page.getByRole("button", { name: "suspend compute" }).click();
+ await expect(page.locator("dd").filter({ hasText: /^sleeping$/ })).toBeVisible();
+ await page.getByLabel("Message", { exact: true }).fill("Continue in the same workspace");
+ await page.getByRole("button", { name: "send to agent" }).click();
+ await expect(page.locator("dd").filter({ hasText: /^running$/ })).toBeVisible();
+ await expect(page.getByText("Continue in the same workspace", { exact: true })).toBeVisible();
+ await page.getByRole("button", { name: "archive", exact: true }).click();
+ await expect(page.getByRole("button", { name: "restore", exact: true })).toBeVisible();
+ await page.reload();
+ await page.getByRole("button", { name: "restore", exact: true }).click();
+ await expect(page.getByRole("button", { name: "archive", exact: true })).toBeVisible();
+ await expect(page.getByText("Keep the shared conversation", { exact: true })).toBeVisible();
+ await expect(page.getByText("fixture-volume", { exact: true }).first()).toBeVisible();
+ const state = await (await request.get(`${api}/__state`)).json();
+ expect(state.requests.map((r: { path: string }) => r.path)).toEqual([
+ endpoint.replace("/story_ui", ""),
+ `${endpoint}/suspend`,
+ `${endpoint}/messages`,
+ `${endpoint}/archive`,
+ `${endpoint}/restore`,
+ ]);
+ expect(state.requests.every((r: { surface: string }) => r.surface === "ui")).toBe(true);
+});
+
+test("deletion requires confirmation, permits retry, and leaves only history", async ({
+ page,
+ request,
+}) => {
+ await page.goto(story);
+ await page.getByLabel("Message", { exact: true }).fill("History survives deletion");
+ await page.getByRole("button", { name: "send to agent" }).click();
+ await expect(page.getByText("History survives deletion", { exact: true })).toBeVisible();
+ await page.getByText("Permanently delete workspace", { exact: true }).click();
+ const confirm = page.getByRole("checkbox");
+ const remove = page.getByRole("button", { name: "delete workspace", exact: true });
+ await expect(remove).toBeDisabled();
+ await confirm.check();
+ await confirm.uncheck();
+ await expect(remove).toBeDisabled();
+ expect(
+ (await (await request.get(`${api}/__state`)).json()).requests.filter(
+ (r: { method: string }) => r.method === "DELETE",
+ ),
+ ).toHaveLength(0);
+ await request.post(`${api}/__fail`, { data: { path: `${endpoint}/workspace`, status: 500 } });
+ await confirm.check();
+ await remove.click();
+ await expect(page.getByRole("main").getByRole("alert")).toContainText("Fixture operation failed");
+ await expect(remove).toBeEnabled();
+ await remove.click();
+ await expect(
+ page.getByText("This workspace was permanently deleted.", { exact: false }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", {
+ name: /^(restore|archive|suspend compute|send to agent|delete workspace)$/,
+ }),
+ ).toHaveCount(0);
+ await expect(page.getByText("History survives deletion", { exact: true })).toBeVisible();
+ await page.reload();
+ await expect(page.getByRole("button", { name: "restore", exact: true })).toHaveCount(0);
+ const deletes = (await (await request.get(`${api}/__state`)).json()).requests.filter(
+ (r: { method: string }) => r.method === "DELETE",
+ );
+ expect(deletes).toHaveLength(2);
+ for (const deletion of deletes) {
+ expect(deletion.body.confirm).toBe(true);
+ expect(deletion.key).toBe(deletion.body.idempotency_key);
+ }
+});
+
+test("failed lifecycle actions show an error and can be retried", async ({ page, request }) => {
+ await page.goto(story);
+ await request.post(`${api}/__fail`, { data: { path: `${endpoint}/archive`, status: 403 } });
+ await page.getByRole("button", { name: "archive", exact: true }).click();
+ await expect(page.getByRole("main").getByRole("alert")).toContainText("Fixture operation failed");
+ await expect(page.getByRole("button", { name: "restore", exact: true })).toHaveCount(0);
+ await page.getByRole("button", { name: "archive", exact: true }).click();
+ await expect(page.getByRole("button", { name: "restore", exact: true })).toBeVisible();
+ await expect(page.getByRole("main").getByRole("alert")).toHaveCount(0);
+});
+
+test("read-only users have no execution or destructive controls", async ({ page, request }) => {
+ await request.post(`${api}/__reset`, {
+ data: { permissions: ["projects:read", "workspaces:read"] },
+ });
+ await page.goto(list);
+ await expect(page.getByRole("button", { name: "start story", exact: true })).toHaveCount(0);
+ await page.goto(story);
+ await expect(page.getByRole("heading", { name: "Persistent UI story" })).toBeVisible();
+ await expect(
+ page.getByRole("button", {
+ name: /^(restore|archive|suspend compute|send to agent|delete workspace)$/,
+ }),
+ ).toHaveCount(0);
+ expect((await (await request.get(`${api}/__state`)).json()).requests).toHaveLength(0);
+});
diff --git a/apps/web/e2e/workspace-real-api.spec.ts b/apps/web/e2e/workspace-real-api.spec.ts
new file mode 100644
index 00000000..6a8ee578
--- /dev/null
+++ b/apps/web/e2e/workspace-real-api.spec.ts
@@ -0,0 +1,65 @@
+import { expect, test } from "@playwright/test";
+
+test("real API retains workspace files and sessions through UI lifecycle and deletes only on confirmation", async ({
+ page,
+ context,
+ request,
+}) => {
+ const setup = await request.post("http://127.0.0.1:4492/__fixture/setup");
+ expect(setup.ok()).toBe(true);
+ const fixture = await setup.json();
+ await context.addCookies([
+ { ...fixture.cookie, url: "http://127.0.0.1:3492", httpOnly: true, sameSite: "Lax" },
+ ]);
+ await context.route("**/*", (route) =>
+ new URL(route.request().url()).hostname === "127.0.0.1" ? route.continue() : route.abort(),
+ );
+ const files = async () =>
+ (await request.get(`http://127.0.0.1:4492/__fixture/${fixture.fixtureId}/files`)).json();
+ await page.goto(`/projects/${fixture.projectId}/stories`);
+ await page.getByLabel("Story title").fill("Real lifecycle journey");
+ await page.getByLabel("First message").fill("Create durable state");
+ await page.getByRole("button", { name: "start story", exact: true }).click();
+ await expect(page.getByRole("heading", { name: "Real lifecycle journey" })).toBeVisible();
+ const before = await files();
+ expect(before.files).toEqual([
+ "retained:untracked.txt",
+ "retained:.facility/claude/session",
+ "retained:.facility/codex/session",
+ ]);
+ await page.getByRole("button", { name: "suspend compute" }).click();
+ await expect(page.locator("dd").filter({ hasText: /^sleeping$/ })).toBeVisible();
+ expect((await files()).files).toEqual(before.files);
+ await page.getByLabel("Message", { exact: true }).fill("Resume with the same files");
+ await page.getByRole("button", { name: "send to agent" }).click();
+ await expect(page.locator("dd").filter({ hasText: /^running$/ })).toBeVisible();
+ await page.getByRole("button", { name: "archive", exact: true }).click();
+ await expect(page.getByRole("button", { name: "restore", exact: true })).toBeVisible();
+ expect((await files()).files).toEqual(before.files);
+ await page.reload();
+ await page.getByRole("button", { name: "restore", exact: true }).click();
+ await expect(page.getByRole("button", { name: "archive", exact: true })).toBeVisible();
+ expect(await files()).toMatchObject({
+ workspaceId: before.workspaceId,
+ volumeRef: before.volumeRef,
+ files: before.files,
+ });
+ await page.getByText("Permanently delete workspace", { exact: true }).click();
+ const remove = page.getByRole("button", { name: "delete workspace", exact: true });
+ await expect(remove).toBeDisabled();
+ await page.getByRole("checkbox").check();
+ await page.getByRole("checkbox").uncheck();
+ await expect(remove).toBeDisabled();
+ expect((await files()).files).toEqual(before.files);
+ await page.getByRole("checkbox").check();
+ await remove.click();
+ await expect(
+ page.getByText("This workspace was permanently deleted.", { exact: false }),
+ ).toBeVisible();
+ expect(await files()).toMatchObject({ state: "destroyed", files: [null, null, null] });
+ await expect(page.getByText("Create durable state", { exact: true })).toBeVisible();
+ await page.reload();
+ await expect(
+ page.getByRole("button", { name: /^(restore|suspend compute|archive|send to agent)$/ }),
+ ).toHaveCount(0);
+});
diff --git a/apps/web/package.json b/apps/web/package.json
index 1342806b..098194a0 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -10,7 +10,9 @@
"build": "next build",
"start": "next start --port 3400",
"typecheck": "tsc --noEmit",
- "test": "vitest run"
+ "test": "vitest run",
+ "test:e2e": "playwright test",
+ "test:e2e:integration": "playwright test --config playwright.integration.config.ts"
},
"dependencies": {
"@facility/sdk": "workspace:*",
@@ -24,6 +26,7 @@
"react-dom": "^19.2.4"
},
"devDependencies": {
+ "@playwright/test": "1.58.2",
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.4",
diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts
new file mode 100644
index 00000000..70a9bfcc
--- /dev/null
+++ b/apps/web/playwright.config.ts
@@ -0,0 +1,33 @@
+import { resolve } from "node:path";
+import { defineConfig, devices } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./e2e",
+ testIgnore: "**/workspace-real-api.spec.ts",
+ fullyParallel: false,
+ workers: 1,
+ retries: 0,
+ timeout: 60_000,
+ expect: { timeout: 15_000 },
+ use: { baseURL: "http://127.0.0.1:3491", trace: "retain-on-failure" },
+ projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
+ webServer: [
+ {
+ command: "node e2e/api-fixture.mjs",
+ url: "http://127.0.0.1:4491/health",
+ reuseExistingServer: false,
+ },
+ {
+ command:
+ "node node_modules/next/dist/bin/next dev --webpack --hostname 127.0.0.1 --port 3491",
+ url: "http://127.0.0.1:3491/login",
+ reuseExistingServer: false,
+ timeout: 120_000,
+ env: {
+ FACILITY_API_URL: "http://127.0.0.1:4491",
+ NEXT_TELEMETRY_DISABLED: "1",
+ NEXT_FONT_GOOGLE_MOCKED_RESPONSES: resolve(import.meta.dirname, "e2e/fonts.cjs"),
+ },
+ },
+ ],
+});
diff --git a/apps/web/playwright.integration.config.ts b/apps/web/playwright.integration.config.ts
new file mode 100644
index 00000000..8b076cbd
--- /dev/null
+++ b/apps/web/playwright.integration.config.ts
@@ -0,0 +1,28 @@
+import { defineConfig } from "@playwright/test";
+import base from "./playwright.config";
+
+export default defineConfig({
+ ...base,
+ testMatch: "**/workspace-real-api.spec.ts",
+ testIgnore: [],
+ use: { ...base.use, baseURL: "http://127.0.0.1:3492" },
+ webServer: [
+ {
+ command:
+ "node ../../services/api/node_modules/tsx/dist/cli.mjs ../../services/api/test/fixtures/browser-api.ts",
+ url: "http://127.0.0.1:4492/health",
+ reuseExistingServer: false,
+ timeout: 120_000,
+ },
+ {
+ ...(base.webServer as Array)[1],
+ command:
+ "node node_modules/next/dist/bin/next dev --webpack --hostname 127.0.0.1 --port 3492",
+ url: "http://127.0.0.1:3492/login",
+ env: {
+ ...(base.webServer as Array<{ env?: Record }>)[1]?.env,
+ FACILITY_API_URL: "http://127.0.0.1:4492",
+ },
+ },
+ ],
+});
diff --git a/apps/web/test/story-start-permission.test.ts b/apps/web/test/story-start-permission.test.ts
new file mode 100644
index 00000000..c75ab7a8
--- /dev/null
+++ b/apps/web/test/story-start-permission.test.ts
@@ -0,0 +1,47 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import ProjectStoriesPage from "../app/(app)/projects/[projectId]/stories/page";
+
+const fixture = vi.hoisted(() => ({ permissions: [] as string[] }));
+vi.mock("@/lib/api", () => ({
+ api: {
+ workspaceStories: async () => ({ ok: true, data: { stories: [] } }),
+ storyAgents: async () => ({ ok: true, data: { agents: [{ name: "builder", enabled: true }] } }),
+ me: async () => ({ ok: true, data: { permissions: fixture.permissions } }),
+ },
+}));
+vi.mock("@/components/story/start-workspace-story", () => ({
+ StartWorkspaceStory: () => "START_STORY",
+}));
+vi.mock("@/components/shell/live-refresh", () => ({ LiveRefresh: () => null }));
+
+describe("story creation permission", () => {
+ beforeEach(() => {
+ fixture.permissions = [];
+ });
+ it.each([
+ "workspaces:execute",
+ "workspaces:*",
+ "*",
+ ])("shows creation for %s", async (permission) => {
+ fixture.permissions = [permission];
+ expect(await render()).toContain("START_STORY");
+ });
+ it.each([
+ "projects:read",
+ "projects:write",
+ "runs:execute",
+ ])("does not use %s to offer execution", async (permission) => {
+ fixture.permissions = [permission];
+ expect(await render()).not.toContain("START_STORY");
+ });
+});
+
+async function render() {
+ return renderToStaticMarkup(
+ await ProjectStoriesPage({
+ params: Promise.resolve({ projectId: "project" }),
+ searchParams: Promise.resolve({}),
+ }),
+ );
+}
diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts
index 96814fe7..74d54d0e 100644
--- a/apps/web/vitest.config.ts
+++ b/apps/web/vitest.config.ts
@@ -1,10 +1,11 @@
import { resolve } from "node:path";
-import { defineConfig } from "vitest/config";
+import { configDefaults, defineConfig } from "vitest/config";
// The app's tsconfig sets jsx: "preserve" for Next's own compiler, which the
// test transform can't consume — components under test are .tsx, so the runner
// needs its own JSX runtime. The alias mirrors Next's "@/…" tsconfig path.
export default defineConfig({
+ test: { exclude: [...configDefaults.exclude, "e2e/**"] },
oxc: { jsx: { runtime: "automatic" } },
resolve: { alias: { "@": resolve(import.meta.dirname, ".") } },
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 738c831b..b10dc2ca 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -109,7 +109,7 @@ importers:
version: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
next:
specifier: ^16.2.11
- version: 16.2.11(@babel/core@7.29.7(supports-color@10.2.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ version: 16.2.11(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.58.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react:
specifier: ^19.2.4
version: 19.2.7
@@ -117,6 +117,9 @@ importers:
specifier: ^19.2.4
version: 19.2.7(react@19.2.7)
devDependencies:
+ '@playwright/test':
+ specifier: 1.58.2
+ version: 1.58.2
'@tailwindcss/postcss':
specifier: ^4.2.1
version: 4.3.2
@@ -2722,6 +2725,11 @@ packages:
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
+ '@playwright/test@1.58.2':
+ resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@pnpm/config.env-replace@1.1.0':
resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==}
engines: {node: '>=12.22.0'}
@@ -4966,6 +4974,11 @@ packages:
resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==}
engines: {node: '>=14.14'}
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -6445,6 +6458,16 @@ packages:
resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==}
engines: {node: '>=16.0.0'}
+ playwright-core@1.58.2:
+ resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.58.2:
+ resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
+ engines: {node: '>=18'}
+ hasBin: true
+
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
@@ -12029,6 +12052,10 @@ snapshots:
'@pinojs/redact@0.4.0': {}
+ '@playwright/test@1.58.2':
+ dependencies:
+ playwright: 1.58.2
+
'@pnpm/config.env-replace@1.1.0': {}
'@pnpm/network.ca-file@1.0.2':
@@ -14291,6 +14318,9 @@ snapshots:
jsonfile: 6.2.1
universalify: 2.0.1
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -15722,7 +15752,7 @@ snapshots:
neo-async@2.6.2: {}
- next@16.2.11(@babel/core@7.29.7(supports-color@10.2.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ next@16.2.11(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.58.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@next/env': 16.2.11
'@swc/helpers': 0.5.15
@@ -15741,6 +15771,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.2.11
'@next/swc-win32-arm64-msvc': 16.2.11
'@next/swc-win32-x64-msvc': 16.2.11
+ '@playwright/test': 1.58.2
sharp: 0.35.0
transitivePeerDependencies:
- '@babel/core'
@@ -16072,6 +16103,14 @@ snapshots:
pvutils: 1.1.5
tslib: 2.8.1
+ playwright-core@1.58.2: {}
+
+ playwright@1.58.2:
+ dependencies:
+ playwright-core: 1.58.2
+ optionalDependencies:
+ fsevents: 2.3.2
+
pluralize@8.0.0: {}
postcss-attribute-case-insensitive@7.0.1(postcss@8.5.23):
diff --git a/services/api/test/fixtures/browser-api.ts b/services/api/test/fixtures/browser-api.ts
new file mode 100644
index 00000000..82974e04
--- /dev/null
+++ b/services/api/test/fixtures/browser-api.ts
@@ -0,0 +1,177 @@
+import { execFile } from "node:child_process";
+import { randomUUID } from "node:crypto";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { promisify } from "node:util";
+import { newId } from "@facility/core";
+import { createDb, migrate, orgMembers, roles, seed, workspaces } from "@facility/db";
+import { and, eq } from "drizzle-orm";
+import { AgentCatalogService } from "../../src/agents/catalog.js";
+import { buildApp } from "../../src/app.js";
+import { createStoryDomain } from "../../src/story-domain.js";
+import type { AppConfig } from "../../src/types.js";
+import { FakeWorkspaceRuntime } from "../../src/workspaces/fake.js";
+import { parseProjectManifest } from "../../src/workspaces/project-environment.js";
+
+// A test-only executable: real HTTP authorization, routes, PostgreSQL and
+// lifecycle services; only external catalog/manifest reads and execution are fake.
+const databaseUrl = process.env.DATABASE_URL;
+if (!databaseUrl) throw new Error("DATABASE_URL must point to a disposable local test database");
+const database = new URL(databaseUrl);
+if (
+ !["localhost", "127.0.0.1"].includes(database.hostname) ||
+ !["/facility_test", "/facility_ws"].includes(database.pathname)
+) {
+ throw new Error("Refusing a non-local or non-test database");
+}
+process.env.NODE_ENV = "test";
+await migrate(databaseUrl);
+await seed(databaseUrl, { includeDemoData: true });
+const { db, client } = createDb(databaseUrl);
+const root = await mkdtemp(join(tmpdir(), "facility-browser-"));
+const runtime = new FakeWorkspaceRuntime(root);
+const initialized = new Set();
+const execute = promisify(execFile);
+const config: AppConfig = {
+ databaseUrl,
+ secretMasterKey: Buffer.alloc(32, 19).toString("base64"),
+ port: 4492,
+ publicUrl: "http://127.0.0.1:4492",
+ webUrl: "http://127.0.0.1:3492",
+ workspaceImage: "fixture",
+ workspaceDriver: "docker",
+ facilityInsecureDev: false,
+ logLevel: "silent",
+};
+const domain = createStoryDomain({
+ db,
+ config,
+ runtime,
+ enqueue: async (_queue, data) => {
+ const orgId = String(data.orgId);
+ const projectId = String(data.projectId);
+ const turnId = String(data.turnId);
+ const [workspace] = await db
+ .select()
+ .from(workspaces)
+ .where(and(eq(workspaces.orgId, orgId), eq(workspaces.projectId, projectId)));
+ if (!workspace?.externalRef) throw new Error("Missing fixture workspace");
+ await runtime.wake({
+ id: workspace.id,
+ volumeRef: workspace.volumeRef,
+ externalRef: workspace.externalRef,
+ image: "fixture",
+ });
+ // Seed only on the first turn. A later turn must fail if state disappeared,
+ // rather than silently recreating the same markers and hiding data loss.
+ const firstTurn = !initialized.has(workspace.id);
+ if (firstTurn) await execute("git", ["init", "--quiet", workspace.volumeRef]);
+ for (const path of ["untracked.txt", ".facility/claude/session", ".facility/codex/session"]) {
+ const target = join(workspace.volumeRef, path);
+ if (firstTurn) await writeFile(target, `retained:${path}`, { flag: "wx" });
+ else if ((await readFile(target, "utf8")) !== `retained:${path}`)
+ throw new Error(`Lost fixture state: ${path}`);
+ }
+ initialized.add(workspace.id);
+ await db.update(workspaces).set({ state: "running" }).where(eq(workspaces.id, workspace.id));
+ await domain.stories.completeTurn({
+ orgId,
+ projectId,
+ turnId,
+ output: "Fixture turn completed",
+ actor: { type: "system", id: "fixture-engine" },
+ });
+ },
+});
+domain.catalog = new AgentCatalogService(db, {
+ load: async () => ({
+ commitSha: "a".repeat(40),
+ sources: [
+ {
+ file: ".agents/builder.md",
+ source:
+ "---\nname: builder\ndescription: Browser test agent\nengine: codex\nmodel: fixture\nenabled: true\ntriggers:\n - type: ui\n---\nComplete the fixture turn.\n",
+ },
+ ],
+ }),
+});
+domain.projectManifests.load = async () =>
+ parseProjectManifest(
+ "version: 1\nrepositories:\n primary: github.com/fixture/app\n related: []\nenvironment:\n start: 'true'\n services: {}\n",
+ );
+const app = await buildApp(config, { storyDomain: domain, rateLimitMax: 10_000 });
+const sessions = new Map();
+app.post("/__fixture/setup", { config: { public: true } }, async () => {
+ const id = randomUUID();
+ const login = await app.inject({
+ method: "POST",
+ url: "/__test/session",
+ payload: { email: `${id}@example.test` },
+ });
+ if (login.statusCode !== 200) throw new Error(login.body);
+ const { orgId, userId } = login.json();
+ const cookie = login.cookies.find((item) => item.name === "facility_session");
+ if (!cookie) throw new Error("No session cookie");
+ const created = await app.inject({
+ method: "POST",
+ url: "/v1/projects",
+ headers: { cookie: `${cookie.name}=${cookie.value}` },
+ payload: { name: "Browser lifecycle", slug: `browser-${id}` },
+ });
+ if (created.statusCode !== 200) throw new Error(created.body);
+ const roleId = newId("role");
+ await db.insert(roles).values({
+ id: roleId,
+ orgId,
+ name: `executor-${id}`,
+ permissions: ["org:read", "projects:read", "projects:write", "workspaces:execute"],
+ });
+ await db
+ .update(orgMembers)
+ .set({ roleId })
+ .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)));
+ sessions.set(id, { orgId, projectId: created.json().id });
+ return {
+ fixtureId: id,
+ projectId: created.json().id,
+ cookie: { name: cookie.name, value: cookie.value },
+ };
+});
+app.get<{ Params: { id: string } }>(
+ "/__fixture/:id/files",
+ { config: { public: true } },
+ async (request) => {
+ const scope = sessions.get(request.params.id);
+ if (!scope) throw new Error("Unknown fixture");
+ const [workspace] = await db
+ .select()
+ .from(workspaces)
+ .where(and(eq(workspaces.orgId, scope.orgId), eq(workspaces.projectId, scope.projectId)));
+ if (!workspace) return { files: null };
+ const files = await Promise.all(
+ ["untracked.txt", ".facility/claude/session", ".facility/codex/session"].map(async (path) => {
+ try {
+ return await readFile(join(workspace.volumeRef, path), "utf8");
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
+ throw error;
+ }
+ }),
+ );
+ return {
+ workspaceId: workspace.id,
+ volumeRef: workspace.volumeRef,
+ state: workspace.state,
+ files,
+ };
+ },
+);
+await app.listen({ host: "127.0.0.1", port: 4492 });
+async function close() {
+ await app.close();
+ await client.end();
+ await rm(root, { recursive: true, force: true });
+}
+process.once("SIGTERM", () => void close());
+process.once("SIGINT", () => void close());
From c4a2eec4c1dec90c87ef710ce59794366e35212c Mon Sep 17 00:00:00 2001
From: shuntianyifang <2016860013@qq.com>
Date: Sat, 12 Sep 2026 15:29:43 +0800
Subject: [PATCH 2/2] test(web): adapt lifecycle coverage to the story backlog
---
apps/web/e2e/api-fixture.mjs | 68 ++++++++++++++++++--
apps/web/e2e/workspace-lifecycle.spec.ts | 40 ++++++++----
apps/web/e2e/workspace-real-api.spec.ts | 33 +++++++---
apps/web/test/story-start-permission.test.ts | 29 +++++++--
4 files changed, 140 insertions(+), 30 deletions(-)
diff --git a/apps/web/e2e/api-fixture.mjs b/apps/web/e2e/api-fixture.mjs
index 5abd4270..07c8bba1 100644
--- a/apps/web/e2e/api-fixture.mjs
+++ b/apps/web/e2e/api-fixture.mjs
@@ -11,7 +11,13 @@ let bundle;
let messages;
let requests;
let failure;
-const agent = { name: "builder", enabled: true, engine: "codex", model: "fixture-model" };
+const agent = {
+ name: "builder",
+ enabled: true,
+ engine: "codex",
+ model: "fixture-model",
+ triggers: [{ type: "ui" }],
+};
function reset(options = {}) {
permissions = options.permissions ?? [
@@ -27,6 +33,9 @@ function reset(options = {}) {
provider: "manual",
externalId: "ui-fixture",
title: "Persistent UI story",
+ titleSource: "request",
+ createdAt: timestamp,
+ updatedAt: timestamp,
status: "ready",
deletedAt: null,
activeAgentName: null,
@@ -42,6 +51,7 @@ function reset(options = {}) {
environment: { image: "fixture", ports: [] },
},
attention: [],
+ assignees: [],
timeline: [],
turns: [],
artifacts: [],
@@ -74,17 +84,59 @@ createServer(async (req, res) => {
if (path === "/__state") return reply({ bundle, messages, requests });
if (path === "/v1/me")
return reply({
- principal: { email: "fixture@example.test" },
+ principal: { id: "fixture", userId: "fixture", email: "fixture@example.test" },
org: { name: "Fixture" },
permissions,
});
if (path === "/v1/projects") return reply([project]);
if (path === `/v1/projects/${project.id}`) return reply(project);
- if (path === `/v1/projects/${project.id}/story-agents`) return reply({ agents: [agent] });
+ if (path === `/v1/projects/${project.id}/story-agents`)
+ return reply({
+ agents: [agent],
+ defaults: { ui: "builder", manual: "builder", mcp: "builder" },
+ title_generation: false,
+ });
+ if (path === `/v1/projects/${project.id}/backlog`)
+ return reply({
+ items: [
+ {
+ key: "story:story_ui",
+ kind: "story",
+ title: bundle.story.title,
+ titleSource: "request",
+ phase: bundle.story.status === "archived" ? "archived" : "not_started",
+ reason: "ready",
+ activity: { state: "idle", agentName: null, engine: null, turnId: null, since: null },
+ environment: { recordedState: bundle.workspace.state, lastActivityAt: timestamp },
+ attention: [],
+ story: bundle.story,
+ issue: null,
+ pullRequest: null,
+ labels: [],
+ assignees: [],
+ createdAt: timestamp,
+ lastActivityAt: timestamp,
+ },
+ ],
+ total: 1,
+ limit: 50,
+ offset: 0,
+ generatedAt: timestamp,
+ counts: {
+ not_started: bundle.story.status === "archived" ? 0 : 1,
+ in_progress: 0,
+ attention: 0,
+ review: 0,
+ done: 0,
+ archived: bundle.story.status === "archived" ? 1 : 0,
+ },
+ facets: { labels: [], assignees: [], repositories: [], unassigned: 0 },
+ });
if (req.method === "GET") {
if (path === base) return reply({ stories: [bundle.story] });
if (path === `${base}/story_ui`) return reply(bundle);
- if (path === `${base}/story_ui/conversation`) return reply({ messages });
+ if (path === `${base}/story_ui/conversation`)
+ return reply({ messages, related: [], has_more: false, next_cursor: null });
if (path === `${base}/story_ui/environment`)
return reply({
workspace: bundle.workspace,
@@ -113,9 +165,15 @@ createServer(async (req, res) => {
return reply({ error: { message: "Fixture operation failed; retry is safe." } }, status);
}
if (req.method === "POST" && (path === base || path === `${base}/story_ui/messages`)) {
- if (path === base) bundle.story.title = body.title;
+ if (path === base) bundle.story.title = body.title ?? body.message.split("\n")[0];
messages.push({
id: `message_${messages.length}`,
+ seq: messages.length + 1,
+ author: { kind: "user", name: "Fixture user", handle: null, avatarUrl: null },
+ turn: null,
+ turnId: null,
+ requestedAgentName: "builder",
+ content: { kind: "text", progressMessages: null, reportedModel: null },
role: "user",
body: body.message,
createdAt: timestamp,
diff --git a/apps/web/e2e/workspace-lifecycle.spec.ts b/apps/web/e2e/workspace-lifecycle.spec.ts
index 56273330..2d59f1c6 100644
--- a/apps/web/e2e/workspace-lifecycle.spec.ts
+++ b/apps/web/e2e/workspace-lifecycle.spec.ts
@@ -1,4 +1,4 @@
-import { expect, test } from "@playwright/test";
+import { expect, type Page, test } from "@playwright/test";
const api = "http://127.0.0.1:4491";
const list = "/projects/proj_ui/stories";
@@ -18,23 +18,30 @@ test("a workspace executor can create, suspend, continue, archive and restore th
request,
}) => {
await page.goto(list);
- await page.getByLabel("Story title").fill("UI lifecycle journey");
- await page.getByLabel("First message").fill("Keep the shared conversation");
- await page.getByRole("button", { name: "start story", exact: true }).click();
+ await expect(page.getByRole("link", { name: "Persistent UI story", exact: true })).toBeVisible();
+ await page.getByLabel("What do you need?").fill("Keep the shared conversation");
+ await page.getByRole("button", { name: "Start story", exact: true }).click();
await expect(page).toHaveURL(story);
- await expect(page.getByRole("heading", { name: "UI lifecycle journey" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Keep the shared conversation" })).toBeVisible();
+ await openMaintenance(page);
await page.getByRole("button", { name: "suspend compute" }).click();
- await expect(page.locator("dd").filter({ hasText: /^sleeping$/ })).toBeVisible();
+ await expect(page.locator("dd").filter({ hasText: /^Suspended$/ })).toBeVisible();
+ await page.getByRole("button", { name: "send a task", exact: true }).click();
await page.getByLabel("Message", { exact: true }).fill("Continue in the same workspace");
await page.getByRole("button", { name: "send to agent" }).click();
- await expect(page.locator("dd").filter({ hasText: /^running$/ })).toBeVisible();
+ await expect(page.locator("dd").filter({ hasText: /^Machine on$/ })).toBeVisible();
await expect(page.getByText("Continue in the same workspace", { exact: true })).toBeVisible();
+ await openMaintenance(page);
await page.getByRole("button", { name: "archive", exact: true }).click();
await expect(page.getByRole("button", { name: "restore", exact: true })).toBeVisible();
await page.reload();
+ await openMaintenance(page);
await page.getByRole("button", { name: "restore", exact: true }).click();
await expect(page.getByRole("button", { name: "archive", exact: true })).toBeVisible();
- await expect(page.getByText("Keep the shared conversation", { exact: true })).toBeVisible();
+ await expect(
+ page.locator("#conversation").getByText("Keep the shared conversation", { exact: true }),
+ ).toBeVisible();
+ await page.getByText("Environment details", { exact: true }).click();
await expect(page.getByText("fixture-volume", { exact: true }).first()).toBeVisible();
const state = await (await request.get(`${api}/__state`)).json();
expect(state.requests.map((r: { path: string }) => r.path)).toEqual([
@@ -52,9 +59,11 @@ test("deletion requires confirmation, permits retry, and leaves only history", a
request,
}) => {
await page.goto(story);
+ await page.getByRole("button", { name: "send a task", exact: true }).click();
await page.getByLabel("Message", { exact: true }).fill("History survives deletion");
await page.getByRole("button", { name: "send to agent" }).click();
await expect(page.getByText("History survives deletion", { exact: true })).toBeVisible();
+ await openMaintenance(page);
await page.getByText("Permanently delete workspace", { exact: true }).click();
const confirm = page.getByRole("checkbox");
const remove = page.getByRole("button", { name: "delete workspace", exact: true });
@@ -78,7 +87,7 @@ test("deletion requires confirmation, permits retry, and leaves only history", a
).toBeVisible();
await expect(
page.getByRole("button", {
- name: /^(restore|archive|suspend compute|send to agent|delete workspace)$/,
+ name: /^(restore|archive|suspend compute|send to agent|send a task|delete workspace|run browser test)$/,
}),
).toHaveCount(0);
await expect(page.getByText("History survives deletion", { exact: true })).toBeVisible();
@@ -97,9 +106,11 @@ test("deletion requires confirmation, permits retry, and leaves only history", a
test("failed lifecycle actions show an error and can be retried", async ({ page, request }) => {
await page.goto(story);
await request.post(`${api}/__fail`, { data: { path: `${endpoint}/archive`, status: 403 } });
+ await openMaintenance(page);
await page.getByRole("button", { name: "archive", exact: true }).click();
await expect(page.getByRole("main").getByRole("alert")).toContainText("Fixture operation failed");
await expect(page.getByRole("button", { name: "restore", exact: true })).toHaveCount(0);
+ await openMaintenance(page);
await page.getByRole("button", { name: "archive", exact: true }).click();
await expect(page.getByRole("button", { name: "restore", exact: true })).toBeVisible();
await expect(page.getByRole("main").getByRole("alert")).toHaveCount(0);
@@ -110,13 +121,20 @@ test("read-only users have no execution or destructive controls", async ({ page,
data: { permissions: ["projects:read", "workspaces:read"] },
});
await page.goto(list);
- await expect(page.getByRole("button", { name: "start story", exact: true })).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Start story", exact: true })).toHaveCount(0);
await page.goto(story);
await expect(page.getByRole("heading", { name: "Persistent UI story" })).toBeVisible();
await expect(
page.getByRole("button", {
- name: /^(restore|archive|suspend compute|send to agent|delete workspace)$/,
+ name: /^(restore|archive|suspend compute|send to agent|send a task|delete workspace|run browser test)$/,
}),
).toHaveCount(0);
expect((await (await request.get(`${api}/__state`)).json()).requests).toHaveLength(0);
});
+
+async function openMaintenance(page: Page) {
+ const summary = page.locator("summary").filter({ hasText: /^maintenance/ });
+ if (!(await summary.evaluate((element) => element.parentElement?.hasAttribute("open")))) {
+ await summary.click();
+ }
+}
diff --git a/apps/web/e2e/workspace-real-api.spec.ts b/apps/web/e2e/workspace-real-api.spec.ts
index 6a8ee578..ab81ffcc 100644
--- a/apps/web/e2e/workspace-real-api.spec.ts
+++ b/apps/web/e2e/workspace-real-api.spec.ts
@@ -1,4 +1,4 @@
-import { expect, test } from "@playwright/test";
+import { expect, type Page, test } from "@playwright/test";
test("real API retains workspace files and sessions through UI lifecycle and deletes only on confirmation", async ({
page,
@@ -17,26 +17,29 @@ test("real API retains workspace files and sessions through UI lifecycle and del
const files = async () =>
(await request.get(`http://127.0.0.1:4492/__fixture/${fixture.fixtureId}/files`)).json();
await page.goto(`/projects/${fixture.projectId}/stories`);
- await page.getByLabel("Story title").fill("Real lifecycle journey");
- await page.getByLabel("First message").fill("Create durable state");
- await page.getByRole("button", { name: "start story", exact: true }).click();
- await expect(page.getByRole("heading", { name: "Real lifecycle journey" })).toBeVisible();
+ await page.getByLabel("What do you need?").fill("Create durable state");
+ await page.getByRole("button", { name: "Start story", exact: true }).click();
+ await expect(page.getByRole("heading", { name: "Create durable state" })).toBeVisible();
const before = await files();
expect(before.files).toEqual([
"retained:untracked.txt",
"retained:.facility/claude/session",
"retained:.facility/codex/session",
]);
+ await openMaintenance(page);
await page.getByRole("button", { name: "suspend compute" }).click();
- await expect(page.locator("dd").filter({ hasText: /^sleeping$/ })).toBeVisible();
+ await expect(page.locator("dd").filter({ hasText: /^Suspended$/ })).toBeVisible();
expect((await files()).files).toEqual(before.files);
+ await page.getByRole("button", { name: "send a task", exact: true }).click();
await page.getByLabel("Message", { exact: true }).fill("Resume with the same files");
await page.getByRole("button", { name: "send to agent" }).click();
- await expect(page.locator("dd").filter({ hasText: /^running$/ })).toBeVisible();
+ await expect(page.locator("dd").filter({ hasText: /^Machine on$/ })).toBeVisible();
+ await openMaintenance(page);
await page.getByRole("button", { name: "archive", exact: true }).click();
await expect(page.getByRole("button", { name: "restore", exact: true })).toBeVisible();
expect((await files()).files).toEqual(before.files);
await page.reload();
+ await openMaintenance(page);
await page.getByRole("button", { name: "restore", exact: true }).click();
await expect(page.getByRole("button", { name: "archive", exact: true })).toBeVisible();
expect(await files()).toMatchObject({
@@ -44,6 +47,7 @@ test("real API retains workspace files and sessions through UI lifecycle and del
volumeRef: before.volumeRef,
files: before.files,
});
+ await openMaintenance(page);
await page.getByText("Permanently delete workspace", { exact: true }).click();
const remove = page.getByRole("button", { name: "delete workspace", exact: true });
await expect(remove).toBeDisabled();
@@ -57,9 +61,20 @@ test("real API retains workspace files and sessions through UI lifecycle and del
page.getByText("This workspace was permanently deleted.", { exact: false }),
).toBeVisible();
expect(await files()).toMatchObject({ state: "destroyed", files: [null, null, null] });
- await expect(page.getByText("Create durable state", { exact: true })).toBeVisible();
+ await expect(
+ page.locator("#conversation").getByText("Create durable state", { exact: true }),
+ ).toBeVisible();
await page.reload();
await expect(
- page.getByRole("button", { name: /^(restore|suspend compute|archive|send to agent)$/ }),
+ page.getByRole("button", {
+ name: /^(restore|suspend compute|archive|send to agent|send a task)$/,
+ }),
).toHaveCount(0);
});
+
+async function openMaintenance(page: Page) {
+ const summary = page.locator("summary").filter({ hasText: /^maintenance/ });
+ if (!(await summary.evaluate((element) => element.parentElement?.hasAttribute("open")))) {
+ await summary.click();
+ }
+}
diff --git a/apps/web/test/story-start-permission.test.ts b/apps/web/test/story-start-permission.test.ts
index c75ab7a8..2e283132 100644
--- a/apps/web/test/story-start-permission.test.ts
+++ b/apps/web/test/story-start-permission.test.ts
@@ -5,14 +5,33 @@ import ProjectStoriesPage from "../app/(app)/projects/[projectId]/stories/page";
const fixture = vi.hoisted(() => ({ permissions: [] as string[] }));
vi.mock("@/lib/api", () => ({
api: {
- workspaceStories: async () => ({ ok: true, data: { stories: [] } }),
- storyAgents: async () => ({ ok: true, data: { agents: [{ name: "builder", enabled: true }] } }),
- me: async () => ({ ok: true, data: { permissions: fixture.permissions } }),
+ projectBacklog: async () => ({
+ ok: true,
+ data: {
+ items: [],
+ total: 0,
+ counts: { not_started: 0, in_progress: 0, attention: 0, review: 0, done: 0, archived: 0 },
+ facets: { labels: [], assignees: [], repositories: [], unassigned: 0 },
+ },
+ }),
+ storyAgents: async () => ({
+ ok: true,
+ data: {
+ agents: [{ name: "builder", enabled: true, engine: "codex", triggers: [{ type: "ui" }] }],
+ defaults: { ui: "builder" },
+ title_generation: false,
+ },
+ }),
+ me: async () => ({
+ ok: true,
+ data: { permissions: fixture.permissions, principal: { id: "viewer" } },
+ }),
},
}));
-vi.mock("@/components/story/start-workspace-story", () => ({
- StartWorkspaceStory: () => "START_STORY",
+vi.mock("@/components/story/new-story", () => ({
+ NewStory: () => "START_STORY",
}));
+vi.mock("next/navigation", () => ({ useRouter: () => ({ push() {}, refresh() {} }) }));
vi.mock("@/components/shell/live-refresh", () => ({ LiveRefresh: () => null }));
describe("story creation permission", () => {