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"
}]
}
@@ -245,15 +245,12 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
);
assert(
workspacePage.includes("ticketsHref") &&
workspacePage.includes("runtimeSettingsHref") &&
workspacePage.includes("workersHref") &&
workspacePage.includes("workspaceRoute(workspaceId, '/tickets')") &&
workspacePage.includes(
"workspaceRoute(workspaceId, '/settings/runtimes')",
) &&
workspacePage.includes("workspaceRoute(workspaceId, '/workers')"),
"top workspace page should link to Tickets, Runtimes under Settings, and the Workers page",
workspacePage.includes("path: '/tickets'") &&
workspacePage.includes("path: '/settings/runtimes'") &&
workspacePage.includes("permissions.manage_runtimes === true") &&
workspacePage.includes("path: '/workers'") &&
workspacePage.includes("workspaceRoute(data.workspace.workspace_id, resource.path)"),
"Home should link to daily resources and permission-gated Settings without owning their inventories",
);
assert(
!workspacePage.includes("workerConsoleHref") &&
@@ -58,39 +58,6 @@
margin-top: var(--space-1);
}
.workspace-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.workspace-action-card {
display: grid;
gap: var(--space-1);
padding: var(--space-4);
border: 1px solid var(--line);
background: var(--bg-raised);
color: inherit;
text-decoration: none;
}
.workspace-action-card:hover,
.workspace-action-card:focus-visible {
border-color: var(--accent);
}
.workspace-action-card span,
.workspace-action-card small {
color: var(--text-muted);
}
.workspace-action-card span {
font-size: var(--font-size-compact);
}
.workspace-action-card strong {
color: var(--text-strong);
}
@media (max-width: 720px) {
.workspace-actions {
grid-template-columns: 1fr;
}
}
.runtime-card {
padding: 0;
}
@@ -1,103 +1,126 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http';
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? data.workspaceId);
let ticketsHref = $derived(workspaceRoute(workspaceId, '/tickets'));
let runtimeSettingsHref = $derived(workspaceRoute(workspaceId, '/settings/runtimes'));
let workersHref = $derived(workspaceRoute(workspaceId, '/workers'));
const resources = [
{ label: 'Tickets', path: '/tickets' },
{ label: 'Objectives', path: '/objectives' },
{ label: 'Merge Requests', path: '/merge-requests' },
{ label: 'Memory', path: '/memory' },
{ label: 'Workers', path: '/workers' },
];
const settings = $derived([
{ label: 'Runtimes', path: '/settings/runtimes', visible: data.workspace?.permissions.manage_runtimes === true },
{ label: 'Configuration Sources', path: '/settings/configuration', visible: true },
{ label: 'Repositories', path: '/settings/repositories', visible: data.workspace?.permissions.manage_repositories === true },
{ label: 'Repository Access', path: '/settings/repository-access', visible: data.workspace?.permissions.manage_secrets === true },
{ label: 'Profile Sources', path: '/settings/profiles', visible: true },
{ label: 'Workspace Identity', path: '/settings', visible: data.workspace?.permissions.delete_workspace === true },
].filter((item) => item.visible));
</script>
<svelte:head>
<title>Yoi Workspace Control Plane</title>
<meta name="description" content="Local single-workspace Yoi control plane bootstrap" />
<title>{data.workspace?.display_name ?? 'Workspace'} · Yoi</title>
</svelte:head>
<section class="card">
<h2>Workspace</h2>
<div class="workspace-home">
{#if data.workspace}
<dl>
<div>
<dt>ID</dt>
<dd>{data.workspace.workspace_id}</dd>
</div>
<div>
<dt>Name</dt>
<dd>{data.workspace.display_name}</dd>
</div>
<div>
<dt>Record authority</dt>
<dd>{data.workspace.record_authority}</dd>
</div>
<div>
<dt>Host / Worker bridge</dt>
<dd>{data.workspace.extension_points.host_worker_bridge.status}</dd>
</div>
</dl>
<div class="home-columns">
<section aria-labelledby="home-work-heading">
<h1 id="home-work-heading">Work</h1>
<nav aria-labelledby="home-work-heading">
<ul>
{#each resources as resource, index (resource.path)}
<li>
<a class="resource-link" href={workspaceRoute(data.workspace.workspace_id, resource.path)}>
<span>{resource.label}</span>
<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="error">{data.workspaceError}</p>
<p class="home-error" role="alert">{data.workspaceError}</p>
<a class="recovery-link" href="/">Choose a workspace</a>
{:else}
<p>Waiting for <code>/api/workspace</code>…</p>
<p class="home-loading" role="status">Loading workspace…</p>
{/if}
</section>
</div>
<section class="workspace-actions" aria-label="Workspace sections">
<a class="workspace-action-card" href={ticketsHref}>
<span>Tickets</span>
<strong>Browse workspace tickets</strong>
<small>Read typed Ticket records</small>
</a>
<a class="workspace-action-card" href={runtimeSettingsHref}>
<span>Runtimes</span>
<strong>Open admin Runtimes</strong>
<small>{data.hosts?.items.length ?? 0} host{(data.hosts?.items.length ?? 0) === 1 ? '' : 's'} visible</small>
</a>
<a class="workspace-action-card" href={workersHref}>
<span>Workers</span>
<strong>Open worker list</strong>
<small>Inspect status and attach to consoles</small>
</a>
</section>
<section class="card">
<h2>Hosts</h2>
{#if data.hosts}
{#if data.hosts.items.length === 0}
<p>No local Hosts are visible.</p>
{:else}
<div class="stack">
{#each data.hosts.items as host}
<article class="runtime-card">
<div class="runtime-heading">
<strong>{host.label}</strong>
<span class:warn={host.status !== 'available'}>{host.status}</span>
</div>
<dl>
<div>
<dt>ID</dt>
<dd><code>{host.host_id}</code></dd>
</div>
<div>
<dt>Kind</dt>
<dd>{host.kind}</dd>
</div>
<div>
<dt>Runtime</dt>
<dd><code>{host.runtime_id}</code></dd>
</div>
<div>
<dt>Platform</dt>
<dd>{host.os} / {host.arch}</dd>
</div>
</dl>
</article>
{/each}
</div>
{/if}
{:else if data.hostsError}
<p class="error">{data.hostsError}</p>
{:else}
<p>Waiting for <code>/api/hosts</code>…</p>
{/if}
</section>
<style>
.workspace-home {
container-type: inline-size;
min-width: 0;
}
.home-columns {
display: grid;
gap: var(--space-6);
align-items: start;
}
section { min-width: 0; }
h1 {
margin: 0 0 var(--space-2);
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>
@@ -1,28 +1,5 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
parseHostListResponse,
type HostListResponse,
} from "$lib/workspace/api/workspace-model";
import type { PageLoad } from "./$types";
const HOST_API_LOAD_POLICY = {
diagnosticLabel: "Host API",
maxResponseBytes: 4 * 1024 * 1024,
} as const;
export const load: PageLoad = async ({ fetch, params }) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
const hosts = await loadJson<HostListResponse>(
fetch,
apiPath("/hosts"),
undefined,
parseHostListResponse,
HOST_API_LOAD_POLICY,
);
return {
workspaceId: params.workspaceId,
hosts: hosts.data,
hostsError: hosts.error,
};
};
// Home uses the Workspace identity and permissions already loaded by its layout.
// Infrastructure inventories belong to Settings and must not delay task navigation.
export const load: PageLoad = ({ params }) => ({ workspaceId: params.workspaceId });
@@ -0,0 +1,175 @@
// @vitest-environment happy-dom
import { cleanup, render, screen, within } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import type { ComponentProps } from "svelte";
import Home from "./+page.svelte";
import { load } from "./+page";
import { parseWorkspaceResponse } from "$lib/workspace/api/workspace-model";
type Data = ComponentProps<typeof Home>["data"];
function homeData(owner = true, id = "home-review"): Data {
return {
workspaceId: id,
workspace: parseWorkspaceResponse({
workspace_id: id,
display_name: "Workspace Home Review",
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);
test("Home presents daily resources in product order, without internal metadata or repeated identity", () => {
const { container } = render(Home, {
props: { params: { workspaceId: "home-review" }, data: homeData() },
});
const work = screen.getByRole("navigation", { name: "Work" });
expect(
within(work).getAllByRole("link").map((link) => link.textContent?.trim()),
).toEqual([
"Tickets",
"Objectives",
"Merge Requests",
"Memory",
"Workers",
]);
expect(
within(work).getAllByRole("link").map((link) => link.getAttribute("href")),
).toEqual([
"/w/home-review/tickets",
"/w/home-review/objectives",
"/w/home-review/merge-requests",
"/w/home-review/memory",
"/w/home-review/workers",
]);
expect(container.querySelectorAll("h1")).toHaveLength(1);
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")) {
expect(link.getAttribute("href")).toMatch(
/^\/w\/space%20%2F%20%E6%97%A5%E6%9C%AC\//,
);
}
});
test("loading and failure do not invent resources or management permissions", async () => {
const data = { ...homeData(), workspace: null } as unknown as Data;
const view = render(Home, {
props: { params: { workspaceId: "home-review" }, data },
});
expect(screen.getByRole("status").textContent).toBe("Loading workspace…");
expect(screen.queryAllByRole("link")).toHaveLength(0);
await view.rerender({
data: {
...data,
workspaceError: "Workspace access unavailable",
} as unknown as Data,
});
expect(screen.getByRole("alert").textContent).toBe(
"Workspace access unavailable",
);
expect(
screen.getByRole("link", { name: "Choose a workspace" }).getAttribute(
"href",
),
).toBe("/");
expect(screen.queryByRole("navigation")).toBeNull();
});
test("Home does not fetch infrastructure inventories", async () => {
const fetch = vi.fn();
expect(
await load(
{
params: { workspaceId: "home-review" },
fetch,
} as unknown as Parameters<typeof load>[0],
),
).toEqual({ workspaceId: "home-review" });
expect(fetch).not.toHaveBeenCalled();
});