fix: replace home navigation with work dashboard

This commit is contained in:
2026-10-01 14:25:21 +09:00
parent 696e6a51e7
commit 8e0dad98e8
12 changed files with 969 additions and 341 deletions
+7 -4
View File
@@ -183,9 +183,11 @@ approved immutable artifact channel when durable review evidence is required.
## Workspace Home fixture ## Workspace Home fixture
`scenarios/workspace-home.json` captures the production Home with owner, non-owner, empty-resource, `scenarios/workspace-home.json` captures the production work dashboard with owner, non-owner,
and long-name Workspace projections at 1440px, 768px, and 390px. It uses no real account or private long-name, empty, and intentionally failing-review Workspace projections at 1440px, 768px, and 390px.
Workspace data. Build `web/workspace` first, then run from `tools/web-ux`: 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 ```sh
deno task web-ux capture --scenario scenarios/workspace-home.json --output ../../target/web-ux --run-id home-review 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 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`) 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 ## Adding a scenario
@@ -1,4 +1,5 @@
import { extname, join, normalize } from "jsr:@std/path@1.1.4"; 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 port = Number(Deno.args[0]);
const buildRoot = Deno.args[1]; 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> = { const names: Record<string, string> = {
"home-owner": "Workspace Home Review", "home-owner": "Workspace Home Review",
"home-member": "Shared Workspace", "home-member": "Shared Workspace",
"home-empty": "New Workspace",
"home-error": "Partial failure Workspace",
"home-long": "home-long":
"Workspace with a long name — international documentation and distributed development", "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) { if (match) {
const [, workspaceId, path] = match; const [, workspaceId, path] = match;
const owner = workspaceId !== "home-member"; 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") { if (path === "/workspace") {
return json({ return json({
workspace_id: workspaceId, workspace_id: workspaceId,
@@ -1,11 +1,15 @@
/// <reference lib="dom" /> /// <reference lib="dom" />
import { assert, assertEquals } from "@std/assert"; import { assert, assertEquals } from "@std/assert";
import { dirname, fromFileUrl, join } from "@std/path"; 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)), "../../.."); const root = join(dirname(fromFileUrl(import.meta.url)), "../../..");
const ready = async (page: Page) => {
Deno.test("production Home preserves scoped navigation, permission boundaries, responsive layout, and keyboard access", async () => { 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(), { const build = await new Deno.Command(Deno.execPath(), {
args: ["task", "build"], args: ["task", "build"],
cwd: join(root, "web/workspace"), cwd: join(root, "web/workspace"),
@@ -30,19 +34,19 @@ Deno.test("production Home preserves scoped navigation, permission boundaries, r
stderr: "null", stderr: "null",
}).spawn(); }).spawn();
try { try {
let ready = false; let started = false;
for (let n = 0; n < 100; n++) { for (let n = 0; n < 100; n++) {
try { try {
const response = await fetch(`${url}/health`); const response = await fetch(`${url}/health`);
await response.body?.cancel(); await response.body?.cancel();
if (response.ok) { if (response.ok) {
ready = true; started = true;
break; break;
} }
} catch { /* owned fixture is starting */ } } catch { /* fixture is starting */ }
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
} }
assert(ready, "fixture did not start"); assert(started);
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
try { try {
for (const colorScheme of ["light", "dark"] as const) { 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())); page.on("request", (request) => requests.push(request.url()));
for (const workspace of ["home-owner", "home-member", "home-long"]) { for (const workspace of ["home-owner", "home-member", "home-long"]) {
await page.goto(`${url}/w/${workspace}`); await page.goto(`${url}/w/${workspace}`);
const work = page.getByRole("navigation", { name: "Work", exact: true }); await ready(page);
await work.waitFor(); assertEquals(await page.locator("main h1").allTextContents(), ["Activity"]);
assertEquals( assertEquals(await page.locator("main nav").count(), 0);
(await work.getByRole("link").allTextContents()).map((text) => text.trim()), assertEquals(await page.locator("main [role=alert]").count(), 0);
["Tickets", "Objectives", "Merge Requests", "Memory", "Workers"], assertEquals(await page.locator("[data-feed-ready=home-attention] a").count(), 3);
); assertEquals(await page.locator("[data-feed-ready=home-active] a").count(), 2);
const settings = page.locator("main").getByRole("navigation", { assertEquals(await page.locator("[data-feed-ready=home-recent] a").count(), 4);
name: "Settings", await page.getByText("Blocked by T-99", { exact: true }).waitFor();
exact: true, await page.getByText("W-7", { exact: true }).waitFor();
});
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 geometry = await page.locator(".workspace-home").evaluate((home) => {
const main = home.closest("main")!; const main = home.closest("main")!;
const work = home.querySelector("section")!.getBoundingClientRect(); const sections = [...home.querySelectorAll("section")].map((section) =>
const settings = home.querySelector(".home-settings")!.getBoundingClientRect(); section.getBoundingClientRect()
);
return { return {
pageOverflow: document.documentElement.scrollWidth > innerWidth, pageOverflow: document.documentElement.scrollWidth > innerWidth,
mainOverflow: main.scrollWidth > main.clientWidth, mainOverflow: main.scrollWidth > main.clientWidth,
firstLinkBottom: home.querySelector("a")!.getBoundingClientRect().bottom, firstLinkTop: home.querySelector("a")!.getBoundingClientRect().top,
sideBySide: settings.left > work.right, sideBySide: sections[1].left > sections[0].right &&
stacked: settings.top >= work.bottom, sections[2].left > sections[1].right,
smallTargets: [...home.querySelectorAll("a")].filter((link) => stacked: sections[1].top >= sections[0].bottom &&
link.getBoundingClientRect().height < 44 sections[2].top >= sections[1].bottom,
).length,
}; };
}); });
assertEquals(geometry.pageOverflow, false); assertEquals(geometry.pageOverflow, false);
assertEquals(geometry.mainOverflow, false); assertEquals(geometry.mainOverflow, false);
assert(geometry.firstLinkBottom < 900); assert(geometry.firstLinkTop < 900);
assertEquals(geometry.smallTargets, 0);
assert(width === 1440 ? geometry.sideBySide : geometry.stacked); assert(width === 1440 ? geometry.sideBySide : geometry.stacked);
const first = work.getByRole("link", { name: "Tickets", exact: true }); const links = page.locator("main a");
await first.focus(); await links.first().focus();
const outline = await first.evaluate((link) => getComputedStyle(link).outlineStyle); assertEquals(
assertEquals(outline, "solid"); await links.first().evaluate((link) => getComputedStyle(link).outlineStyle),
"solid",
);
await page.keyboard.press("Tab"); await page.keyboard.press("Tab");
assertEquals( assertEquals(
await page.evaluate(() => document.activeElement?.textContent?.trim()), await links.nth(1).evaluate((link) => document.activeElement === link),
"Objectives", true,
); );
for (const link of await page.locator("main a").all()) { for (const link of await links.all()) {
assert((await link.getAttribute("href"))!.startsWith(`/w/${workspace}/`)); assert(
new RegExp(`/w/${workspace}/(tickets|objectives|merge-requests)/[^/]+$`).test(
(await link.getAttribute("href"))!,
),
);
} }
} }
const memory = page.getByRole("navigation", { name: "Work", exact: true }).getByRole( const beforeRefresh = requests.filter((request) =>
"link", request.includes("/merge-requests?")
{ name: "Memory", exact: true }, ).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.keyboard.press("Enter");
await page.waitForURL(`**/w/home-long/memory`); await page.waitForURL("**/objectives/O-12-*");
await page.getByRole("heading", { name: "Home navigation destination" }).waitFor(); await page.getByText("Home dashboard navigation destination.", { exact: true })
.waitFor();
assertEquals( assertEquals(
requests.some((request) => new URL(request).pathname.endsWith("/hosts")), requests.some((request) => new URL(request).pathname.endsWith("/hosts")),
false, 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 { } finally {
await browser.close(); await browser.close();
} }
+47 -14
View File
@@ -1,7 +1,7 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"id": "workspace-home", "id": "workspace-home",
"title": "Workspace Home task entry and permission boundaries", "title": "Workspace Home operational dashboard",
"baseUrl": "http://127.0.0.1:15281", "baseUrl": "http://127.0.0.1:15281",
"colorScheme": "light", "colorScheme": "light",
"reducedMotion": "reduce", "reducedMotion": "reduce",
@@ -22,28 +22,61 @@
"id": "owner", "id": "owner",
"label": "Owner Home", "label": "Owner Home",
"path": "/w/home-owner", "path": "/w/home-owner",
"goal": "Choose a daily resource without exposing infrastructure details; settings remain secondary.", "goal": "Identify review work, blocked or assigned active tickets, and recent updates; open an actual record.",
"dataState": "Workspace owner; empty repositories and workers; one Host available for before-change comparison.", "dataState": "Mixed review states, in-progress/queued tickets with a coder and dependency blocker, done/closed tickets and an Objective.",
"ready": { "kind": "selector", "selector": "main a[href='/w/home-owner/tickets']" }, "ready": {
"capturePoints": [{ "id": "initial", "label": "Home" }] "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", "id": "non-owner",
"label": "Non-owner Home", "label": "Non-owner Home",
"path": "/w/home-member", "path": "/w/home-member",
"goal": "Reach daily resources without seeing owner-only management links.", "goal": "Read the same work records without owner-only management controls.",
"dataState": "All management permissions false; empty resource collections.", "dataState": "All management permissions false; same authorized read-only work projections as owner.",
"ready": { "kind": "selector", "selector": "main a[href='/w/home-member/tickets']" }, "ready": {
"capturePoints": [{ "id": "initial", "label": "Home" }] "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", "id": "long-name",
"label": "Long Workspace name", "label": "Long names and selectors",
"path": "/w/home-long", "path": "/w/home-long",
"goal": "Keep resource navigation visible and avoid horizontal page overflow with long Workspace identity.", "goal": "Read long record titles and branch selectors without page-wide overflow.",
"dataState": "Owner with a long Workspace name.", "dataState": "Owner with long Workspace name, Objective title, and Merge Request selectors.",
"ready": { "kind": "selector", "selector": "main a[href='/w/home-long/tickets']" }, "ready": {
"capturePoints": [{ "id": "initial", "label": "Home" }] "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": [{ "processes": [{
+1
View File
@@ -94,6 +94,7 @@ Use semantic tokens from `app.css`. Do not introduce component-local color, spac
- `src/routes/+layout.svelte`: root application shell and override contexts. - `src/routes/+layout.svelte`: root application shell and override contexts.
- `src/routes/w/[workspaceId]/+layout.svelte`: Workspace Header and Sidebar registration. - `src/routes/w/[workspaceId]/+layout.svelte`: Workspace Header and Sidebar registration.
- `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration. - `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration.
- `src/lib/workspace/home/`: bounded, read-only work dashboard feeds and their presentation.
- `src/lib/workspace/header/`: Header frame, context, and overrides. - `src/lib/workspace/header/`: Header frame, context, and overrides.
- `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack. - `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack.
- `src/lib/workspace/markdown/`: shared safe Markdown parsing plus presentation-specific document renderers. - `src/lib/workspace/markdown/`: shared safe Markdown parsing plus presentation-specific document renderers.
@@ -245,12 +245,10 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
); );
assert( assert(
workspacePage.includes("path: '/tickets'") && workspacePage.includes("DashboardSection") &&
workspacePage.includes("path: '/settings/runtimes'") && !workspacePage.includes("<nav") &&
workspacePage.includes("permissions.manage_runtimes === true") && !workspacePage.includes("/settings/runtimes"),
workspacePage.includes("path: '/workers'") && "Home should show work records without duplicating Sidebar navigation",
workspacePage.includes("workspaceRoute(data.workspace.workspace_id, resource.path)"),
"Home should link to daily resources and permission-gated Settings without owning their inventories",
); );
assert( assert(
!workspacePage.includes("workerConsoleHref") && !workspacePage.includes("workerConsoleHref") &&
@@ -0,0 +1,67 @@
<script lang="ts">
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
import type { DashboardFeed } from './dashboard';
let { id, title, empty, result }: { id: string; title: string; empty: string; result: Promise<DashboardFeed> } = $props();
function timestamp(value?: string | null): string | null {
return value && Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : null;
}
function displayDate(value: string): string {
return new Date(value).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
}
</script>
<section aria-labelledby={id} class="dashboard-section">
<h2 {id}>{title}</h2>
{#await result}
<p class="feed-state" role="status">Loading {title.toLowerCase()}…</p>
{:then feed}
<div data-feed-ready={id}>
{#each feed.errors as error}<p class="feed-error" role="alert">{error}</p>{/each}
{#if feed.rows.length}
<ul>
{#each feed.rows as row, index (row.key)}
{@const date = timestamp(row.updatedAt)}
<li>
<a href={row.href} class:attention={row.attention}>
<div class="row-meta"><span>{row.kind}</span><code>{row.reference}</code></div>
<strong class:machine-title={row.kind === 'Merge Request'}>{row.title}</strong>
<span class="row-status">{row.status}</span>
{#if row.workerKey}<span class="row-detail">Coder <code>{row.workerKey}</code></span>{/if}
{#if row.detail}<span class="row-detail">{row.detail}</span>{/if}
{#if date}<time datetime={date}>Updated {displayDate(date)}</time>{:else}<span class="row-detail">Update time unavailable</span>{/if}
</a>
{#if index < feed.rows.length - 1}<BevelLine decorative />{/if}
</li>
{/each}
</ul>
{:else if !feed.errors.length}
<p class="feed-state">{empty}</p>
{/if}
{#if feed.notice}<p class="feed-notice">{feed.notice}</p>{/if}
</div>
{:catch}
<p class="feed-error" role="alert">Could not load {title.toLowerCase()}. Refresh to try again.</p>
{/await}
</section>
<style>
section { min-width: 0; }
h2 { margin: 0 0 var(--space-2); font-size: var(--font-size-body); line-height: var(--line-height-body); color: var(--text-strong); }
ul { list-style: none; margin: 0; padding: 0; }
a { display: grid; gap: var(--space-1); min-width: 0; padding-block: var(--space-3); text-decoration: none; color: var(--text); overflow-wrap: anywhere; }
a:hover, a:focus-visible { background: var(--interactive-hover); }
a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
a:active { background: var(--interactive-selected); }
strong { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; color: var(--text-strong); font-size: var(--font-size-body); line-height: var(--line-height-body); font-weight: 600; }
.machine-title { font-family: var(--font-mono); }
.row-meta { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.row-meta, .row-detail, time, .row-status, .feed-notice { font-size: var(--font-size-compact); line-height: var(--line-height-compact); }
.row-meta, .row-detail, time, .feed-state, .feed-notice { color: var(--text-muted); }
code { font-family: var(--font-mono); }
.row-status { font-weight: 600; }
.attention .row-status { color: var(--warning); }
.feed-state { margin: var(--space-3) 0; }
.feed-error { margin: var(--space-3) 0; color: var(--danger); overflow-wrap: anywhere; }
.feed-notice { margin: var(--space-3) 0 0; }
</style>
@@ -0,0 +1,194 @@
// Deterministic read-only fixtures shared by Home component and browser tests.
export const fixturePage = (returned: number, more = false) => ({
returned,
limit: 5,
sort: "priority",
has_more: more,
next_cursor: more ? "next" : null,
source_limit: null,
source_truncated: false,
});
const updated = (hour: number) =>
`2026-09-28T${String(hour).padStart(2, "0")}:00:00Z`;
export function fixtureTicket(key: string, state: string, hour = 10) {
return {
id: `internal-${key}`,
resource_key: key,
title: key === "T-101"
? "Restore queued Worker submissions"
: `${state} ticket ${key}`,
state,
priority: "normal",
queued_at: null,
queued_by: null,
updated_at: updated(hour),
record_source: "fixture",
workspace_action_priority: state === "inprogress" || state === "queued"
? "active_work"
: "background",
};
}
export function fixtureDetail(key = "T-101") {
const summary = fixtureTicket(key, key === "T-101" ? "inprogress" : "queued");
const { workspace_action_priority: _, ...ticket } = summary;
return {
...ticket,
item_revision: "revision-1",
created_at: updated(1),
readiness: null,
body: "Implementation in progress.",
body_truncated: false,
resolution: null,
artifacts: [],
artifact_count: 0,
assignments: [],
assignment_diagnostics: [],
events: [],
event_count: 0,
event_page: fixturePage(0),
implementation_reports: [],
evidence: {
has_merge_request: false,
has_current_subject_ref: false,
has_review_request: false,
has_commit: false,
review_status: null,
approved_current_subject: false,
review_after_rescope: false,
unresolved_request_changes: false,
complete_for_integration: false,
missing: [],
},
current_coder: key === "T-101"
? {
assignment_id: "a-1",
runtime_id: "internal-runtime",
worker_id: "internal-worker",
worker_resource_key: "W-7",
}
: null,
action_eligibility: {
blockers: [],
can_assign_orchestrator: false,
can_queue: false,
can_start_manual_coder: false,
can_unassign_orchestrator: false,
queue_tickets: [],
},
linked_objectives: [],
merge_request: null,
merge_requests: [],
targets: [],
risk_flags: [],
relations: {
outgoing: [],
incoming: [],
notices: [],
blockers: key === "T-101"
? [{
blocking_resource_key: "T-99",
blocking_state: "ready",
blocking_ticket: "internal-T-99",
note: null,
reason_kind: "dependency",
relation_kind: "depends_on",
}]
: [],
},
};
}
export function dashboardFixture(
path: string,
search: URLSearchParams,
workspaceId = "home-owner",
): unknown | null {
const empty = workspaceId === "home-empty";
const long = workspaceId === "home-long";
if (path === "/tickets") {
const state = search.get("states")!;
const items = empty
? []
: state === "inprogress,queued"
? [fixtureTicket("T-101", "inprogress"), fixtureTicket("T-102", "queued")]
: state === "done"
? [fixtureTicket("T-103", "done", 12), fixtureTicket("T-104", "done", 7)]
: [fixtureTicket("T-105", "closed", 14)];
if (long && items[0]) {
items[0].title = "長いTicket名 — ".repeat(8) +
"preserve-the-full-title-without-horizontal-overflow";
}
return {
workspace_id: workspaceId,
limit: 5,
items,
page: fixturePage(items.length, !empty),
invalid_records: [],
record_authority: "fixture",
};
}
if (/^\/tickets\/T-10[12]/.test(path)) {
return fixtureDetail(path.includes("T-101") ? "T-101" : "T-102");
}
const objective = {
id: "internal-objective",
resource_key: "O-12",
title: long ? "Objective ".repeat(18) : "Improve daily Workspace operation",
state: "active",
created_at: updated(1),
updated_at: updated(13),
summary: "",
linked_tickets: [],
record_source: "fixture",
};
if (path === "/objectives") {
return {
workspace_id: workspaceId,
limit: 5,
items: empty ? [] : [objective],
invalid_records: [],
record_authority: "fixture",
};
}
if (path.startsWith("/objectives/O-12")) {
const { summary: _, ...detail } = objective;
return {
...detail,
body: "# Objective detail\n\nHome dashboard navigation destination.",
body_truncated: false,
revision: "revision-1",
linked_ticket_summaries: [],
resources: [],
events: [],
event_page: fixturePage(0),
};
}
if (path === "/merge-requests") {
return {
items: empty
? []
: ["request_changes", "pending", "approved"].map((status, index) => ({
summary: {
merge_request_id: `internal-merge-${index}`,
repository_key: "main",
state: "open",
selector_from: long
? "feature/" + "long-branch-".repeat(15)
: ["fix/queue", "feat/home", "fix/sidebar"][index],
selector_to: "develop",
review_status: status,
updated_at: updated(15 - index),
review_excerpt: null,
review_requested_at: updated(10),
review_submitted_at: null,
current_subject_ref: "abc123",
review_subject_ref: "abc123",
},
thread_event_count: 1,
ticket_ids: [],
ref_diagnostics: [],
})),
next_cursor: empty ? null : "older-reviews",
};
}
return null;
}
@@ -0,0 +1,263 @@
import type { TicketListItemSummary } from "$lib/generated/ticket-api";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
parseMergeRequestListResponse,
parseObjectiveListResponse,
parseTicketDetail,
parseTicketListResponse,
TICKET_BROWSER_API_LOAD_POLICY,
} from "$lib/workspace/api/ticket-browser";
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
import { objectiveHref, ticketHref } from "$lib/workspace/resource-links";
export const HOME_LIMIT = 5;
export type DashboardRow = {
key: string;
kind: "Ticket" | "Objective" | "Merge Request";
reference: string;
title: string;
href: string;
status: string;
detail?: string;
workerKey?: string;
attention?: boolean;
updatedAt?: string | null;
};
export type DashboardFeed = {
rows: DashboardRow[];
errors: string[];
notice?: string;
};
export type Dashboard = {
reviews: Promise<DashboardFeed>;
active: Promise<DashboardFeed>;
recent: Promise<DashboardFeed>;
};
const states: Record<string, string> = {
inprogress: "In progress",
queued: "Queued",
done: "Done",
closed: "Closed",
active: "Active",
completed: "Completed",
paused: "Paused",
};
const reviewStates: Record<string, string> = {
none: "Not reviewed",
pending: "Review pending",
approved: "Approved",
request_changes: "Changes requested",
unresolved_changes: "Unresolved changes",
};
function ticketRow(
workspaceId: string,
ticket: TicketListItemSummary,
): DashboardRow {
return {
key: `ticket:${ticket.resource_key}`,
kind: "Ticket",
reference: ticket.resource_key,
title: ticket.title,
href: ticketHref(workspaceId, ticket),
status: states[ticket.state] ?? ticket.state,
updatedAt: ticket.updated_at,
};
}
function failure(label: string, error: string | null): string {
const status = error?.match(/HTTP \d{3}/)?.[0];
return `${label} unavailable.${
status ? ` ${status}.` : ""
} Refresh to retry.`;
}
function dateValue(value?: string | null): number {
const timestamp = value ? Date.parse(value) : NaN;
return Number.isFinite(timestamp) ? timestamp : -Infinity;
}
export function recentRows(rows: DashboardRow[]): DashboardRow[] {
const latest = new Map<string, DashboardRow>();
for (const row of rows) {
const previous = latest.get(row.key);
if (
!previous || dateValue(row.updatedAt) >= dateValue(previous.updatedAt)
) latest.set(row.key, row);
}
return [...latest.values()]
.sort((a, b) =>
dateValue(b.updatedAt) - dateValue(a.updatedAt) ||
a.key.localeCompare(b.key)
)
.slice(0, HOME_LIMIT);
}
// These are bounded, read-only browser endpoints. QueryTicket/QueryObjective are
// tool-only capabilities and must not be approximated by guessed HTTP routes.
export function loadDashboard(
fetchFn: typeof fetch,
workspaceId: string,
): Dashboard {
function get<T>(path: string, parse: (value: unknown) => T) {
return loadJson(
fetchFn,
workspaceApiPath(workspaceId, path),
{ signal: AbortSignal.timeout(10_000) },
parse,
TICKET_BROWSER_API_LOAD_POLICY,
);
}
const tickets = (state: string) =>
get(
`/tickets?${new URLSearchParams({
states: state,
limit: String(HOME_LIMIT),
})}`,
parseTicketListResponse,
);
const activeResult = tickets("inprogress,queued");
const doneResult = tickets("done");
const closedResult = tickets("closed");
const objectivesResult = get(
`/objectives?limit=${HOME_LIMIT}`,
parseObjectiveListResponse,
);
const reviewsResult = get(
`/merge-requests?state=open&limit=${HOME_LIMIT}`,
parseMergeRequestListResponse,
);
return {
reviews: reviewsResult.then((result): DashboardFeed => {
if (!result.data) {
return { rows: [], errors: [failure("Merge Requests", result.error)] };
}
return {
rows: result.data.items.slice(0, HOME_LIMIT).map((
{ summary, ref_diagnostics },
) => ({
key: `merge:${summary.merge_request_id}`,
kind: "Merge Request",
reference: summary.repository_key,
title: `${
summary.selector_from ?? "Source unavailable"
} → ${summary.selector_to}`,
href: mergeRequestPagePath(workspaceId, summary.merge_request_id),
status: reviewStates[summary.review_status] ?? summary.review_status,
attention: ["request_changes", "unresolved_changes"].includes(
summary.review_status,
) || Boolean(ref_diagnostics?.length),
detail: ref_diagnostics?.length
? "Source or target needs attention"
: undefined,
updatedAt: summary.updated_at,
})),
errors: [],
notice: result.data.next_cursor
? `Showing ${
Math.min(result.data.items.length, HOME_LIMIT)
} most recently updated open Merge Requests.`
: undefined,
};
}),
active: activeResult.then(async (result): Promise<DashboardFeed> => {
if (!result.data) {
return { rows: [], errors: [failure("Active tickets", result.error)] };
}
const details = await Promise.all(
result.data.items.slice(0, HOME_LIMIT).map(async (ticket) => {
const detail = await get(
`/tickets/${encodeURIComponent(ticket.resource_key)}`,
parseTicketDetail,
);
if (!detail.data) {
return {
row: {
...ticketRow(workspaceId, ticket),
detail: "Assignment and blockers unavailable",
},
error: failure(`${ticket.resource_key} details`, detail.error),
};
}
const current = detail.data;
// A concurrent transition can remove a Ticket from this snapshot's scope.
if (!["inprogress", "queued"].includes(current.state)) {
return { changed: true };
}
const blockers = current.relations.blockers;
const row = ticketRow(workspaceId, { ...ticket, ...current });
const blockedKeys = blockers.map((blocker) =>
blocker.blocking_resource_key
).filter(Boolean);
return {
row: {
...row,
status: blockers.length ? `${row.status} · Blocked` : row.status,
attention: blockers.length > 0,
detail: blockers.length
? (blockedKeys.length === blockers.length
? `Blocked by ${blockedKeys.join(", ")}`
: `${blockers.length} unresolved dependencies`)
: current.current_coder
? undefined
: "No coder assigned",
workerKey: current.current_coder?.worker_resource_key ??
undefined,
},
};
}),
);
return {
rows: details.flatMap((item) => item.row ? [item.row] : []),
errors: [
...details.flatMap((item) => item.error ? [item.error] : []),
...(result.data.invalid_records.length
? ["Some Ticket records could not be read."]
: []),
],
notice: details.some((item) => item.changed)
? "Work changed while loading. Refresh for the latest list."
: result.data.page.has_more || result.data.page.source_truncated
? `Showing ${
Math.min(result.data.items.length, HOME_LIMIT)
} active tickets, with in-progress work first.`
: undefined,
};
}),
recent: Promise.all([doneResult, closedResult, objectivesResult]).then((
[done, closed, objectives],
): DashboardFeed => ({
rows: recentRows([
...(done.data?.items ?? []).map((ticket) =>
ticketRow(workspaceId, ticket)
),
...(closed.data?.items ?? []).map((ticket) =>
ticketRow(workspaceId, ticket)
),
...(objectives.data?.items ?? []).map((objective): DashboardRow => ({
key: `objective:${objective.resource_key}`,
kind: "Objective",
reference: objective.resource_key,
title: objective.title,
href: objectiveHref(workspaceId, objective),
status: states[objective.state] ?? objective.state,
updatedAt: objective.updated_at,
})),
]),
errors: [
...(!done.data ? [failure("Done tickets", done.error)] : []),
...(!closed.data ? [failure("Closed tickets", closed.error)] : []),
...(!objectives.data ? [failure("Objectives", objectives.error)] : []),
...([done.data, closed.data, objectives.data].some((data) =>
data?.invalid_records.length
)
? ["Some records could not be read."]
: []),
],
notice:
[done.data, closed.data, objectives.data].some((data) =>
data?.items.length
)
? "Up to 5 latest updates to done / closed tickets and Objectives."
: undefined,
})),
};
}
@@ -1,126 +1,53 @@
<script lang="ts"> <script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http'; import { invalidate } from '$app/navigation';
import BevelLine from '$lib/workspace/ui/BevelLine.svelte'; import DashboardSection from '$lib/workspace/home/DashboardSection.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
const resources = [ let refreshing = $state(false);
{ label: 'Tickets', path: '/tickets' }, let refreshError = $state<string | null>(null);
{ label: 'Objectives', path: '/objectives' }, async function refresh() {
{ label: 'Merge Requests', path: '/merge-requests' }, if (refreshing) return;
{ label: 'Memory', path: '/memory' }, refreshing = true;
{ label: 'Workers', path: '/workers' }, refreshError = null;
]; try {
const settings = $derived([ await invalidate('workspace:home');
{ label: 'Runtimes', path: '/settings/runtimes', visible: data.workspace?.permissions.manage_runtimes === true }, await Promise.all(Object.values(data.dashboard));
{ label: 'Configuration Sources', path: '/settings/configuration', visible: true }, } catch {
{ label: 'Repositories', path: '/settings/repositories', visible: data.workspace?.permissions.manage_repositories === true }, refreshError = 'Could not refresh. Try again.';
{ label: 'Repository Access', path: '/settings/repository-access', visible: data.workspace?.permissions.manage_secrets === true }, } finally {
{ label: 'Profile Sources', path: '/settings/profiles', visible: true }, refreshing = false;
{ label: 'Workspace Identity', path: '/settings', visible: data.workspace?.permissions.delete_workspace === true }, }
].filter((item) => item.visible)); }
</script> </script>
<svelte:head> <svelte:head><title>{data.workspace?.display_name ?? 'Workspace'} · Yoi</title></svelte:head>
<title>{data.workspace?.display_name ?? 'Workspace'} · Yoi</title>
</svelte:head>
<div class="workspace-home"> <div class="workspace-home">
{#if data.workspace} <header class="home-header">
<div class="home-columns"> <h1>Activity</h1>
<section aria-labelledby="home-work-heading"> <button type="button" onclick={refresh} disabled={refreshing}>{refreshing ? 'Refreshing…' : 'Refresh'}</button>
<h1 id="home-work-heading">Work</h1> </header>
<nav aria-labelledby="home-work-heading"> {#if refreshError}<p class="home-error" role="alert">{refreshError}</p>{/if}
<ul> <div class="home-columns">
{#each resources as resource, index (resource.path)} <DashboardSection id="home-attention" title="Needs attention" empty="No open Merge Requests." result={data.dashboard.reviews} />
<li> <DashboardSection id="home-active" title="In progress" empty="No in-progress or queued tickets." result={data.dashboard.active} />
<a class="resource-link" href={workspaceRoute(data.workspace.workspace_id, resource.path)}> <DashboardSection id="home-recent" title="Recent updates" empty="No done / closed tickets or Objectives yet." result={data.dashboard.recent} />
<span>{resource.label}</span> </div>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>
</a>
{#if index < resources.length - 1}<BevelLine decorative />{/if}
</li>
{/each}
</ul>
</nav>
</section>
<section class="home-settings" aria-labelledby="home-settings-heading">
<h2 id="home-settings-heading">Settings</h2>
<nav aria-labelledby="home-settings-heading">
<ul>
{#each settings as item (item.path)}
<li><a href={workspaceRoute(data.workspace.workspace_id, item.path)}>{item.label}</a></li>
{/each}
</ul>
</nav>
</section>
</div>
{:else if data.workspaceError}
<p class="home-error" role="alert">{data.workspaceError}</p>
<a class="recovery-link" href="/">Choose a workspace</a>
{:else}
<p class="home-loading" role="status">Loading workspace…</p>
{/if}
</div> </div>
<style> <style>
.workspace-home { .workspace-home { container-type: inline-size; min-width: 0; }
container-type: inline-size; .home-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-bottom: var(--space-5); }
min-width: 0; h1 { margin: 0; font-size: var(--font-size-title); line-height: var(--line-height-title); color: var(--text-strong); }
} button { flex: 0 0 auto; padding: var(--space-2) var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-soft); color: var(--text); background: transparent; cursor: pointer; }
.home-columns { button:hover:not(:disabled), button:focus-visible { background: var(--interactive-hover); }
display: grid; button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
gap: var(--space-6); button:active:not(:disabled) { background: var(--interactive-selected); }
align-items: start; button:disabled { opacity: 0.6; cursor: wait; }
} .home-columns { display: grid; gap: var(--space-6); align-items: start; }
section { min-width: 0; } .home-error { color: var(--danger); overflow-wrap: anywhere; }
h1 { @container (min-width: 52rem) {
margin: 0 0 var(--space-2); .home-columns { grid-template-columns: repeat(3, minmax(0, 1fr)); }
color: var(--text-strong);
font-size: var(--font-size-title);
line-height: var(--line-height-title);
}
h2 {
margin: 0 0 var(--space-2);
color: var(--text-muted);
font-size: var(--font-size-body);
line-height: var(--line-height-body);
}
ul { list-style: none; margin: 0; padding: 0; }
a {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
min-height: 44px;
padding-block: var(--space-3);
color: var(--text);
font-size: var(--font-size-body);
line-height: var(--line-height-body);
text-decoration: none;
overflow-wrap: anywhere;
}
.resource-link { font-weight: 600; }
a:hover, a:focus-visible { background: var(--interactive-hover); }
a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
a:active { background: var(--interactive-selected); }
svg {
flex: 0 0 auto;
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
color: var(--text-muted);
}
.home-error, .home-loading { margin: 0; overflow-wrap: anywhere; }
.home-error { color: var(--danger); }
.home-loading { color: var(--text-muted); }
.recovery-link { width: fit-content; text-decoration: underline; }
@container (min-width: 48rem) {
.home-columns { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
.home-settings { padding-top: var(--space-3); }
} }
</style> </style>
@@ -1,5 +1,10 @@
import { loadDashboard } from "$lib/workspace/home/dashboard";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
// Home uses the Workspace identity and permissions already loaded by its layout. export const load: PageLoad = ({ fetch, params, depends }) => {
// Infrastructure inventories belong to Settings and must not delay task navigation. depends("workspace:home");
export const load: PageLoad = ({ params }) => ({ workspaceId: params.workspaceId }); return {
workspaceId: params.workspaceId,
dashboard: loadDashboard(fetch, params.workspaceId),
};
};
@@ -1,175 +1,266 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { cleanup, render, screen, within } from "@testing-library/svelte"; import {
cleanup,
fireEvent,
render,
screen,
within,
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, expect, test, vi } from "vitest";
import type { ComponentProps } from "svelte"; import type { ComponentProps } from "svelte";
import { invalidate } from "$app/navigation";
import Home from "./+page.svelte"; import Home from "./+page.svelte";
import { load } from "./+page"; import { load } from "./+page";
import { parseWorkspaceResponse } from "$lib/workspace/api/workspace-model"; import {
type Dashboard,
type DashboardFeed,
loadDashboard,
recentRows,
} from "$lib/workspace/home/dashboard";
import {
dashboardFixture,
fixtureDetail,
} from "$lib/workspace/home/dashboard.test-fixtures";
vi.mock("$app/navigation", () => ({ invalidate: vi.fn(async () => {}) }));
type Data = ComponentProps<typeof Home>["data"]; type Data = ComponentProps<typeof Home>["data"];
function homeData(owner = true, id = "home-review"): Data { function fixtureFetch(failPath?: string) {
return vi.fn(async (path: RequestInfo | URL, _init?: RequestInit) => {
const url = new URL(String(path), "https://example.test");
const [, id, resource] = url.pathname.match(/^\/api\/w\/([^/]+)(\/.*)$/)!;
return resource === failPath
? Response.json({}, { status: 503 })
: Response.json(
dashboardFixture(resource, url.searchParams, decodeURIComponent(id)),
);
});
}
function data(id = "home-owner", fetchFn: typeof fetch = fixtureFetch()): Data {
return { return {
workspaceId: id, workspaceId: id,
workspace: parseWorkspaceResponse({ workspace: { workspace_id: id, display_name: "Home review" },
workspace_id: id, dashboard: loadDashboard(fetchFn, id),
display_name: "Workspace Home Review", } as unknown as Data;
record_authority: "private-authority",
schema_version: 1,
auth: {
Passkey: {
rp_id: "example.test",
origin: "https://example.test",
public_base_url: "https://example.test",
cookie_name: "fixture",
},
},
permissions: {
manage_repositories: owner,
manage_secrets: owner,
manage_runtimes: owner,
delete_workspace: owner,
},
extension_points: {
store: "private-store",
event_stream: { status: "ready", note: "", diagnostics: [] },
host_worker_bridge: { status: "ready", note: "", diagnostics: [] },
companion_console: { status: "ready", note: "", diagnostics: [] },
},
}),
workspaceError: null,
repositories: null,
repositoriesError: null,
accessibleWorkspaces: [],
workspaceCatalogError: null,
} as Data;
} }
afterEach(cleanup); function mount(value = data()) {
return render(Home, {
test("Home presents daily resources in product order, without internal metadata or repeated identity", () => { props: { params: { workspaceId: value.workspaceId }, data: value },
const { container } = render(Home, {
props: { params: { workspaceId: "home-review" }, data: homeData() },
}); });
const work = screen.getByRole("navigation", { name: "Work" }); }
expect( afterEach(() => {
within(work).getAllByRole("link").map((link) => link.textContent?.trim()), cleanup();
).toEqual([ vi.clearAllMocks();
"Tickets", });
"Objectives",
"Merge Requests", test("Home shows actual reviews, active assignments/blockers, and recent records, not navigation tiles", async () => {
"Memory", mount();
"Workers", expect(await screen.findByText("Changes requested")).toBeTruthy();
]); expect(await screen.findByText("Blocked by T-99")).toBeTruthy();
expect( expect(screen.getByText("W-7")).toBeTruthy();
within(work).getAllByRole("link").map((link) => link.getAttribute("href")), expect(await screen.findByText("Improve daily Workspace operation"))
).toEqual([ .toBeTruthy();
"/w/home-review/tickets", expect(screen.getByText("Review pending")).toBeTruthy();
"/w/home-review/objectives", expect(screen.getByText("Approved")).toBeTruthy();
"/w/home-review/merge-requests", expect(screen.queryByRole("navigation")).toBeNull();
"/w/home-review/memory", expect(screen.queryByRole("heading", { name: "Settings" })).toBeNull();
"/w/home-review/workers", expect(screen.getAllByRole("button")).toHaveLength(1);
]); expect(document.body.textContent).not.toMatch(
expect(container.querySelectorAll("h1")).toHaveLength(1); /internal-worker|internal-merge|internal-runtime|Record authority/,
expect(container.textContent).not.toMatch(
/private-authority|private-store|home-review|Workspace Home Review|Hosts|Record authority|API/,
); );
expect(
container.querySelectorAll(
"button, .card, .workspace-action-card, .state-pill",
),
).toHaveLength(0);
});
test("owner management destinations are separate from daily resources", () => {
render(Home, {
props: { params: { workspaceId: "home-review" }, data: homeData() },
});
const settings = screen.getByRole("navigation", { name: "Settings" });
expect(within(settings).getAllByRole("link").map((link) => link.textContent))
.toEqual([
"Runtimes",
"Configuration Sources",
"Repositories",
"Repository Access",
"Profile Sources",
"Workspace Identity",
]);
});
test("non-owner retains read destinations without owner-only controls", () => {
render(Home, {
props: { params: { workspaceId: "home-review" }, data: homeData(false) },
});
const settings = screen.getByRole("navigation", { name: "Settings" });
expect(within(settings).getAllByRole("link").map((link) => link.textContent))
.toEqual(["Configuration Sources", "Profile Sources"]);
expect(screen.getAllByRole("link")).toHaveLength(7);
});
test.each(
[
["manage_runtimes", "Runtimes"],
["manage_repositories", "Repositories"],
["manage_secrets", "Repository Access"],
["delete_workspace", "Workspace Identity"],
] as const,
)(
"%s alone exposes only its matching management destination",
(permission, label) => {
const data = homeData(false);
data.workspace!.permissions[permission] = true;
render(Home, { props: { params: { workspaceId: "home-review" }, data } });
const settings = screen.getByRole("navigation", { name: "Settings" });
expect(within(settings).getAllByRole("link")).toHaveLength(3);
expect(within(settings).getByRole("link", { name: label })).toBeTruthy();
},
);
test("route reuse updates all destinations and permissions from the new Workspace", async () => {
const view = render(Home, {
props: { params: { workspaceId: "home-review" }, data: homeData() },
});
await view.rerender({ data: homeData(false, "space / 日本") });
expect(screen.queryByRole("link", { name: "Runtimes" })).toBeNull();
for (const link of screen.getAllByRole("link")) { for (const link of screen.getAllByRole("link")) {
expect(link.getAttribute("href")).toMatch( expect(link.getAttribute("href")).toMatch(
/^\/w\/space%20%2F%20%E6%97%A5%E6%9C%AC\//, /^\/w\/home-owner\/(tickets|objectives|merge-requests)\/[^/]+$/,
); );
} }
}); });
test("loading and failure do not invent resources or management permissions", async () => { test("loader only uses bounded read-only supported endpoints and advertises explicit refresh dependency", async () => {
const data = { ...homeData(), workspace: null } as unknown as Data; const fetch = fixtureFetch();
const view = render(Home, { const depends = vi.fn();
props: { params: { workspaceId: "home-review" }, data }, const result = await load(
}); {
expect(screen.getByRole("status").textContent).toBe("Loading workspace…"); fetch,
expect(screen.queryAllByRole("link")).toHaveLength(0); params: { workspaceId: "home-owner" },
await view.rerender({ depends,
data: { } as unknown as Parameters<typeof load>[0],
...data,
workspaceError: "Workspace access unavailable",
} as unknown as Data,
});
expect(screen.getByRole("alert").textContent).toBe(
"Workspace access unavailable",
); );
await Promise.all(Object.values(result!.dashboard));
expect(depends).toHaveBeenCalledWith("workspace:home");
expect(fetch).toHaveBeenCalledTimes(7);
for (const [path, init] of fetch.mock.calls) {
const url = new URL(String(path), "https://example.test");
expect(init?.method ?? "GET").toBe("GET");
expect(init?.signal).toBeTruthy();
expect(url.pathname).not.toMatch(/query|hosts|workers|settings/);
if (!url.pathname.includes("/tickets/T-")) {
expect(url.searchParams.get("limit")).toBe("5");
}
}
expect(fetch.mock.calls.some(([url]) => String(url).includes("states=done")))
.toBe(true);
expect( expect(
screen.getByRole("link", { name: "Choose a workspace" }).getAttribute( fetch.mock.calls.some(([url]) => String(url).includes("states=closed")),
"href", ).toBe(true);
),
).toBe("/");
expect(screen.queryByRole("navigation")).toBeNull();
}); });
test("Home does not fetch infrastructure inventories", async () => { test("recent updates merge independently limited sources in actual update order, not state priority", async () => {
const fetch = vi.fn(); const feed = await data().dashboard.recent;
expect(feed.errors).toEqual([]);
expect(feed.rows.map((row) => row.reference)).toEqual([
"T-105",
"O-12",
"T-103",
"T-104",
]);
const rows = Array.from(
{ length: 10 },
(_, index) => ({
...feed.rows[0],
key: String(index),
updatedAt: index === 9 ? null : `2026-01-01T0${index}:00:00Z`,
}),
);
expect( expect(
await load( recentRows([...rows, { ...rows[8], updatedAt: rows[0].updatedAt }]).map((
{ row,
params: { workspaceId: "home-review" }, ) => row.key),
fetch, ).toEqual([
} as unknown as Parameters<typeof load>[0], "8",
), "7",
).toEqual({ workspaceId: "home-review" }); "6",
expect(fetch).not.toHaveBeenCalled(); "5",
"4",
]);
});
test("partial failure is not an empty/all-clear state and leaves sibling sections usable", async () => {
mount(data("home-owner", fixtureFetch("/merge-requests")));
expect((await screen.findByRole("alert")).textContent).toContain(
"Merge Requests",
);
expect(screen.queryByText("No open Merge Requests.")).toBeNull();
expect(await screen.findByText("Blocked by T-99")).toBeTruthy();
expect(await screen.findByText("Improve daily Workspace operation"))
.toBeTruthy();
});
test("one recent source failure preserves the other records and does not assert an empty feed", async () => {
const value = data("home-owner", fixtureFetch("/objectives"));
mount(value);
expect((await value.dashboard.recent).rows.map((row) => row.reference))
.toEqual(["T-105", "T-103", "T-104"]);
expect((await screen.findByRole("alert")).textContent).toContain(
"Objectives",
);
expect(screen.queryByText("No done / closed tickets or Objectives yet."))
.toBeNull();
});
test("a failed Ticket detail preserves the summary without inventing no-assignee or unblocked state", async () => {
const feed = await data("home-owner", fixtureFetch("/tickets/T-101"))
.dashboard.active;
expect(feed.rows[0].detail).toBe("Assignment and blockers unavailable");
expect(feed.rows[0].workerKey).toBeUndefined();
expect(feed.rows[0].attention).toBeUndefined();
expect(feed.errors[0]).toContain("T-101");
});
test("a concurrent Ticket transition leaves the active scope with a refresh notice", async () => {
const fallback = fixtureFetch();
const fetchFn: typeof fetch = (path, init) =>
String(path).endsWith("/tickets/T-101")
? Promise.resolve(Response.json({ ...fixtureDetail(), state: "done" }))
: fallback(path, init);
const feed = await data("home-owner", fetchFn).dashboard.active;
expect(feed.rows.map((row) => row.reference)).toEqual(["T-102"]);
expect(feed.notice).toContain("Work changed");
});
test("empty resources have scoped empty states and no fabricated totals", async () => {
mount(data("home-empty"));
expect(await screen.findByText("No open Merge Requests.")).toBeTruthy();
expect(await screen.findByText("No in-progress or queued tickets."))
.toBeTruthy();
expect(await screen.findByText("No done / closed tickets or Objectives yet."))
.toBeTruthy();
expect(screen.queryAllByRole("link")).toHaveLength(0);
});
test("slow review loading does not hide the other completed sections", async () => {
const value = data();
value.dashboard.reviews = new Promise(() => {});
mount(value);
expect(
within(screen.getByRole("region", { name: "Needs attention" })).getByRole(
"status",
).textContent,
).toContain("Loading needs attention");
expect(await screen.findByText("Blocked by T-99")).toBeTruthy();
expect(await screen.findByText("Improve daily Workspace operation"))
.toBeTruthy();
});
test("route reuse drops pending old Workspace results and updates scoped links", async () => {
let finish!: (feed: DashboardFeed) => void;
const value = data();
value.dashboard.reviews = new Promise((resolve) => {
finish = resolve;
});
const view = mount(value);
await view.rerender({ data: data("space / 日本") });
await screen.findByText("Changes requested");
finish({ rows: [], errors: ["OLD WORKSPACE ERROR"] });
await Promise.resolve();
expect(screen.queryByText("OLD WORKSPACE ERROR")).toBeNull();
for (const link of screen.getAllByRole("link")) {
expect(link.getAttribute("href")).toContain(
"/w/space%20%2F%20%E6%97%A5%E6%9C%AC/",
);
}
});
test("malformed review responses are unavailable rather than empty", async () => {
const fallback = fixtureFetch();
const fetchFn: typeof fetch = (path, init) =>
String(path).includes("/merge-requests?")
? Promise.resolve(Response.json({ items: "invalid" }))
: fallback(path, init);
const feed = await data("home-owner", fetchFn).dashboard.reviews;
expect(feed.rows).toEqual([]);
expect(feed.errors).toEqual([
"Merge Requests unavailable. Refresh to retry.",
]);
});
test("unknown review states are preserved and branch text never becomes HTML", async () => {
const fallback = fixtureFetch();
const fetchFn: typeof fetch = async (path, init) => {
const response = await fallback(path, init);
if (!String(path).includes("/merge-requests?")) return response;
const body = await response.json();
body.items[0].summary.review_status = "future_status";
body.items[0].summary.selector_from = '<img src=x onerror="alert(1)">';
return Response.json(body);
};
const view = mount(data("home-owner", fetchFn));
expect(await screen.findByText("future_status")).toBeTruthy();
expect(view.container.querySelector("img")).toBeNull();
expect(await screen.findByText(/<img src=x/)).toBeTruthy();
});
test("unavailable timestamps are not presented as current activity", async () => {
const value = data();
const feed = await value.dashboard.recent;
feed.rows[0].updatedAt = "invalid";
value.dashboard.recent = Promise.resolve(feed);
mount(value);
expect(await screen.findByText("Update time unavailable")).toBeTruthy();
});
test("Refresh invalidates only the Home data dependency", async () => {
const value = data();
mount(value);
await Promise.all(Object.values(value.dashboard));
await fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
expect(invalidate).toHaveBeenCalledWith("workspace:home");
}); });