diff --git a/tools/web-ux/README.md b/tools/web-ux/README.md index 63b7be793..ebac1c947 100644 --- a/tools/web-ux/README.md +++ b/tools/web-ux/README.md @@ -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”. diff --git a/tools/web-ux/browser-tests/workspace_home_fixture_server.ts b/tools/web-ux/browser-tests/workspace_home_fixture_server.ts new file mode 100644 index 000000000..71a5e1b21 --- /dev/null +++ b/tools/web-ux/browser-tests/workspace_home_fixture_server.ts @@ -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 "); +const names: Record = { + "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 = { + ".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"] }, + }); + } +}); diff --git a/tools/web-ux/browser-tests/workspace_home_shell_test.ts b/tools/web-ux/browser-tests/workspace_home_shell_test.ts new file mode 100644 index 000000000..1f2b27a78 --- /dev/null +++ b/tools/web-ux/browser-tests/workspace_home_shell_test.ts @@ -0,0 +1,148 @@ +/// +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; + } +}); diff --git a/tools/web-ux/deno.json b/tools/web-ux/deno.json index 48fe75c03..cd003fc81 100644 --- a/tools/web-ux/deno.json +++ b/tools/web-ux/deno.json @@ -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, diff --git a/tools/web-ux/scenarios/workspace-home.json b/tools/web-ux/scenarios/workspace-home.json new file mode 100644 index 000000000..5e6db20cf --- /dev/null +++ b/tools/web-ux/scenarios/workspace-home.json @@ -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" + }] +} diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index f69f9b4a3..81a826029 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -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") && diff --git a/web/workspace/src/lib/workspace/styles/workspace-pages.css b/web/workspace/src/lib/workspace/styles/workspace-pages.css index d86012a44..b1ca31cac 100644 --- a/web/workspace/src/lib/workspace/styles/workspace-pages.css +++ b/web/workspace/src/lib/workspace/styles/workspace-pages.css @@ -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; } diff --git a/web/workspace/src/routes/w/[workspaceId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/+page.svelte index 0f8431623..b3924765c 100644 --- a/web/workspace/src/routes/w/[workspaceId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/+page.svelte @@ -1,103 +1,126 @@ - Yoi Workspace Control Plane - + {data.workspace?.display_name ?? 'Workspace'} · Yoi -
-

Workspace

+
{#if data.workspace} -
-
-
ID
-
{data.workspace.workspace_id}
-
-
-
Name
-
{data.workspace.display_name}
-
-
-
Record authority
-
{data.workspace.record_authority}
-
-
-
Host / Worker bridge
-
{data.workspace.extension_points.host_worker_bridge.status}
-
-
+
+
+

Work

+ +
+
+

Settings

+ +
+
{:else if data.workspaceError} -

{data.workspaceError}

+ + Choose a workspace {:else} -

Waiting for /api/workspace…

+

Loading workspace…

{/if} -
+ -
- - Tickets - Browse workspace tickets - Read typed Ticket records - - - Runtimes - Open admin Runtimes - {data.hosts?.items.length ?? 0} host{(data.hosts?.items.length ?? 0) === 1 ? '' : 's'} visible - - - Workers - Open worker list - Inspect status and attach to consoles - -
- -
-

Hosts

- {#if data.hosts} - {#if data.hosts.items.length === 0} -

No local Hosts are visible.

- {:else} -
- {#each data.hosts.items as host} -
-
- {host.label} - {host.status} -
-
-
-
ID
-
{host.host_id}
-
-
-
Kind
-
{host.kind}
-
-
-
Runtime
-
{host.runtime_id}
-
-
-
Platform
-
{host.os} / {host.arch}
-
-
-
- {/each} -
- {/if} - {:else if data.hostsError} -

{data.hostsError}

- {:else} -

Waiting for /api/hosts…

- {/if} -
+ diff --git a/web/workspace/src/routes/w/[workspaceId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/+page.ts index daf3fc39f..182ddd239 100644 --- a/web/workspace/src/routes/w/[workspaceId]/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/+page.ts @@ -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( - 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 }); diff --git a/web/workspace/src/routes/w/[workspaceId]/home.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/home.browser.spec.ts new file mode 100644 index 000000000..985de6aed --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/home.browser.spec.ts @@ -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["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[0], + ), + ).toEqual({ workspaceId: "home-review" }); + expect(fetch).not.toHaveBeenCalled(); +});