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", () => {