fix: rebuild workspace home around daily navigation
This commit is contained in:
@@ -181,6 +181,20 @@ is ignored by Git, while authentication state remains outside the repository. `c
|
|||||||
`target/web-ux` when `--output` is omitted. Keep a bundle outside Git or publish it through the
|
`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.
|
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
|
## Adding a scenario
|
||||||
|
|
||||||
1. Name the concrete user task and expected data state; do not write “looks correct”.
|
1. Name the concrete user task and expected data state; do not write “looks correct”.
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
|
||||||
|
|
||||||
|
const port = Number(Deno.args[0]);
|
||||||
|
const buildRoot = Deno.args[1];
|
||||||
|
if (!Number.isInteger(port) || !buildRoot) throw new Error("usage: server <port> <build-root>");
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
"home-owner": "Workspace Home Review",
|
||||||
|
"home-member": "Shared Workspace",
|
||||||
|
"home-long":
|
||||||
|
"Workspace with a long name — international documentation and distributed development",
|
||||||
|
};
|
||||||
|
const json = (value: unknown, status = 200) => Response.json(value, { status });
|
||||||
|
const mime: Record<string, string> = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".js": "text/javascript",
|
||||||
|
".css": "text/css",
|
||||||
|
".json": "application/json",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".png": "image/png",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
};
|
||||||
|
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.pathname === "/health") return new Response("ok");
|
||||||
|
if (url.pathname === "/api/workspaces") {
|
||||||
|
return json(
|
||||||
|
Object.entries(names).map(([workspace_id, display_name]) => ({
|
||||||
|
workspace_id,
|
||||||
|
display_name,
|
||||||
|
owner_account_id: "fixture-owner",
|
||||||
|
state: "active",
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
updated_at: "2026-01-02T00:00:00Z",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const match = url.pathname.match(/^\/api\/w\/([^/]+)(\/.*)$/);
|
||||||
|
if (match) {
|
||||||
|
const [, workspaceId, path] = match;
|
||||||
|
const owner = workspaceId !== "home-member";
|
||||||
|
if (path === "/workspace") {
|
||||||
|
return json({
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
display_name: names[workspaceId],
|
||||||
|
record_authority: "fixture",
|
||||||
|
schema_version: 1,
|
||||||
|
auth: {
|
||||||
|
Passkey: {
|
||||||
|
rp_id: "127.0.0.1",
|
||||||
|
origin: url.origin,
|
||||||
|
public_base_url: url.origin,
|
||||||
|
cookie_name: "fixture",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
manage_repositories: owner,
|
||||||
|
manage_secrets: owner,
|
||||||
|
manage_runtimes: owner,
|
||||||
|
delete_workspace: owner,
|
||||||
|
},
|
||||||
|
extension_points: {
|
||||||
|
store: "fixture",
|
||||||
|
event_stream: { status: "ready", note: "fixture", diagnostics: [] },
|
||||||
|
host_worker_bridge: { status: "ready", note: "fixture", diagnostics: [] },
|
||||||
|
companion_console: { status: "ready", note: "fixture", diagnostics: [] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (path === "/repositories") {
|
||||||
|
return json({ workspace_id: workspaceId, items: [], source: "fixture", diagnostics: [] });
|
||||||
|
}
|
||||||
|
if (path === "/working-directories") {
|
||||||
|
return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
|
||||||
|
}
|
||||||
|
// Retained only for before-change captures. The redesigned Home must not request Hosts.
|
||||||
|
if (path === "/hosts") {
|
||||||
|
return json({
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
limit: 100,
|
||||||
|
source: "fixture",
|
||||||
|
diagnostics: [],
|
||||||
|
items: workspaceId === "home-member" ? [] : [{
|
||||||
|
runtime_id: "fixture-runtime",
|
||||||
|
host_id: "fixture-host",
|
||||||
|
label: "Development host",
|
||||||
|
kind: "local",
|
||||||
|
status: "available",
|
||||||
|
observed_at: "2026-01-02T00:00:00Z",
|
||||||
|
last_seen_at: null,
|
||||||
|
os: "linux",
|
||||||
|
arch: "x86_64",
|
||||||
|
diagnostics: [],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (path === "/memory") {
|
||||||
|
return json({
|
||||||
|
body_md: "# Home navigation destination",
|
||||||
|
bytes: 29,
|
||||||
|
created_at: "2026-01-01T00:00:00Z",
|
||||||
|
updated_at: "2026-01-02T00:00:00Z",
|
||||||
|
record_source: "fixture",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (path === "/protocol/ws" && request.headers.get("upgrade") === "websocket") {
|
||||||
|
const { socket, response } = Deno.upgradeWebSocket(request);
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
const frame = JSON.parse(String(event.data));
|
||||||
|
if (frame?.message?.method !== "subscribe_events") return;
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
protocol_version: 1,
|
||||||
|
frame: "response",
|
||||||
|
message: {
|
||||||
|
result: "subscribed",
|
||||||
|
payload: {
|
||||||
|
request_id: frame.message.params.request_id,
|
||||||
|
subscription_id: "fixture-workers",
|
||||||
|
selector: { topic: "workspace_workers" },
|
||||||
|
snapshot_revision: 1,
|
||||||
|
snapshot: { topic: "workers", data: { workers: [] } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
return json({ error: "Unexpected fixture API request" }, 404);
|
||||||
|
}
|
||||||
|
const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html");
|
||||||
|
if (relative.startsWith("..")) return new Response("not found", { status: 404 });
|
||||||
|
let file = join(buildRoot, relative);
|
||||||
|
try {
|
||||||
|
if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html");
|
||||||
|
return new Response(await Deno.readFile(file), {
|
||||||
|
headers: { "content-type": mime[extname(file)] ?? "application/octet-stream" },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return new Response(await Deno.readFile(join(buildRoot, "index.html")), {
|
||||||
|
headers: { "content-type": mime[".html"] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/// <reference lib="dom" />
|
||||||
|
import { assert, assertEquals } from "@std/assert";
|
||||||
|
import { dirname, fromFileUrl, join } from "@std/path";
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const root = join(dirname(fromFileUrl(import.meta.url)), "../../..");
|
||||||
|
|
||||||
|
Deno.test("production Home preserves scoped navigation, permission boundaries, responsive layout, and keyboard access", async () => {
|
||||||
|
const build = await new Deno.Command(Deno.execPath(), {
|
||||||
|
args: ["task", "build"],
|
||||||
|
cwd: join(root, "web/workspace"),
|
||||||
|
stdout: "piped",
|
||||||
|
stderr: "piped",
|
||||||
|
}).output();
|
||||||
|
assert(build.success, new TextDecoder().decode(build.stderr));
|
||||||
|
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||||
|
const port = (listener.addr as Deno.NetAddr).port;
|
||||||
|
listener.close();
|
||||||
|
const url = `http://127.0.0.1:${port}`;
|
||||||
|
const server = new Deno.Command(Deno.execPath(), {
|
||||||
|
args: [
|
||||||
|
"run",
|
||||||
|
"--allow-net",
|
||||||
|
"--allow-read",
|
||||||
|
join(root, "tools/web-ux/browser-tests/workspace_home_fixture_server.ts"),
|
||||||
|
String(port),
|
||||||
|
join(root, "web/workspace/build"),
|
||||||
|
],
|
||||||
|
stdout: "null",
|
||||||
|
stderr: "null",
|
||||||
|
}).spawn();
|
||||||
|
try {
|
||||||
|
let ready = false;
|
||||||
|
for (let n = 0; n < 100; n++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${url}/health`);
|
||||||
|
await response.body?.cancel();
|
||||||
|
if (response.ok) {
|
||||||
|
ready = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch { /* owned fixture is starting */ }
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
assert(ready, "fixture did not start");
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
for (const colorScheme of ["light", "dark"] as const) {
|
||||||
|
for (const width of [1440, 768, 390, 320]) {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width, height: 900 },
|
||||||
|
colorScheme,
|
||||||
|
reducedMotion: "reduce",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors: string[] = [];
|
||||||
|
const requests: string[] = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(String(error)));
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error") errors.push(message.text());
|
||||||
|
});
|
||||||
|
page.on("requestfailed", (request) => errors.push(request.url()));
|
||||||
|
page.on("request", (request) => requests.push(request.url()));
|
||||||
|
for (const workspace of ["home-owner", "home-member", "home-long"]) {
|
||||||
|
await page.goto(`${url}/w/${workspace}`);
|
||||||
|
const work = page.getByRole("navigation", { name: "Work", exact: true });
|
||||||
|
await work.waitFor();
|
||||||
|
assertEquals(
|
||||||
|
(await work.getByRole("link").allTextContents()).map((text) => text.trim()),
|
||||||
|
["Tickets", "Objectives", "Merge Requests", "Memory", "Workers"],
|
||||||
|
);
|
||||||
|
const settings = page.locator("main").getByRole("navigation", {
|
||||||
|
name: "Settings",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
assertEquals(
|
||||||
|
await settings.getByRole("link").count(),
|
||||||
|
workspace === "home-member" ? 2 : 6,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
await settings.getByRole("link", { name: "Runtimes", exact: true }).count(),
|
||||||
|
workspace === "home-member" ? 0 : 1,
|
||||||
|
);
|
||||||
|
assertEquals(await page.locator("main h1").allTextContents(), ["Work"]);
|
||||||
|
const geometry = await page.locator(".workspace-home").evaluate((home) => {
|
||||||
|
const main = home.closest("main")!;
|
||||||
|
const work = home.querySelector("section")!.getBoundingClientRect();
|
||||||
|
const settings = home.querySelector(".home-settings")!.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
pageOverflow: document.documentElement.scrollWidth > innerWidth,
|
||||||
|
mainOverflow: main.scrollWidth > main.clientWidth,
|
||||||
|
firstLinkBottom: home.querySelector("a")!.getBoundingClientRect().bottom,
|
||||||
|
sideBySide: settings.left > work.right,
|
||||||
|
stacked: settings.top >= work.bottom,
|
||||||
|
smallTargets: [...home.querySelectorAll("a")].filter((link) =>
|
||||||
|
link.getBoundingClientRect().height < 44
|
||||||
|
).length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assertEquals(geometry.pageOverflow, false);
|
||||||
|
assertEquals(geometry.mainOverflow, false);
|
||||||
|
assert(geometry.firstLinkBottom < 900);
|
||||||
|
assertEquals(geometry.smallTargets, 0);
|
||||||
|
assert(width === 1440 ? geometry.sideBySide : geometry.stacked);
|
||||||
|
const first = work.getByRole("link", { name: "Tickets", exact: true });
|
||||||
|
await first.focus();
|
||||||
|
const outline = await first.evaluate((link) => getComputedStyle(link).outlineStyle);
|
||||||
|
assertEquals(outline, "solid");
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
assertEquals(
|
||||||
|
await page.evaluate(() => document.activeElement?.textContent?.trim()),
|
||||||
|
"Objectives",
|
||||||
|
);
|
||||||
|
for (const link of await page.locator("main a").all()) {
|
||||||
|
assert((await link.getAttribute("href"))!.startsWith(`/w/${workspace}/`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const memory = page.getByRole("navigation", { name: "Work", exact: true }).getByRole(
|
||||||
|
"link",
|
||||||
|
{ name: "Memory", exact: true },
|
||||||
|
);
|
||||||
|
await memory.focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await page.waitForURL(`**/w/home-long/memory`);
|
||||||
|
await page.getByRole("heading", { name: "Home navigation destination" }).waitFor();
|
||||||
|
assertEquals(
|
||||||
|
requests.some((request) => new URL(request).pathname.endsWith("/hosts")),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assertEquals(errors, []);
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
server.kill("SIGTERM");
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
||||||
|
}
|
||||||
|
await server.status;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
|
"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",
|
"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": "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": {
|
"fmt": {
|
||||||
"lineWidth": 100,
|
"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(
|
assert(
|
||||||
workspacePage.includes("ticketsHref") &&
|
workspacePage.includes("path: '/tickets'") &&
|
||||||
workspacePage.includes("runtimeSettingsHref") &&
|
workspacePage.includes("path: '/settings/runtimes'") &&
|
||||||
workspacePage.includes("workersHref") &&
|
workspacePage.includes("permissions.manage_runtimes === true") &&
|
||||||
workspacePage.includes("workspaceRoute(workspaceId, '/tickets')") &&
|
workspacePage.includes("path: '/workers'") &&
|
||||||
workspacePage.includes(
|
workspacePage.includes("workspaceRoute(data.workspace.workspace_id, resource.path)"),
|
||||||
"workspaceRoute(workspaceId, '/settings/runtimes')",
|
"Home should link to daily resources and permission-gated Settings without owning their inventories",
|
||||||
) &&
|
|
||||||
workspacePage.includes("workspaceRoute(workspaceId, '/workers')"),
|
|
||||||
"top workspace page should link to Tickets, Runtimes under Settings, and the Workers page",
|
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!workspacePage.includes("workerConsoleHref") &&
|
!workspacePage.includes("workerConsoleHref") &&
|
||||||
|
|||||||
@@ -58,39 +58,6 @@
|
|||||||
margin-top: var(--space-1);
|
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 {
|
.runtime-card {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,103 +1,126 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||||
|
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
let workspaceId = $derived(data.workspace?.workspace_id ?? data.workspaceId);
|
const resources = [
|
||||||
let ticketsHref = $derived(workspaceRoute(workspaceId, '/tickets'));
|
{ label: 'Tickets', path: '/tickets' },
|
||||||
let runtimeSettingsHref = $derived(workspaceRoute(workspaceId, '/settings/runtimes'));
|
{ label: 'Objectives', path: '/objectives' },
|
||||||
let workersHref = $derived(workspaceRoute(workspaceId, '/workers'));
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Yoi Workspace Control Plane</title>
|
<title>{data.workspace?.display_name ?? 'Workspace'} · Yoi</title>
|
||||||
<meta name="description" content="Local single-workspace Yoi control plane bootstrap" />
|
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<section class="card">
|
<div class="workspace-home">
|
||||||
<h2>Workspace</h2>
|
|
||||||
{#if data.workspace}
|
{#if data.workspace}
|
||||||
<dl>
|
<div class="home-columns">
|
||||||
<div>
|
<section aria-labelledby="home-work-heading">
|
||||||
<dt>ID</dt>
|
<h1 id="home-work-heading">Work</h1>
|
||||||
<dd>{data.workspace.workspace_id}</dd>
|
<nav aria-labelledby="home-work-heading">
|
||||||
</div>
|
<ul>
|
||||||
<div>
|
{#each resources as resource, index (resource.path)}
|
||||||
<dt>Name</dt>
|
<li>
|
||||||
<dd>{data.workspace.display_name}</dd>
|
<a class="resource-link" href={workspaceRoute(data.workspace.workspace_id, resource.path)}>
|
||||||
</div>
|
<span>{resource.label}</span>
|
||||||
<div>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>
|
||||||
<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>
|
|
||||||
{:else if data.workspaceError}
|
|
||||||
<p class="error">{data.workspaceError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for <code>/api/workspace</code>…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<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>
|
||||||
<a class="workspace-action-card" href={runtimeSettingsHref}>
|
{#if index < resources.length - 1}<BevelLine decorative />{/if}
|
||||||
<span>Runtimes</span>
|
</li>
|
||||||
<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}
|
{/each}
|
||||||
</div>
|
</ul>
|
||||||
{/if}
|
</nav>
|
||||||
{:else if data.hostsError}
|
|
||||||
<p class="error">{data.hostsError}</p>
|
|
||||||
{:else}
|
|
||||||
<p>Waiting for <code>/api/hosts</code>…</p>
|
|
||||||
{/if}
|
|
||||||
</section>
|
</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>
|
||||||
|
|
||||||
|
<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";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
const HOST_API_LOAD_POLICY = {
|
// Home uses the Workspace identity and permissions already loaded by its layout.
|
||||||
diagnosticLabel: "Host API",
|
// Infrastructure inventories belong to Settings and must not delay task navigation.
|
||||||
maxResponseBytes: 4 * 1024 * 1024,
|
export const load: PageLoad = ({ params }) => ({ workspaceId: params.workspaceId });
|
||||||
} 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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user