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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ node_modules/

# Next.js build output
.next/
test-results/
playwright-report/
.docusaurus/
build/
dist/
Expand Down
26 changes: 26 additions & 0 deletions apps/docs/docs/contributors/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
205 changes: 205 additions & 0 deletions apps/web/e2e/api-fixture.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
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",
triggers: [{ type: "ui" }],
};

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",
titleSource: "request",
createdAt: timestamp,
updatedAt: timestamp,
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: [],
assignees: [],
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: { 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],
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, related: [], has_more: false, next_cursor: null });
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 ?? 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,
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");
5 changes: 5 additions & 0 deletions apps/web/e2e/fonts.cjs
Original file line number Diff line number Diff line change
@@ -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'); }" },
);
Loading