fix: replace home navigation with work dashboard
This commit is contained in:
@@ -183,9 +183,11 @@ 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`:
|
||||
`scenarios/workspace-home.json` captures the production work dashboard with owner, non-owner,
|
||||
long-name, empty, and intentionally failing-review Workspace projections at 1440px, 768px, and 390px.
|
||||
The partial-error route deliberately returns HTTP 503 only for Merge Requests; captures should retain
|
||||
that request/console/visible error while showing successful active-work and recent-update feeds.
|
||||
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
|
||||
@@ -193,7 +195,8 @@ deno task web-ux capture --scenario scenarios/workspace-home.json --output ../..
|
||||
|
||||
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.
|
||||
also checks 320px, both themes, keyboard record navigation, bounded loading, independent slow/error
|
||||
feeds, explicit refresh, and absence of Host requests.
|
||||
|
||||
## Adding a scenario
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
|
||||
import { dashboardFixture } from "../../../web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts";
|
||||
|
||||
const port = Number(Deno.args[0]);
|
||||
const buildRoot = Deno.args[1];
|
||||
@@ -6,6 +7,8 @@ if (!Number.isInteger(port) || !buildRoot) throw new Error("usage: server <port>
|
||||
const names: Record<string, string> = {
|
||||
"home-owner": "Workspace Home Review",
|
||||
"home-member": "Shared Workspace",
|
||||
"home-empty": "New Workspace",
|
||||
"home-error": "Partial failure Workspace",
|
||||
"home-long":
|
||||
"Workspace with a long name — international documentation and distributed development",
|
||||
};
|
||||
@@ -38,6 +41,11 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
if (match) {
|
||||
const [, workspaceId, path] = match;
|
||||
const owner = workspaceId !== "home-member";
|
||||
if (workspaceId === "home-error" && path === "/merge-requests") {
|
||||
return json({ error: "fixture unavailable" }, 503);
|
||||
}
|
||||
const dashboardData = dashboardFixture(path, url.searchParams, workspaceId);
|
||||
if (dashboardData) return json(dashboardData);
|
||||
if (path === "/workspace") {
|
||||
return json({
|
||||
workspace_id: workspaceId,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
/// <reference lib="dom" />
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { dirname, fromFileUrl, join } from "@std/path";
|
||||
import { chromium } from "playwright";
|
||||
import { chromium, type Page } 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 ready = async (page: Page) => {
|
||||
for (const id of ["home-attention", "home-active", "home-recent"]) {
|
||||
await page.locator(`[data-feed-ready="${id}"]`).waitFor();
|
||||
}
|
||||
};
|
||||
Deno.test("production Home dashboard isolates failures, shows authoritative records, and supports responsive keyboard navigation", async () => {
|
||||
const build = await new Deno.Command(Deno.execPath(), {
|
||||
args: ["task", "build"],
|
||||
cwd: join(root, "web/workspace"),
|
||||
@@ -30,19 +34,19 @@ Deno.test("production Home preserves scoped navigation, permission boundaries, r
|
||||
stderr: "null",
|
||||
}).spawn();
|
||||
try {
|
||||
let ready = false;
|
||||
let started = false;
|
||||
for (let n = 0; n < 100; n++) {
|
||||
try {
|
||||
const response = await fetch(`${url}/health`);
|
||||
await response.body?.cancel();
|
||||
if (response.ok) {
|
||||
ready = true;
|
||||
started = true;
|
||||
break;
|
||||
}
|
||||
} catch { /* owned fixture is starting */ }
|
||||
} catch { /* fixture is starting */ }
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
assert(ready, "fixture did not start");
|
||||
assert(started);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
@@ -64,66 +68,69 @@ Deno.test("production Home preserves scoped navigation, permission boundaries, r
|
||||
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"]);
|
||||
await ready(page);
|
||||
assertEquals(await page.locator("main h1").allTextContents(), ["Activity"]);
|
||||
assertEquals(await page.locator("main nav").count(), 0);
|
||||
assertEquals(await page.locator("main [role=alert]").count(), 0);
|
||||
assertEquals(await page.locator("[data-feed-ready=home-attention] a").count(), 3);
|
||||
assertEquals(await page.locator("[data-feed-ready=home-active] a").count(), 2);
|
||||
assertEquals(await page.locator("[data-feed-ready=home-recent] a").count(), 4);
|
||||
await page.getByText("Blocked by T-99", { exact: true }).waitFor();
|
||||
await page.getByText("W-7", { exact: true }).waitFor();
|
||||
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();
|
||||
const sections = [...home.querySelectorAll("section")].map((section) =>
|
||||
section.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,
|
||||
firstLinkTop: home.querySelector("a")!.getBoundingClientRect().top,
|
||||
sideBySide: sections[1].left > sections[0].right &&
|
||||
sections[2].left > sections[1].right,
|
||||
stacked: sections[1].top >= sections[0].bottom &&
|
||||
sections[2].top >= sections[1].bottom,
|
||||
};
|
||||
});
|
||||
assertEquals(geometry.pageOverflow, false);
|
||||
assertEquals(geometry.mainOverflow, false);
|
||||
assert(geometry.firstLinkBottom < 900);
|
||||
assertEquals(geometry.smallTargets, 0);
|
||||
assert(geometry.firstLinkTop < 900);
|
||||
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");
|
||||
const links = page.locator("main a");
|
||||
await links.first().focus();
|
||||
assertEquals(
|
||||
await links.first().evaluate((link) => getComputedStyle(link).outlineStyle),
|
||||
"solid",
|
||||
);
|
||||
await page.keyboard.press("Tab");
|
||||
assertEquals(
|
||||
await page.evaluate(() => document.activeElement?.textContent?.trim()),
|
||||
"Objectives",
|
||||
await links.nth(1).evaluate((link) => document.activeElement === link),
|
||||
true,
|
||||
);
|
||||
for (const link of await page.locator("main a").all()) {
|
||||
assert((await link.getAttribute("href"))!.startsWith(`/w/${workspace}/`));
|
||||
for (const link of await links.all()) {
|
||||
assert(
|
||||
new RegExp(`/w/${workspace}/(tickets|objectives|merge-requests)/[^/]+$`).test(
|
||||
(await link.getAttribute("href"))!,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
const memory = page.getByRole("navigation", { name: "Work", exact: true }).getByRole(
|
||||
"link",
|
||||
{ name: "Memory", exact: true },
|
||||
const beforeRefresh = requests.filter((request) =>
|
||||
request.includes("/merge-requests?")
|
||||
).length;
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).waitFor();
|
||||
await ready(page);
|
||||
assertEquals(
|
||||
requests.filter((request) => request.includes("/merge-requests?")).length,
|
||||
beforeRefresh + 1,
|
||||
);
|
||||
await memory.focus();
|
||||
const objective = page.locator("main a[href*='/objectives/O-12-']");
|
||||
await objective.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForURL(`**/w/home-long/memory`);
|
||||
await page.getByRole("heading", { name: "Home navigation destination" }).waitFor();
|
||||
await page.waitForURL("**/objectives/O-12-*");
|
||||
await page.getByText("Home dashboard navigation destination.", { exact: true })
|
||||
.waitFor();
|
||||
assertEquals(
|
||||
requests.some((request) => new URL(request).pathname.endsWith("/hosts")),
|
||||
false,
|
||||
@@ -134,6 +141,37 @@ Deno.test("production Home preserves scoped navigation, permission boundaries, r
|
||||
}
|
||||
}
|
||||
}
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
try {
|
||||
await page.goto(`${url}/w/home-empty`);
|
||||
await ready(page);
|
||||
assertEquals(await page.locator("main a").count(), 0);
|
||||
await page.getByText("No open Merge Requests.", { exact: true }).waitFor();
|
||||
await page.goto(`${url}/w/home-error`);
|
||||
await ready(page);
|
||||
assertEquals(
|
||||
await page.locator("[data-feed-ready=home-attention] [role=alert]").count(),
|
||||
1,
|
||||
);
|
||||
assertEquals(await page.getByText("No open Merge Requests.", { exact: true }).count(), 0);
|
||||
assertEquals(await page.locator("[data-feed-ready=home-active] a").count(), 2);
|
||||
assertEquals(await page.locator("[data-feed-ready=home-recent] a").count(), 4);
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route("**/merge-requests?*", async (route) => {
|
||||
await held;
|
||||
await route.continue();
|
||||
});
|
||||
await page.goto(`${url}/w/home-owner`);
|
||||
await page.locator("[data-feed-ready=home-active]").waitFor();
|
||||
assertEquals(await page.getByText("Loading needs attention…", { exact: true }).count(), 1);
|
||||
release();
|
||||
await ready(page);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "workspace-home",
|
||||
"title": "Workspace Home task entry and permission boundaries",
|
||||
"title": "Workspace Home operational dashboard",
|
||||
"baseUrl": "http://127.0.0.1:15281",
|
||||
"colorScheme": "light",
|
||||
"reducedMotion": "reduce",
|
||||
@@ -22,28 +22,61 @@
|
||||
"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" }]
|
||||
"goal": "Identify review work, blocked or assigned active tickets, and recent updates; open an actual record.",
|
||||
"dataState": "Mixed review states, in-progress/queued tickets with a coder and dependency blocker, done/closed tickets and an Objective.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Dashboard" }]
|
||||
},
|
||||
{
|
||||
"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" }]
|
||||
"goal": "Read the same work records without owner-only management controls.",
|
||||
"dataState": "All management permissions false; same authorized read-only work projections as owner.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Dashboard" }]
|
||||
},
|
||||
{
|
||||
"id": "long-name",
|
||||
"label": "Long Workspace name",
|
||||
"label": "Long names and selectors",
|
||||
"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" }]
|
||||
"goal": "Read long record titles and branch selectors without page-wide overflow.",
|
||||
"dataState": "Owner with long Workspace name, Objective title, and Merge Request selectors.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Dashboard" }]
|
||||
},
|
||||
{
|
||||
"id": "empty",
|
||||
"label": "Empty Workspace",
|
||||
"path": "/w/home-empty",
|
||||
"goal": "Distinguish empty work scopes without fabricated status totals.",
|
||||
"dataState": "All dashboard resource queries successfully return empty collections.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Empty scopes" }]
|
||||
},
|
||||
{
|
||||
"id": "partial-error",
|
||||
"label": "Reviews unavailable",
|
||||
"path": "/w/home-error",
|
||||
"goal": "Keep active work and recent updates usable when reviews fail; never claim all-clear.",
|
||||
"dataState": "Intentional HTTP 503 from Merge Requests. Expected visible error and browser HTTP console diagnostic; other resource requests succeed.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Scoped review failure" }]
|
||||
}
|
||||
],
|
||||
"processes": [{
|
||||
|
||||
Reference in New Issue
Block a user