fix: rebuild workspace home around daily navigation

This commit is contained in:
2026-10-01 13:52:16 +09:00
parent b9aaff4052
commit 696e6a51e7
10 changed files with 663 additions and 157 deletions
+14
View File
@@ -181,6 +181,20 @@ is ignored by Git, while authentication state remains outside the repository. `c
`target/web-ux` when `--output` is omitted. Keep a bundle outside Git or publish it through the
approved immutable artifact channel when durable review evidence is required.
## Workspace Home fixture
`scenarios/workspace-home.json` captures the production Home with owner, non-owner, empty-resource,
and long-name Workspace projections at 1440px, 768px, and 390px. It uses no real account or private
Workspace data. Build `web/workspace` first, then run from `tools/web-ux`:
```sh
deno task web-ux capture --scenario scenarios/workspace-home.json --output ../../target/web-ux --run-id home-review
```
The scenario owns a fixture server on localhost port 15281 and stops only that server. The production
Home browser regression (`browser-tests/workspace_home_shell_test.ts`, included in `test:browser`)
also checks 320px, both themes, keyboard navigation, permission-gated links, and absence of Host requests.
## Adding a scenario
1. Name the concrete user task and expected data state; do not write “looks correct”.
@@ -0,0 +1,142 @@
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
const port = Number(Deno.args[0]);
const buildRoot = Deno.args[1];
if (!Number.isInteger(port) || !buildRoot) throw new Error("usage: server <port> <build-root>");
const names: Record<string, string> = {
"home-owner": "Workspace Home Review",
"home-member": "Shared Workspace",
"home-long":
"Workspace with a long name — international documentation and distributed development",
};
const json = (value: unknown, status = 200) => Response.json(value, { status });
const mime: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".woff2": "font/woff2",
};
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const url = new URL(request.url);
if (url.pathname === "/health") return new Response("ok");
if (url.pathname === "/api/workspaces") {
return json(
Object.entries(names).map(([workspace_id, display_name]) => ({
workspace_id,
display_name,
owner_account_id: "fixture-owner",
state: "active",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T00:00:00Z",
})),
);
}
const match = url.pathname.match(/^\/api\/w\/([^/]+)(\/.*)$/);
if (match) {
const [, workspaceId, path] = match;
const owner = workspaceId !== "home-member";
if (path === "/workspace") {
return json({
workspace_id: workspaceId,
display_name: names[workspaceId],
record_authority: "fixture",
schema_version: 1,
auth: {
Passkey: {
rp_id: "127.0.0.1",
origin: url.origin,
public_base_url: url.origin,
cookie_name: "fixture",
},
},
permissions: {
manage_repositories: owner,
manage_secrets: owner,
manage_runtimes: owner,
delete_workspace: owner,
},
extension_points: {
store: "fixture",
event_stream: { status: "ready", note: "fixture", diagnostics: [] },
host_worker_bridge: { status: "ready", note: "fixture", diagnostics: [] },
companion_console: { status: "ready", note: "fixture", diagnostics: [] },
},
});
}
if (path === "/repositories") {
return json({ workspace_id: workspaceId, items: [], source: "fixture", diagnostics: [] });
}
if (path === "/working-directories") {
return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
}
// Retained only for before-change captures. The redesigned Home must not request Hosts.
if (path === "/hosts") {
return json({
workspace_id: workspaceId,
limit: 100,
source: "fixture",
diagnostics: [],
items: workspaceId === "home-member" ? [] : [{
runtime_id: "fixture-runtime",
host_id: "fixture-host",
label: "Development host",
kind: "local",
status: "available",
observed_at: "2026-01-02T00:00:00Z",
last_seen_at: null,
os: "linux",
arch: "x86_64",
diagnostics: [],
}],
});
}
if (path === "/memory") {
return json({
body_md: "# Home navigation destination",
bytes: 29,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T00:00:00Z",
record_source: "fixture",
});
}
if (path === "/protocol/ws" && request.headers.get("upgrade") === "websocket") {
const { socket, response } = Deno.upgradeWebSocket(request);
socket.onmessage = (event) => {
const frame = JSON.parse(String(event.data));
if (frame?.message?.method !== "subscribe_events") return;
socket.send(JSON.stringify({
protocol_version: 1,
frame: "response",
message: {
result: "subscribed",
payload: {
request_id: frame.message.params.request_id,
subscription_id: "fixture-workers",
selector: { topic: "workspace_workers" },
snapshot_revision: 1,
snapshot: { topic: "workers", data: { workers: [] } },
},
},
}));
};
return response;
}
return json({ error: "Unexpected fixture API request" }, 404);
}
const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html");
if (relative.startsWith("..")) return new Response("not found", { status: 404 });
let file = join(buildRoot, relative);
try {
if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html");
return new Response(await Deno.readFile(file), {
headers: { "content-type": mime[extname(file)] ?? "application/octet-stream" },
});
} catch {
return new Response(await Deno.readFile(join(buildRoot, "index.html")), {
headers: { "content-type": mime[".html"] },
});
}
});
@@ -0,0 +1,148 @@
/// <reference lib="dom" />
import { assert, assertEquals } from "@std/assert";
import { dirname, fromFileUrl, join } from "@std/path";
import { chromium } from "playwright";
const root = join(dirname(fromFileUrl(import.meta.url)), "../../..");
Deno.test("production Home preserves scoped navigation, permission boundaries, responsive layout, and keyboard access", async () => {
const build = await new Deno.Command(Deno.execPath(), {
args: ["task", "build"],
cwd: join(root, "web/workspace"),
stdout: "piped",
stderr: "piped",
}).output();
assert(build.success, new TextDecoder().decode(build.stderr));
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const port = (listener.addr as Deno.NetAddr).port;
listener.close();
const url = `http://127.0.0.1:${port}`;
const server = new Deno.Command(Deno.execPath(), {
args: [
"run",
"--allow-net",
"--allow-read",
join(root, "tools/web-ux/browser-tests/workspace_home_fixture_server.ts"),
String(port),
join(root, "web/workspace/build"),
],
stdout: "null",
stderr: "null",
}).spawn();
try {
let ready = false;
for (let n = 0; n < 100; n++) {
try {
const response = await fetch(`${url}/health`);
await response.body?.cancel();
if (response.ok) {
ready = true;
break;
}
} catch { /* owned fixture is starting */ }
await new Promise((resolve) => setTimeout(resolve, 50));
}
assert(ready, "fixture did not start");
const browser = await chromium.launch({ headless: true });
try {
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390, 320]) {
const context = await browser.newContext({
viewport: { width, height: 900 },
colorScheme,
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
const errors: string[] = [];
const requests: string[] = [];
page.on("pageerror", (error) => errors.push(String(error)));
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("requestfailed", (request) => errors.push(request.url()));
page.on("request", (request) => requests.push(request.url()));
for (const workspace of ["home-owner", "home-member", "home-long"]) {
await page.goto(`${url}/w/${workspace}`);
const work = page.getByRole("navigation", { name: "Work", exact: true });
await work.waitFor();
assertEquals(
(await work.getByRole("link").allTextContents()).map((text) => text.trim()),
["Tickets", "Objectives", "Merge Requests", "Memory", "Workers"],
);
const settings = page.locator("main").getByRole("navigation", {
name: "Settings",
exact: true,
});
assertEquals(
await settings.getByRole("link").count(),
workspace === "home-member" ? 2 : 6,
);
assertEquals(
await settings.getByRole("link", { name: "Runtimes", exact: true }).count(),
workspace === "home-member" ? 0 : 1,
);
assertEquals(await page.locator("main h1").allTextContents(), ["Work"]);
const geometry = await page.locator(".workspace-home").evaluate((home) => {
const main = home.closest("main")!;
const work = home.querySelector("section")!.getBoundingClientRect();
const settings = home.querySelector(".home-settings")!.getBoundingClientRect();
return {
pageOverflow: document.documentElement.scrollWidth > innerWidth,
mainOverflow: main.scrollWidth > main.clientWidth,
firstLinkBottom: home.querySelector("a")!.getBoundingClientRect().bottom,
sideBySide: settings.left > work.right,
stacked: settings.top >= work.bottom,
smallTargets: [...home.querySelectorAll("a")].filter((link) =>
link.getBoundingClientRect().height < 44
).length,
};
});
assertEquals(geometry.pageOverflow, false);
assertEquals(geometry.mainOverflow, false);
assert(geometry.firstLinkBottom < 900);
assertEquals(geometry.smallTargets, 0);
assert(width === 1440 ? geometry.sideBySide : geometry.stacked);
const first = work.getByRole("link", { name: "Tickets", exact: true });
await first.focus();
const outline = await first.evaluate((link) => getComputedStyle(link).outlineStyle);
assertEquals(outline, "solid");
await page.keyboard.press("Tab");
assertEquals(
await page.evaluate(() => document.activeElement?.textContent?.trim()),
"Objectives",
);
for (const link of await page.locator("main a").all()) {
assert((await link.getAttribute("href"))!.startsWith(`/w/${workspace}/`));
}
}
const memory = page.getByRole("navigation", { name: "Work", exact: true }).getByRole(
"link",
{ name: "Memory", exact: true },
);
await memory.focus();
await page.keyboard.press("Enter");
await page.waitForURL(`**/w/home-long/memory`);
await page.getByRole("heading", { name: "Home navigation destination" }).waitFor();
assertEquals(
requests.some((request) => new URL(request).pathname.endsWith("/hosts")),
false,
);
assertEquals(errors, []);
} finally {
await context.close();
}
}
}
} finally {
await browser.close();
}
} finally {
try {
server.kill("SIGTERM");
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
}
await server.status;
}
});
+1 -1
View File
@@ -11,7 +11,7 @@
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts"
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts"
},
"fmt": {
"lineWidth": 100,
@@ -0,0 +1,63 @@
{
"schemaVersion": 1,
"id": "workspace-home",
"title": "Workspace Home task entry and permission boundaries",
"baseUrl": "http://127.0.0.1:15281",
"colorScheme": "light",
"reducedMotion": "reduce",
"personas": [
{
"id": "fixture",
"label": "Fixture account; permission projection varies by Workspace",
"auth": { "kind": "anonymous" }
}
],
"viewports": [
{ "label": "desktop", "width": 1440, "height": 900 },
{ "label": "narrow", "width": 768, "height": 900 },
{ "label": "mobile", "width": 390, "height": 844 }
],
"routes": [
{
"id": "owner",
"label": "Owner Home",
"path": "/w/home-owner",
"goal": "Choose a daily resource without exposing infrastructure details; settings remain secondary.",
"dataState": "Workspace owner; empty repositories and workers; one Host available for before-change comparison.",
"ready": { "kind": "selector", "selector": "main a[href='/w/home-owner/tickets']" },
"capturePoints": [{ "id": "initial", "label": "Home" }]
},
{
"id": "non-owner",
"label": "Non-owner Home",
"path": "/w/home-member",
"goal": "Reach daily resources without seeing owner-only management links.",
"dataState": "All management permissions false; empty resource collections.",
"ready": { "kind": "selector", "selector": "main a[href='/w/home-member/tickets']" },
"capturePoints": [{ "id": "initial", "label": "Home" }]
},
{
"id": "long-name",
"label": "Long Workspace name",
"path": "/w/home-long",
"goal": "Keep resource navigation visible and avoid horizontal page overflow with long Workspace identity.",
"dataState": "Owner with a long Workspace name.",
"ready": { "kind": "selector", "selector": "main a[href='/w/home-long/tickets']" },
"capturePoints": [{ "id": "initial", "label": "Home" }]
}
],
"processes": [{
"id": "home-fixture",
"command": "deno",
"cwd": "..",
"args": [
"run",
"--allow-net",
"--allow-read",
"browser-tests/workspace_home_fixture_server.ts",
"15281",
"../../web/workspace/build"
],
"readyUrl": "http://127.0.0.1:15281/health"
}]
}