fix: rebuild workspace home around daily navigation
This commit is contained in:
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
}]
|
||||
}
|
||||
Reference in New Issue
Block a user