From a060ffada8c4d2ec2bca9dfdbe874ae383327d09 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 16 Oct 2026 10:48:51 +0900 Subject: [PATCH] feat: add public entry and authenticated workspace dashboard --- tools/web-ux/scenarios/public-entry-dark.json | 372 ++++++++++++++++++ .../web-ux/scenarios/public-entry-light.json | 372 ++++++++++++++++++ web/workspace/deno.json | 3 +- .../workspace/auth/AccountNavigation.svelte | 16 + .../src/lib/workspace/auth/AuthEntry.svelte | 14 +- .../workspace/auth/RegistrationLink.svelte | 40 ++ .../src/lib/workspace/auth/auth-page.css | 3 + .../src/lib/workspace/auth/routes.test.ts | 10 +- .../src/lib/workspace/auth/routes.ts | 6 +- .../sidebar/GlobalNavSections.svelte | 3 +- .../sidebar/WorkspaceSwitcher.svelte | 2 +- .../workspace/styles/workspace-catalog.css | 245 ------------ web/workspace/src/routes/+layout.svelte | 4 +- web/workspace/src/routes/+layout.ts | 8 +- web/workspace/src/routes/+page.svelte | 55 ++- web/workspace/src/routes/+page.ts | 17 - web/workspace/src/routes/account/+page.svelte | 2 +- .../routes/account/account.browser.spec.ts | 31 +- .../src/routes/dashboard/+page.svelte | 44 +++ .../WorkspaceCatalog.svelte} | 94 +++-- .../src/routes/entry.browser.spec.ts | 240 +++++++++++ .../src/routes/sidebar-fold.browser.spec.ts | 2 +- .../routes/workspace-catalog.browser.spec.ts | 34 +- .../auth-browser/auth-shell.browser.test.ts | 12 +- .../auth-browser/entry-shell.browser.test.ts | 350 ++++++++++++++++ .../test/auth-browser/fixture-server.ts | 70 +++- .../hosting-shell.browser.test.ts | 9 +- web/workspace/test/spa-shell.test.ts | 2 + .../empty-workspace-fixture-server.ts | 2 + .../empty-workspace.browser.test.ts | 4 +- .../optional-repository-interactions.json | 2 +- .../visual-review/optional-repository.json | 2 +- 32 files changed, 1702 insertions(+), 368 deletions(-) create mode 100644 tools/web-ux/scenarios/public-entry-dark.json create mode 100644 tools/web-ux/scenarios/public-entry-light.json create mode 100644 web/workspace/src/lib/workspace/auth/AccountNavigation.svelte create mode 100644 web/workspace/src/lib/workspace/auth/RegistrationLink.svelte delete mode 100644 web/workspace/src/lib/workspace/styles/workspace-catalog.css delete mode 100644 web/workspace/src/routes/+page.ts create mode 100644 web/workspace/src/routes/dashboard/+page.svelte rename web/workspace/src/routes/{PageContent.svelte => dashboard/WorkspaceCatalog.svelte} (56%) create mode 100644 web/workspace/src/routes/entry.browser.spec.ts create mode 100644 web/workspace/test/auth-browser/entry-shell.browser.test.ts diff --git a/tools/web-ux/scenarios/public-entry-dark.json b/tools/web-ux/scenarios/public-entry-dark.json new file mode 100644 index 000000000..4ccc005e0 --- /dev/null +++ b/tools/web-ux/scenarios/public-entry-dark.json @@ -0,0 +1,372 @@ +{ + "schemaVersion": 1, + "id": "public-entry-dark", + "title": "Public entry and authenticated Workspace selection", + "baseUrl": "http://127.0.0.1:15413", + "colorScheme": "dark", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Synthetic HTTP projection; not a real authentication session", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 900 + } + ], + "routes": [ + { + "id": "anonymous", + "label": "anonymous", + "path": "/?fixture=anonymous", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Known anonymous; policy allowed; Workspace API would reject", + "ready": { + "kind": "selector", + "selector": "a.entry-primary" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "policy-disabled", + "label": "policy-disabled", + "path": "/?fixture=policy-disabled", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Policy disabled; login retained", + "ready": { + "kind": "selector", + "selector": "section p:has-text(\"registration is disabled\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "policy-error", + "label": "policy-error", + "path": "/?fixture=policy-error", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Intentional public policy HTTP503; login retained, no registration link", + "ready": { + "kind": "selector", + "selector": "section p:has-text(\"availability is unknown\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "policy-loading", + "label": "policy-loading", + "path": "/?fixture=policy-loading", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Public policy held pending; login retained", + "ready": { + "kind": "selector", + "selector": "[role=status]:has-text(\"Checking registration\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "session-loading", + "label": "session-loading", + "path": "/?fixture=session-loading", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Whoami held pending; no anonymous or actor actions", + "ready": { + "kind": "selector", + "selector": "[role=status]:has-text(\"Loading session\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "session-error", + "label": "session-error", + "path": "/?fixture=session-error", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Intentional whoami HTTP503; unknown, not logged out", + "ready": { + "kind": "selector", + "selector": "[role=alert]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "account", + "label": "account", + "path": "/?fixture=account", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Confirmed ordinary actor; no Workspace fetch or automatic navigation", + "ready": { + "kind": "selector", + "selector": "a.entry-primary" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "admin", + "label": "admin", + "path": "/?fixture=admin", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Confirmed hosting admin grant independent of Workspace ownership", + "ready": { + "kind": "selector", + "selector": "a[href=\"/hosting/settings\"]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-zero", + "label": "dashboard-zero", + "path": "/dashboard?fixture=account", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Confirmed actor; zero Workspaces", + "ready": { + "kind": "selector", + "selector": ".workspace-empty-state" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-one", + "label": "dashboard-one", + "path": "/dashboard?fixture=workspace-one", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "One Workspace requires explicit selection", + "ready": { + "kind": "selector", + "selector": ".workspace-catalog-card" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-many", + "label": "dashboard-many", + "path": "/dashboard?fixture=workspace-many", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Three Workspaces, long display name, explicit selection", + "ready": { + "kind": "selector", + "selector": ".workspace-catalog-card:nth-child(3)" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-error", + "label": "dashboard-error", + "path": "/dashboard?fixture=workspace-error", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Intentional catalog HTTP503; not empty; account retained", + "ready": { + "kind": "selector", + "selector": ".workspace-catalog-alert" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-loading", + "label": "dashboard-loading", + "path": "/dashboard?fixture=workspace-loading", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Catalog held pending; account remains available", + "ready": { + "kind": "selector", + "selector": "[role=status]:has-text(\"Loading Workspaces\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-anonymous", + "label": "dashboard-anonymous", + "path": "/dashboard?fixture=anonymous", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Known anonymous; login returnTo dashboard, no catalog fetch", + "ready": { + "kind": "selector", + "selector": "a.entry-primary" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "create-denied", + "label": "create-denied", + "path": "/dashboard?fixture=creation-denied#workspace-create-title", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Intentional creation HTTP403; exact retry, no resources created", + "ready": { + "kind": "selector", + "selector": ".workspace-empty-state" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Creation denied", + "fullPage": true, + "interaction": [ + { + "action": "fill", + "selector": "input[autocomplete=off]", + "value": "New Workspace" + }, + { + "action": "click", + "selector": "button[type=submit]" + } + ], + "ready": { + "kind": "selector", + "selector": ".workspace-catalog-alert" + } + } + ] + }, + { + "id": "login-disabled", + "label": "login-disabled", + "path": "/login?fixture=policy-disabled", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Login remains usable while registration is disabled", + "ready": { + "kind": "selector", + "selector": "nav p:has-text(\"registration is disabled\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + } + ], + "processes": [ + { + "id": "workspace-fixture", + "command": "deno", + "cwd": "../../..", + "args": [ + "run", + "--allow-read", + "--allow-net", + "tools/web-ux/browser-tests/workspace_home_fixture_server.ts", + "15414", + "web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15414/health" + }, + { + "id": "entry-fixture", + "command": "deno", + "cwd": "../../..", + "args": [ + "run", + "--config", + "tools/web-ux/deno.json", + "--allow-read", + "--allow-net", + "web/workspace/test/auth-browser/fixture-server.ts", + "15413", + "web/workspace/build", + "http://127.0.0.1:15414" + ], + "readyUrl": "http://127.0.0.1:15413/health" + } + ] +} diff --git a/tools/web-ux/scenarios/public-entry-light.json b/tools/web-ux/scenarios/public-entry-light.json new file mode 100644 index 000000000..c3dda910d --- /dev/null +++ b/tools/web-ux/scenarios/public-entry-light.json @@ -0,0 +1,372 @@ +{ + "schemaVersion": 1, + "id": "public-entry-light", + "title": "Public entry and authenticated Workspace selection", + "baseUrl": "http://127.0.0.1:15413", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Synthetic HTTP projection; not a real authentication session", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 900 + } + ], + "routes": [ + { + "id": "anonymous", + "label": "anonymous", + "path": "/?fixture=anonymous", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Known anonymous; policy allowed; Workspace API would reject", + "ready": { + "kind": "selector", + "selector": "a.entry-primary" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "policy-disabled", + "label": "policy-disabled", + "path": "/?fixture=policy-disabled", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Policy disabled; login retained", + "ready": { + "kind": "selector", + "selector": "section p:has-text(\"registration is disabled\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "policy-error", + "label": "policy-error", + "path": "/?fixture=policy-error", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Intentional public policy HTTP503; login retained, no registration link", + "ready": { + "kind": "selector", + "selector": "section p:has-text(\"availability is unknown\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "policy-loading", + "label": "policy-loading", + "path": "/?fixture=policy-loading", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Public policy held pending; login retained", + "ready": { + "kind": "selector", + "selector": "[role=status]:has-text(\"Checking registration\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "session-loading", + "label": "session-loading", + "path": "/?fixture=session-loading", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Whoami held pending; no anonymous or actor actions", + "ready": { + "kind": "selector", + "selector": "[role=status]:has-text(\"Loading session\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "session-error", + "label": "session-error", + "path": "/?fixture=session-error", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Intentional whoami HTTP503; unknown, not logged out", + "ready": { + "kind": "selector", + "selector": "[role=alert]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "account", + "label": "account", + "path": "/?fixture=account", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Confirmed ordinary actor; no Workspace fetch or automatic navigation", + "ready": { + "kind": "selector", + "selector": "a.entry-primary" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "admin", + "label": "admin", + "path": "/?fixture=admin", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Confirmed hosting admin grant independent of Workspace ownership", + "ready": { + "kind": "selector", + "selector": "a[href=\"/hosting/settings\"]" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-zero", + "label": "dashboard-zero", + "path": "/dashboard?fixture=account", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Confirmed actor; zero Workspaces", + "ready": { + "kind": "selector", + "selector": ".workspace-empty-state" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-one", + "label": "dashboard-one", + "path": "/dashboard?fixture=workspace-one", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "One Workspace requires explicit selection", + "ready": { + "kind": "selector", + "selector": ".workspace-catalog-card" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-many", + "label": "dashboard-many", + "path": "/dashboard?fixture=workspace-many", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Three Workspaces, long display name, explicit selection", + "ready": { + "kind": "selector", + "selector": ".workspace-catalog-card:nth-child(3)" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-error", + "label": "dashboard-error", + "path": "/dashboard?fixture=workspace-error", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Intentional catalog HTTP503; not empty; account retained", + "ready": { + "kind": "selector", + "selector": ".workspace-catalog-alert" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-loading", + "label": "dashboard-loading", + "path": "/dashboard?fixture=workspace-loading", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Catalog held pending; account remains available", + "ready": { + "kind": "selector", + "selector": "[role=status]:has-text(\"Loading Workspaces\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "dashboard-anonymous", + "label": "dashboard-anonymous", + "path": "/dashboard?fixture=anonymous", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Known anonymous; login returnTo dashboard, no catalog fetch", + "ready": { + "kind": "selector", + "selector": "a.entry-primary" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + }, + { + "id": "create-denied", + "label": "create-denied", + "path": "/dashboard?fixture=creation-denied#workspace-create-title", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Intentional creation HTTP403; exact retry, no resources created", + "ready": { + "kind": "selector", + "selector": ".workspace-empty-state" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Creation denied", + "fullPage": true, + "interaction": [ + { + "action": "fill", + "selector": "input[autocomplete=off]", + "value": "New Workspace" + }, + { + "action": "click", + "selector": "button[type=submit]" + } + ], + "ready": { + "kind": "selector", + "selector": ".workspace-catalog-alert" + } + } + ] + }, + { + "id": "login-disabled", + "label": "login-disabled", + "path": "/login?fixture=policy-disabled", + "goal": "Verify entry, session and Workspace authority remain distinct with reachable actions", + "dataState": "Login remains usable while registration is disabled", + "ready": { + "kind": "selector", + "selector": "nav p:has-text(\"registration is disabled\")" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Initial state", + "fullPage": true + } + ] + } + ], + "processes": [ + { + "id": "workspace-fixture", + "command": "deno", + "cwd": "../../..", + "args": [ + "run", + "--allow-read", + "--allow-net", + "tools/web-ux/browser-tests/workspace_home_fixture_server.ts", + "15414", + "web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15414/health" + }, + { + "id": "entry-fixture", + "command": "deno", + "cwd": "../../..", + "args": [ + "run", + "--config", + "tools/web-ux/deno.json", + "--allow-read", + "--allow-net", + "web/workspace/test/auth-browser/fixture-server.ts", + "15413", + "web/workspace/build", + "http://127.0.0.1:15414" + ], + "readyUrl": "http://127.0.0.1:15413/health" + } + ] +} diff --git a/web/workspace/deno.json b/web/workspace/deno.json index d7623e425..b98886d97 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -19,7 +19,8 @@ "check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check", "generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types", "check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check", - "test:browser:auth": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/auth-shell.browser.test.ts" + "test:browser:auth": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/auth-shell.browser.test.ts", + "test:browser:entry": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/entry-shell.browser.test.ts" }, "imports": { "@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@4.0.0", diff --git a/web/workspace/src/lib/workspace/auth/AccountNavigation.svelte b/web/workspace/src/lib/workspace/auth/AccountNavigation.svelte new file mode 100644 index 000000000..38b1fedd7 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/AccountNavigation.svelte @@ -0,0 +1,16 @@ + + + diff --git a/web/workspace/src/lib/workspace/auth/AuthEntry.svelte b/web/workspace/src/lib/workspace/auth/AuthEntry.svelte index cac9c86f0..8d4946211 100644 --- a/web/workspace/src/lib/workspace/auth/AuthEntry.svelte +++ b/web/workspace/src/lib/workspace/auth/AuthEntry.svelte @@ -5,7 +5,8 @@ import { loginWithPasskey, registerPasskey } from './api.ts'; import { BrowserAuth } from './browser-session.svelte.ts'; import { loadRegistrationPolicy } from './hosting.ts'; - import { authEntryUrl, safeReturnTo } from './routes.ts'; + import { authEntryUrl, safeReturnTo, AUTHENTICATED_HOME } from './routes.ts'; + import RegistrationLink from './RegistrationLink.svelte'; import './auth-page.css'; let { mode }: { mode: 'login' | 'register' } = $props(); @@ -72,7 +73,7 @@ {:else if auth.actor}

Logged in as {auth.actor.display_name} @{auth.actor.handle}.

- Continue to {destination === '/' ? 'dashboard / Workspaces' : 'requested page'} + Continue to {destination === AUTHENTICATED_HOME ? 'dashboard / Workspaces' : 'requested page'} Open Account {:else} {#if mode === 'register'} @@ -103,12 +104,13 @@ {#if auth.status}

{auth.status}

{/if} {#if auth.error}{/if} diff --git a/web/workspace/src/lib/workspace/auth/RegistrationLink.svelte b/web/workspace/src/lib/workspace/auth/RegistrationLink.svelte new file mode 100644 index 000000000..0665e7795 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/RegistrationLink.svelte @@ -0,0 +1,40 @@ + + +{#if policy === 'enabled'} + Create a new account +{:else if policy === 'disabled'} +

New account registration is disabled. Existing users can still log in.

+{:else if policy === 'loading'} +

Checking registration availability…

+{:else} +

Registration availability is unknown. Existing users can still log in.

+ +{/if} diff --git a/web/workspace/src/lib/workspace/auth/auth-page.css b/web/workspace/src/lib/workspace/auth/auth-page.css index c857db5f8..5540693c0 100644 --- a/web/workspace/src/lib/workspace/auth/auth-page.css +++ b/web/workspace/src/lib/workspace/auth/auth-page.css @@ -18,4 +18,7 @@ .auth-page dl { display: grid; gap: var(--space-4); margin: 0; } .auth-page dl div { display: grid; gap: var(--space-1); min-width: 0; } .auth-page dt { color: var(--text-muted); } +.auth-page a.entry-primary { justify-self: start; padding: var(--space-2) var(--space-4); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: var(--text-strong); color: var(--bg); text-decoration: none; } +.auth-page a.entry-primary:hover { background: var(--interactive-hover); color: var(--text-strong); } +.auth-page a.entry-primary:active { background: var(--interactive-selected); color: var(--text-strong); } .auth-page dd { margin: 0; overflow-wrap: anywhere; } diff --git a/web/workspace/src/lib/workspace/auth/routes.test.ts b/web/workspace/src/lib/workspace/auth/routes.test.ts index 79607ed72..c80b69926 100644 --- a/web/workspace/src/lib/workspace/auth/routes.test.ts +++ b/web/workspace/src/lib/workspace/auth/routes.test.ts @@ -15,7 +15,8 @@ import { authEntryUrl, loginUrl, safeReturnTo } from "./routes.ts"; Deno.test("return destination permits known app screens without forwarding arbitrary query or fragment", () => { for ( const [input, expected] of [ - ["/", "/"], + ["/", "/dashboard"], + ["/dashboard?query=discard#fragment", "/dashboard"], ["/account", "/account"], ["/account?returnTo=//evil.invalid#secret", "/account"], [ @@ -68,7 +69,7 @@ Deno.test("return destination rejects external, ambiguous encoding and authentic "/not-an-app-route", "/" + "a".repeat(2049), ] - ) assertEquals(safeReturnTo(input), "/", String(input)); + ) assertEquals(safeReturnTo(input), "/dashboard", String(input)); }); Deno.test("login links encode one validated destination and discard nested auth loops", () => { @@ -80,5 +81,8 @@ Deno.test("login links encode one validated destination and discard nested auth authEntryUrl("/register", "/account"), "/register?returnTo=%2Faccount", ); - assertEquals(loginUrl("/login?returnTo=/login"), "/login?returnTo=%2F"); + assertEquals( + loginUrl("/login?returnTo=/login"), + "/login?returnTo=%2Fdashboard", + ); }); diff --git a/web/workspace/src/lib/workspace/auth/routes.ts b/web/workspace/src/lib/workspace/auth/routes.ts index d6c9c2d50..595f8d44e 100644 --- a/web/workspace/src/lib/workspace/auth/routes.ts +++ b/web/workspace/src/lib/workspace/auth/routes.ts @@ -1,6 +1,6 @@ -// T760が公開トップ/Workspace選択を整理するまで、現行の一覧をdashboardへの入口とする。 +// 公開トップではなく明示的なWorkspace選択へ戻す。先頭Workspaceの推測はしない。 // 戻り先は既知のアプリ画面だけ。認証経路への再帰やdecode段階の差による別originを作らない。 -export const AUTHENTICATED_HOME = "/"; +export const AUTHENTICATED_HOME = "/dashboard"; export function safeReturnTo(value: string | null): string { if ( @@ -32,7 +32,7 @@ export function safeReturnTo(value: string | null): string { return "/login/device"; } if ( - url.pathname === "/" || url.pathname === "/account" || + url.pathname === AUTHENTICATED_HOME || url.pathname === "/account" || /^\/w\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_/-]*)?$/.test(url.pathname) ) { return url.pathname; diff --git a/web/workspace/src/lib/workspace/sidebar/GlobalNavSections.svelte b/web/workspace/src/lib/workspace/sidebar/GlobalNavSections.svelte index c65515793..059c3ae44 100644 --- a/web/workspace/src/lib/workspace/sidebar/GlobalNavSections.svelte +++ b/web/workspace/src/lib/workspace/sidebar/GlobalNavSections.svelte @@ -51,7 +51,8 @@ }: Props = $props(); const globalItems = [ - { href: "/#workspace-create-title", label: "Create Workspace" }, + { href: "/dashboard", label: "Dashboard / Workspaces" }, + { href: "/dashboard#workspace-create-title", label: "Create Workspace" }, { href: "/account", label: "Account" }, { href: "/login/device", label: "Device Login" }, ]; diff --git a/web/workspace/src/lib/workspace/sidebar/WorkspaceSwitcher.svelte b/web/workspace/src/lib/workspace/sidebar/WorkspaceSwitcher.svelte index 8c5cf1d97..a9f682926 100644 --- a/web/workspace/src/lib/workspace/sidebar/WorkspaceSwitcher.svelte +++ b/web/workspace/src/lib/workspace/sidebar/WorkspaceSwitcher.svelte @@ -173,7 +173,7 @@ Workspaces span { - display: inline-flex; - align-items: center; - gap: var(--space-1); - color: var(--text-muted); - font-size: var(--font-size-compact); - text-transform: uppercase; - } - - .workspace-card-heading > span::before { - width: 0.45rem; - height: 0.45rem; - border-radius: 50%; - background: currentColor; - content: ""; - } - - .workspace-card-heading > .workspace-state-active { - border-color: color-mix(in srgb, var(--success) 50%, var(--line)); - color: var(--success); - } - - .workspace-repository-summary { - display: grid; - gap: 0.2rem; - } - - .workspace-empty-state { - padding: 1.5rem; - margin-top: 1rem; - } - - .workspace-create-panel { - padding: 1.5rem; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); - gap: 2rem; - } - - .workspace-create-panel form { - display: grid; - gap: 1rem; - } - - .workspace-create-panel label { - display: grid; - gap: 0.35rem; - color: var(--text-muted); - font-size: var(--font-size-body); - } - - .workspace-create-panel input, - .workspace-switcher select { - min-width: 0; - border: 1px solid var(--line); - border-radius: 0.45rem; - background: var(--bg); - color: var(--text); - padding: 0.65rem 0.75rem; - font: inherit; - } - - .workspace-create-panel .workspace-repository-toggle { - display: flex; - align-items: center; - gap: var(--space-2); - color: var(--text); - } - - .workspace-repository-fields { - display: grid; - gap: var(--space-4); - min-width: 0; - } - - .workspace-create-panel input:not([type="checkbox"]) { - width: 100%; - } - - .workspace-create-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); - } - - .workspace-create-panel label { - min-width: 0; - } - - .workspace-create-row > label { - flex: 1; - } - - .workspace-primary-action, - .workspace-secondary-action { - border-radius: 0.45rem; - padding: 0.65rem 0.9rem; - font: inherit; - font-weight: 600; - cursor: pointer; - } - - .workspace-primary-action { - border: 1px solid var(--accent); - background: var(--accent); - color: var(--bg); - } - - .workspace-secondary-action { - border: 1px solid var(--line); - background: var(--bg-raised); - color: var(--text); - } - - .workspace-primary-action:disabled, - .workspace-secondary-action:disabled { - opacity: 0.55; - cursor: wait; - } - - .workspace-catalog-alert { - border-left: 3px solid var(--danger); - background: color-mix(in srgb, var(--danger) 8%, transparent); - padding: 0.75rem 0.9rem; - color: var(--text); - } - - .workspace-switcher { - display: grid; - gap: 0.45rem; - padding: 0 0.75rem 0.85rem; - border-bottom: 1px solid var(--line); - } - - .workspace-switcher label { - font-size: var(--font-size-compact); - letter-spacing: 0.08em; - color: var(--text-muted); - text-transform: uppercase; - } - - .workspace-switcher select { - width: 100%; - padding: 0.45rem 0.55rem; - font-size: var(--font-size-compact); - } - - .workspace-switcher-actions { - display: flex; - gap: 0.75rem; - font-size: var(--font-size-compact); - } - - .workspace-switcher-error { - color: var(--danger); - font-size: var(--font-size-compact); - } - - @media (max-width: 760px) { - .workspace-create-panel { - grid-template-columns: 1fr; - } - - .workspace-create-row, - .workspace-catalog-heading { - align-items: stretch; - flex-direction: column; - } - } -} diff --git a/web/workspace/src/routes/+layout.svelte b/web/workspace/src/routes/+layout.svelte index 9b5976801..a898e76e3 100644 --- a/web/workspace/src/routes/+layout.svelte +++ b/web/workspace/src/routes/+layout.svelte @@ -27,8 +27,8 @@ let { children, data }: LayoutProps = $props(); import { isAuthPage } from '#lib/workspace/auth/routes.ts'; - // 認証画面はWorkspaceの存在/取得/Sidebarに依存しない。 - const standalone = $derived(isAuthPage(page.url.pathname) || page.url.pathname.startsWith('/hosting/')); + // 公開入口・認証・Workspace選択はWorkspace catalogやSidebarに依存しない。 + const standalone = $derived(page.url.pathname === '/' || page.url.pathname === '/dashboard' || isAuthPage(page.url.pathname) || page.url.pathname.startsWith('/hosting/')); const catalog = observeDeferred(() => data.deferredCatalog, () => 'catalog'); let sidebar = $state(null); const sidebarOverrides = createOverrideStack((activeSidebar) => { diff --git a/web/workspace/src/routes/+layout.ts b/web/workspace/src/routes/+layout.ts index 621bbf6e9..6b013c40c 100644 --- a/web/workspace/src/routes/+layout.ts +++ b/web/workspace/src/routes/+layout.ts @@ -7,8 +7,12 @@ export const ssr = false; export const load = ({ fetch, url }: Parameters[0]) => ({ deferredCatalog: settle((async () => { - // 認証/hostingの判断はWorkspace catalogを必要としない。 - if (isAuthPage(url.pathname) || url.pathname.startsWith("/hosting/")) { + // 公開入口・認証・hostingの判断はWorkspace catalogを必要としない。 + // dashboardはactor確認後に自身の一覧を取得する。 + if ( + url.pathname === "/" || url.pathname === "/dashboard" || + isAuthPage(url.pathname) || url.pathname.startsWith("/hosting/") + ) { return { accessibleWorkspaces: [], workspaceCatalogError: null }; } try { diff --git a/web/workspace/src/routes/+page.svelte b/web/workspace/src/routes/+page.svelte index bd56f2063..83363a428 100644 --- a/web/workspace/src/routes/+page.svelte +++ b/web/workspace/src/routes/+page.svelte @@ -1,10 +1,51 @@ - - {#snippet children(resolved)}{/snippet} - +Yoi · AIと進める開発 +
+

Yoi

+

AIとともに、開発を前へ。

+

Ticketで目的を共有し、AI Workerと実装を進める。Workspaceでコード・レビュー・作業の記録をまとめます。

+
+ {#if auth.session.kind === 'loading'} +

Loading session…

+ {:else if auth.session.kind === 'error'} + + +
Open login + {:else if auth.actor} +

Logged in as {auth.actor.display_name}.

+ Open dashboard + {#key auth.actor.account_id}{/key} + {:else} + Log in + + Activate an account with a code + {/if} +
+
+ + diff --git a/web/workspace/src/routes/+page.ts b/web/workspace/src/routes/+page.ts deleted file mode 100644 index e4409f1dd..000000000 --- a/web/workspace/src/routes/+page.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { deferPage } from "#lib/workspace/navigation/deferred.ts"; -import type { PageLoad } from "./$types"; -import { loadWorkspaceCatalog } from "#lib/workspace/api/workspace-catalog.ts"; - -export const load = deferPage(async ({ fetch }: Parameters[0]) => { - try { - return { - workspaces: await loadWorkspaceCatalog(fetch), - catalogError: null, - }; - } catch (error) { - return { - workspaces: [], - catalogError: error instanceof Error ? error.message : String(error), - }; - } -}); diff --git a/web/workspace/src/routes/account/+page.svelte b/web/workspace/src/routes/account/+page.svelte index 51f673db8..c44ed8ad9 100644 --- a/web/workspace/src/routes/account/+page.svelte +++ b/web/workspace/src/routes/account/+page.svelte @@ -52,5 +52,5 @@ {/if} {#if auth.status}

{auth.status}

{/if} {#if auth.error}{/if} - Dashboard / Workspaces + Dashboard / Workspaces diff --git a/web/workspace/src/routes/account/account.browser.spec.ts b/web/workspace/src/routes/account/account.browser.spec.ts index d9cc685bf..d0a524886 100644 --- a/web/workspace/src/routes/account/account.browser.spec.ts +++ b/web/workspace/src/routes/account/account.browser.spec.ts @@ -141,7 +141,7 @@ async function submit(name: string) { } test("login asks only for an account ID and links separate registration and code activation", async () => { - const { fetcher } = boundary({ policy: false }); + const { fetcher } = boundary({ policy: true }); page.url = new URL("https://example.test/login?returnTo=%2Faccount"); render(Login); const handle = await screen.findByLabelText("Account ID (handle)"); @@ -149,9 +149,10 @@ test("login asks only for an account ID and links separate registration and code expect(screen.queryByRole("button", { name: "Register new account" })) .toBeNull(); expect( - screen.getByRole("link", { name: "Create a new account" }).getAttribute( - "href", - ), + (await screen.findByRole("link", { name: "Create a new account" })) + .getAttribute( + "href", + ), ) .toBe("/register?returnTo=%2Faccount"); expect( @@ -168,25 +169,25 @@ test("login asks only for an account ID and links separate registration and code .toBe(true); await submit("Log in with passkey"); expect(posted(fetcher)).toEqual([]); - expect(apiPaths(fetcher)).not.toContain("/api/auth/registration-policy"); + expect(apiPaths(fetcher)).toContain("/api/auth/registration-policy"); }); for ( const [returnTo, expected] of [ - [null, "/"], + [null, "/dashboard"], ["/account", "/account"], ["/w/project/tickets?ignored=1#fragment", "/w/project/tickets"], [ "/login/device?code=abcd-EFGH&untrusted=1", "/login/device?user_code=abcd-EFGH", ], - ["https://evil.test/", "/"], - ["//evil.test/account", "/"], - ["/\\evil.test/account", "/"], - ["/%2f%2fevil.test", "/"], - ["/login?returnTo=/login", "/"], - ["/register", "/"], - ["/activate", "/"], + ["https://evil.test/", "/dashboard"], + ["//evil.test/account", "/dashboard"], + ["/\\evil.test/account", "/dashboard"], + ["/%2f%2fevil.test", "/dashboard"], + ["/login?returnTo=/login", "/dashboard"], + ["/register", "/dashboard"], + ["/activate", "/dashboard"], ] as const ) { test(`login confirms whoami before navigating safely for returnTo=${returnTo}`, async () => { @@ -258,7 +259,7 @@ test("register requires a separate ID and display name and uses only new-account }); await submit("Register new account"); await waitFor(() => - expect(goto).toHaveBeenCalledWith("/", { replaceState: true }) + expect(goto).toHaveBeenCalledWith("/dashboard", { replaceState: true }) ); const requests = posted(fetcher); expect(requests.map(({ path }) => path)).toEqual([ @@ -301,7 +302,7 @@ for (const policy of [false, "error", "loading"] as const) { screen.getByRole("link", { name: "Log in with an existing account" }) .getAttribute("href"), ) - .toBe("/login?returnTo=%2F"); + .toBe("/login?returnTo=%2Fdashboard"); expect( screen.getByRole("link", { name: "Activate an account with a code" }) .getAttribute("href"), diff --git a/web/workspace/src/routes/dashboard/+page.svelte b/web/workspace/src/routes/dashboard/+page.svelte new file mode 100644 index 000000000..5c0917c53 --- /dev/null +++ b/web/workspace/src/routes/dashboard/+page.svelte @@ -0,0 +1,44 @@ + + +Dashboard · Yoi +
+
Yoi

Dashboard

+ {#if auth.session.kind === 'loading'} +

Loading session…

+ {:else if auth.session.kind === 'error'} + + + Open login + {:else if auth.actor} + {#key auth.actor.account_id} + + + {/key} + {:else} +

Log in to select or create a Workspace.

+ Log in + {/if} +
+ + diff --git a/web/workspace/src/routes/PageContent.svelte b/web/workspace/src/routes/dashboard/WorkspaceCatalog.svelte similarity index 56% rename from web/workspace/src/routes/PageContent.svelte rename to web/workspace/src/routes/dashboard/WorkspaceCatalog.svelte index b52f0a0ef..b996bc2d9 100644 --- a/web/workspace/src/routes/PageContent.svelte +++ b/web/workspace/src/routes/dashboard/WorkspaceCatalog.svelte @@ -1,4 +1,5 @@ - - Workspaces · Yoi - -
-

Backend

-

Workspaces

-

Select an accessible team space or create one on this Backend.

+

Choose a Workspace to open its dashboard. Workspace settings are managed inside each Workspace.

-
+
+ + + diff --git a/web/workspace/src/routes/entry.browser.spec.ts b/web/workspace/src/routes/entry.browser.spec.ts new file mode 100644 index 000000000..77b8c720c --- /dev/null +++ b/web/workspace/src/routes/entry.browser.spec.ts @@ -0,0 +1,240 @@ +// @vitest-environment happy-dom +// UI→本番clientを接続しHTTPだけ代替する。実Serverの認証・認可・SQLiteは共有E2Eが所有する。 +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import Home from "./+page.svelte"; +import Dashboard from "./dashboard/+page.svelte"; +import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts"; +import { + createdWorkspace, + creationResponse, +} from "#lib/workspace/api/workspace-catalog.test-fixtures.ts"; +import { SESSION_CHANGED_EVENT } from "#lib/workspace/auth/hosting.ts"; +import { goto } from "$app/navigation"; +vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) })); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +function boundary() { + const state = { + signedIn: false, + whoamiStatus: 200, + policy: true as boolean | "error" | "loading", + admin: false, + catalogStatus: 200, + count: 0, + createStatus: 200, + sessionGate: null as ReturnType> | null, + catalogGate: null as ReturnType> | null, + }; + const fetcher = vi.fn().mockImplementation( + async (input, init) => { + const path = String(input); + if (path === "/api/auth/whoami") { + if (state.sessionGate) return state.sessionGate.promise; + return state.whoamiStatus === 200 + ? Response.json({ + actor: state.signedIn + ? { ...authUser, auth_method: "browser_session" } + : null, + }) + : new Response(null, { status: state.whoamiStatus }); + } + if (path === "/api/auth/registration-policy") { + if (state.policy === "loading") return new Promise(() => {}); + return state.policy === "error" + ? new Response(null, { status: 503 }) + : Response.json({ self_registration_enabled: state.policy }); + } + if (path === "/api/hosting/access") { + return Response.json({ is_admin: state.admin }); + } + if (init?.method === "POST") { + return state.createStatus === 200 + ? Response.json(creationResponse()) + : new Response(null, { status: state.createStatus }); + } + if (path.startsWith("/api/workspaces?")) { + if (state.catalogGate) return state.catalogGate.promise; + return state.catalogStatus === 200 + ? Response.json( + Array.from( + { length: state.count }, + (_, i) => ({ + ...createdWorkspace, + workspace_id: `space-${i}`, + display_name: `Team ${i}`, + }), + ), + ) + : new Response(null, { status: state.catalogStatus }); + } + if (path.includes("/repositories")) { + return Response.json({ + workspace_id: "fixture", + items: [], + source: "workspace-control-plane", + diagnostics: [], + }); + } + throw new Error(`Unexpected request ${path}`); + }, + ); + vi.stubGlobal("fetch", fetcher); + return { + state, + fetcher, + workspaceCalls: () => + fetcher.mock.calls.filter(([path]) => + String(path).startsWith("/api/workspaces") + ), + }; +} +for (const policy of [true, false, "error", "loading"] as const) { + test(`public home keeps login and exposes registration only for confirmed allowed policy: ${policy}`, async () => { + const { state, workspaceCalls } = boundary(); + state.policy = policy; + state.catalogStatus = 401; + render(Home); + expect( + (await screen.findByRole("link", { name: "Log in" })).getAttribute( + "href", + ), + ).toBe("/login"); + if (policy === true) { + expect( + (await screen.findByRole("link", { name: "Create a new account" })) + .getAttribute("href"), + ).toBe("/register?returnTo=%2Fdashboard"); + } else { + await screen.findByText( + policy === false + ? /registration is disabled/ + : policy === "error" + ? /availability is unknown/ + : /Checking registration/, + ); + expect(screen.queryByRole("link", { name: "Create a new account" })) + .toBeNull(); + } + expect(workspaceCalls()).toEqual([]); + expect(screen.queryByText(/No accessible Workspaces/)).toBeNull(); + }); +} +test("whoami loading and failure never expose anonymous actions or Workspace content; retry confirms actor", async () => { + const { state, workspaceCalls } = boundary(); + state.sessionGate = deferred(); + render(Home); + await screen.findByText("Loading session…"); + expect(screen.queryByRole("link")).toBeNull(); + state.sessionGate.resolve(new Response(null, { status: 503 })); + await screen.findByRole("alert"); + expect(screen.queryByRole("link", { name: "Log in" })).toBeNull(); + expect(screen.queryByRole("link", { name: "Create a new account" })) + .toBeNull(); + state.sessionGate = null; + state.signedIn = true; + await fireEvent.click(screen.getByRole("button", { name: "Retry session" })); + expect( + (await screen.findByRole("link", { name: "Open dashboard" })).getAttribute( + "href", + ), + ).toBe("/dashboard"); + expect(workspaceCalls()).toEqual([]); + expect(goto).not.toHaveBeenCalled(); +}); +for (const admin of [false, true]) { + test(`confirmed actor account/hosting navigation reflects live admin=${admin}`, async () => { + const { state } = boundary(); + state.signedIn = true; + state.admin = admin; + render(Home); + await screen.findByRole("link", { name: "Account" }); + if (admin) await screen.findByRole("link", { name: "Hosting settings" }); + else {expect(screen.queryByRole("link", { name: "Hosting settings" })) + .toBeNull();} + state.signedIn = false; + await fireEvent(window, new Event(SESSION_CHANGED_EVENT)); + await screen.findByRole("link", { name: "Log in" }); + expect(screen.queryByRole("link", { name: "Hosting settings" })).toBeNull(); + }); +} +for (const count of [0, 1, 3]) { + test(`dashboard ${count} Workspaces requires explicit selection and offers creation`, async () => { + const { state } = boundary(); + state.signedIn = true; + state.count = count; + render(Dashboard); + if (count === 0) await screen.findByText("No accessible Workspaces"); + else { + await screen.findByRole("link", { name: /Team 0/ }); + expect(screen.getAllByRole("link", { name: /Team \d/ })).toHaveLength( + count, + ); + expect(screen.getByRole("link", { name: /Team 0/ }).getAttribute("href")) + .toBe("/w/space-0"); + } + expect(goto).not.toHaveBeenCalled(); + await fireEvent.click( + screen.getByText("Create Workspace", { selector: "summary" }), + ); + await fireEvent.input(screen.getByLabelText("Workspace display name"), { + target: { value: "New Workspace" }, + }); + await fireEvent.submit( + screen.getByRole("button", { name: "Create Workspace" }).closest("form")!, + ); + await waitFor(() => expect(goto).toHaveBeenCalledWith("/w/home-empty")); + }); +} +for (const status of [401, 503]) { + test(`dashboard catalog failure ${status} is not empty; account and creation recovery remain`, async () => { + const { state } = boundary(); + state.signedIn = true; + state.catalogStatus = status; + render(Dashboard); + await screen.findByRole("alert"); + expect(screen.queryByText("No accessible Workspaces")).toBeNull(); + expect(screen.getByRole("link", { name: "Account" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Refresh" })).toBeTruthy(); + }); +} +test("dashboard removes private content on session change and ignores old catalog completion", async () => { + const { state } = boundary(); + state.signedIn = true; + state.catalogGate = deferred(); + render(Dashboard); + await screen.findByText("Loading Workspaces…"); + state.signedIn = false; + await fireEvent(window, new Event(SESSION_CHANGED_EVENT)); + await screen.findByText("Log in to select or create a Workspace."); + state.catalogGate.resolve(Response.json([createdWorkspace])); + expect(screen.queryByText("New Workspace")).toBeNull(); + expect(screen.queryByLabelText("Workspace display name")).toBeNull(); +}); +test("dashboard does not contact catalog for anonymous or unknown session", async () => { + const { state, workspaceCalls } = boundary(); + const view = render(Dashboard); + await screen.findByRole("link", { name: "Log in" }); + expect(workspaceCalls()).toEqual([]); + view.unmount(); + state.whoamiStatus = 503; + render(Dashboard); + await screen.findByRole("alert"); + expect(workspaceCalls()).toEqual([]); +}); diff --git a/web/workspace/src/routes/sidebar-fold.browser.spec.ts b/web/workspace/src/routes/sidebar-fold.browser.spec.ts index 88033bb1c..582669a66 100644 --- a/web/workspace/src/routes/sidebar-fold.browser.spec.ts +++ b/web/workspace/src/routes/sidebar-fold.browser.spec.ts @@ -7,7 +7,7 @@ import { logout } from "#lib/workspace/auth/api.ts"; import { settle } from '#lib/workspace/navigation/deferred.ts'; import Layout from "./+layout.svelte"; -vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/") } })); +vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/w/example") } })); const storageKey = "yoi.sidebar.mode.v1"; const legacyKey = "yoi.sidebar.folded.v1"; const props = { diff --git a/web/workspace/src/routes/workspace-catalog.browser.spec.ts b/web/workspace/src/routes/workspace-catalog.browser.spec.ts index 265e10eb4..7b9487542 100644 --- a/web/workspace/src/routes/workspace-catalog.browser.spec.ts +++ b/web/workspace/src/routes/workspace-catalog.browser.spec.ts @@ -8,7 +8,7 @@ import { } from "@testing-library/svelte"; import { afterEach, expect, test, vi } from "vitest"; import { goto } from "$app/navigation"; -import Catalog from "./PageContent.svelte"; +import Catalog from "./dashboard/WorkspaceCatalog.svelte"; import { createdWorkspace, creationResponse, @@ -20,15 +20,25 @@ afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); }); -function mount() { - return render(Catalog, { - props: { - data: { - workspaces: [{ ...createdWorkspace, repositories: [] }], - catalogError: null, +async function mount() { + const mutationFetch = globalThis.fetch; + vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => { + if (init?.method === "POST") return mutationFetch(url, init); + return Response.json( + url.startsWith("/api/workspaces?") ? [createdWorkspace] : { + workspace_id: "home-empty", + items: [], + source: "workspace-control-plane", + diagnostics: [], }, - }, + ); }); + const view = render(Catalog); + await screen.findByRole("link", { name: /New Workspace.*No repositories/ }); + await fireEvent.click( + screen.getByText("Create Workspace", { selector: "summary" }), + ); + return view; } function submit() { return fireEvent.submit( @@ -42,7 +52,7 @@ test("blank repository form creates and opens a Workspace using a null repositor Response.json(creationResponse()), ); vi.stubGlobal("fetch", fetcher); - mount(); + await mount(); expect( screen.getByRole("link", { name: /New Workspace.*No repositories/ }) .getAttribute("href"), @@ -64,7 +74,7 @@ test("opting into a repository requires an explicit key and source and submits n Response.json(creationResponse()), ); vi.stubGlobal("fetch", fetcher); - mount(); + await mount(); await fireEvent.click( screen.getByRole("checkbox", { name: "Add an initial repository (optional)", @@ -98,7 +108,7 @@ test("unchanged empty creation retries reuse the operation key and accept delete ) .mockResolvedValueOnce(Response.json(creationResponse(null, true))); vi.stubGlobal("fetch", fetcher); - mount(); + await mount(); await fireEvent.input(screen.getByLabelText("Workspace display name"), { target: { value: "New Workspace" }, }); @@ -116,7 +126,7 @@ test("opting out ignores retained repository inputs and changes the creation ope Response.json({ message: "Temporary failure" }, { status: 503 }) ); vi.stubGlobal("fetch", fetcher); - mount(); + await mount(); const toggle = screen.getByRole("checkbox", { name: "Add an initial repository (optional)", }); diff --git a/web/workspace/test/auth-browser/auth-shell.browser.test.ts b/web/workspace/test/auth-browser/auth-shell.browser.test.ts index aba522b1f..ed18b2828 100644 --- a/web/workspace/test/auth-browser/auth-shell.browser.test.ts +++ b/web/workspace/test/auth-browser/auth-shell.browser.test.ts @@ -303,7 +303,7 @@ Deno.test("standalone auth separates login, registration, own account and explic const link = page.locator('a[href^="/login"]').first(); await link.click(); await login(page); - await at(page, "/"); + await at(page, "/dashboard"); await page.goto(`${origin}/account`); await page.getByRole("button", { name: "Add passkey to alice", @@ -490,7 +490,7 @@ Deno.test("standalone auth separates login, registration, own account and explic await page.getByRole("button", { name: "Retry session", exact: true }) .click(); await login(page); - await at(page, "/"); + await at(page, "/dashboard"); }), ); for (const route of ["login", "register"]) { @@ -500,9 +500,9 @@ Deno.test("standalone auth separates login, registration, own account and explic isolated(browser!, async ({ page, go }) => { await go(`/${route}`); await (route === "login" ? login(page) : register(page)); - await at(page, "/"); + await at(page, "/dashboard"); await page.reload(); - assertEquals(new URL(page.url()).pathname, "/"); + assertEquals(new URL(page.url()).pathname, "/dashboard"); }), ); for ( @@ -523,7 +523,7 @@ Deno.test("standalone auth separates login, registration, own account and explic isolated(browser!, async ({ page, go }) => { await go(`/${route}?returnTo=${encodeURIComponent(rejected)}`); await (route === "login" ? login(page) : register(page)); - await at(page, "/"); + await at(page, "/dashboard"); }), ); } @@ -619,7 +619,7 @@ Deno.test("standalone auth separates login, registration, own account and explic await page.getByRole("status").filter({ hasText: /added|registered/i, }).waitFor(); - } else await at(page, "/"); + } else await at(page, "/dashboard"); assertEquals( count(mutations, `/api/auth/passkeys/${purpose}/options`), 1, diff --git a/web/workspace/test/auth-browser/entry-shell.browser.test.ts b/web/workspace/test/auth-browser/entry-shell.browser.test.ts new file mode 100644 index 000000000..50be0a582 --- /dev/null +++ b/web/workspace/test/auth-browser/entry-shell.browser.test.ts @@ -0,0 +1,350 @@ +/// +// 本番build/shell/router/clientと合成HTTP境界の検証。実Server/SQLite/SoftPasskey E2Eとは別。 +import { assertEquals } from "@std/assert"; +import { resolve } from "@std/path"; +import { chromium, type Page } from "playwright"; +import { + startOwnedProcesses, + stopOwnedProcesses, +} from "../../../../tools/web-ux/src/processes.ts"; +const root = resolve(import.meta.dirname!, "../../../.."); +const origin = "http://127.0.0.1:15411"; +const ready = (page: Page, name: string) => + page.getByRole("link", { name, exact: true }).waitFor(); +Deno.test("public entry isolates Workspace authority and authenticated dashboard supports explicit selection", async (t) => { + const processes = await startOwnedProcesses( + [ + { + id: "workspace-fixture", + command: "deno", + cwd: root, + args: [ + "run", + "--allow-read", + "--allow-net", + "tools/web-ux/browser-tests/workspace_home_fixture_server.ts", + "15412", + "web/workspace/build", + ], + readyUrl: "http://127.0.0.1:15412/health", + }, + { + id: "entry-fixture", + command: "deno", + cwd: root, + args: [ + "run", + "--config", + "tools/web-ux/deno.json", + "--allow-read", + "--allow-net", + "web/workspace/test/auth-browser/fixture-server.ts", + "15411", + "web/workspace/build", + "http://127.0.0.1:15412", + ], + readyUrl: `${origin}/health`, + }, + ], + `${root}/tools/web-ux/scenarios/anonymous-entry.json`, + `${root}/target/web-ux/entry-browser/processes`, + [], + ); + try { + const browser = await chromium.launch(); + try { + for (const colorScheme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390]) { + const step = async ( + name: string, + body: (page: Page) => Promise, + ) => + await t.step(`${colorScheme}/${width}: ${name}`, async () => { + const context = await browser.newContext({ + viewport: { width, height: 900 }, + colorScheme, + reducedMotion: "reduce", + }); + try { + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await body(page); + assertEquals( + await page.evaluate(() => + document.documentElement.scrollWidth > innerWidth + ), + false, + ); + assertEquals(errors, []); + } finally { + await context.close(); + } + }); + for ( + const mode of [ + "anonymous", + "policy-disabled", + "policy-error", + "policy-loading", + ] + ) { + await step( + `public/login policy ${mode}, no catalog calls even if unavailable`, + async (page) => { + let workspaceCalls = 0; + await page.route("**/api/workspaces*", (route) => { + workspaceCalls++; + return route.fulfill({ status: 503 }); + }); + await page.goto(`${origin}/?fixture=${mode}`); + await page.getByRole("heading", { name: "Yoi", exact: true }) + .waitFor(); + await ready(page, "Log in"); + if (mode === "anonymous") { + await ready(page, "Create a new account"); + assertEquals( + await page.getByRole("link", { + name: "Create a new account", + }).getAttribute("href"), + "/register?returnTo=%2Fdashboard", + ); + } else { + await page.getByText( + mode === "policy-disabled" + ? /registration is disabled/ + : mode === "policy-error" + ? /availability is unknown/ + : /Checking registration/, + ).waitFor(); + assertEquals( + await page.getByRole("link", { + name: "Create a new account", + }).count(), + 0, + ); + } + await page.getByRole("link", { name: "Log in", exact: true }) + .click(); + await page.getByRole("button", { name: "Log in with passkey" }) + .waitFor(); + if (mode === "policy-disabled") { + await page.getByText(/registration is disabled/).waitFor(); + assertEquals( + await page.getByRole("link", { + name: "Create a new account", + }).count(), + 0, + ); + } + assertEquals(workspaceCalls, 0); + assertEquals( + await page.locator(".workspace-catalog-alert").count(), + 0, + ); + }, + ); + } + for (const mode of ["session-error", "session-loading"]) { + await step( + `public ${mode} is not anonymous or authenticated`, + async (page) => { + await page.goto(`${origin}/?fixture=${mode}`); + await page.getByText( + mode === "session-error" + ? /Login status is unknown/ + : "Loading session…", + ).waitFor(); + assertEquals( + await page.getByRole("link", { name: "Log in", exact: true }) + .count(), + 0, + ); + assertEquals( + await page.getByRole("link", { name: "Create a new account" }) + .count(), + 0, + ); + assertEquals( + await page.getByRole("link", { name: "Open dashboard" }) + .count(), + 0, + ); + }, + ); + } + await step( + "session retry and focus/session change remove stale actor/admin links", + async (page) => { + await page.goto(`${origin}/?fixture=session-retry`); + await page.getByRole("button", { name: "Retry session" }).click(); + await ready(page, "Log in"); + await page.goto(`${origin}/?fixture=admin`); + await ready(page, "Hosting settings"); + await ready(page, "Open dashboard"); + await page.request.post(`${origin}/api/auth/logout`); + await page.evaluate(() => + window.dispatchEvent(new Event("yoi:browser-session-changed")) + ); + await ready(page, "Log in"); + assertEquals( + await page.getByRole("link", { name: "Hosting settings" }) + .count(), + 0, + ); + assertEquals( + await page.getByRole("link", { name: "Open dashboard" }) + .count(), + 0, + ); + }, + ); + for ( + const mode of [ + "account", + "workspace-one", + "workspace-many", + "admin", + ] + ) { + await step( + `actor ${mode}: dashboard and explicit Workspace navigation`, + async (page) => { + await page.goto(`${origin}/?fixture=${mode}`); + await ready(page, "Open dashboard"); + await ready(page, "Account"); + if (mode === "admin") await ready(page, "Hosting settings"); + else {assertEquals( + await page.getByRole("link", { name: "Hosting settings" }) + .count(), + 0, + );} + await page.getByRole("link", { name: "Open dashboard" }) + .click(); + await page.getByRole("heading", { + name: "Dashboard", + exact: true, + }).waitFor(); + if (mode === "workspace-one" || mode === "workspace-many") { + const links = page.locator(".workspace-catalog-card"); + await links.first().waitFor(); + assertEquals( + await links.count(), + mode === "workspace-one" ? 1 : 3, + ); + assertEquals(new URL(page.url()).pathname, "/dashboard"); + await links.first().click(); + await page.waitForURL("**/w/home-empty"); + await page.locator(".workspace-home").waitFor(); + // canonical direct link remains independently reopenable, including another Workspace. + await page.goto(`${origin}/w/home-member`); + await page.locator(".workspace-home").waitFor(); + assertEquals(new URL(page.url()).pathname, "/w/home-member"); + } else { + await page.getByText("No accessible Workspaces", { + exact: true, + }).waitFor(); + await page.locator("summary#workspace-create-title").click(); + await page.getByLabel("Workspace display name").fill( + "New Workspace", + ); + const submitted = page.waitForRequest((request) => + new URL(request.url()).pathname === "/api/workspaces" && + request.method() === "POST" + ); + await page.getByRole("button", { + name: "Create Workspace", + exact: true, + }).click(); + const keys = Object.keys((await submitted).postDataJSON()) + .sort(); + assertEquals(keys, [ + "display_name", + "operation_key", + "repository", + ]); + await page.waitForURL("**/w/home-empty"); + await page.locator(".workspace-home").waitFor(); + } + }, + ); + } + for ( + const mode of [ + "workspace-unauthorized", + "workspace-error", + "workspace-loading", + ] + ) { + await step( + `catalog ${mode} does not erase account or imply empty`, + async (page) => { + await page.goto(`${origin}/dashboard?fixture=${mode}`); + await ready(page, "Account"); + await page.getByText( + mode === "workspace-loading" + ? "Loading Workspaces…" + : /Workspaces could not be loaded/, + ).waitFor(); + assertEquals( + await page.getByText("No accessible Workspaces", { + exact: true, + }).count(), + 0, + ); + assertEquals(new URL(page.url()).pathname, "/dashboard"); + await page.getByRole("link", { name: "Yoi", exact: true }) + .click(); + await ready(page, "Open dashboard"); + assertEquals(await page.getByRole("alert").count(), 0); + }, + ); + } + for (const mode of ["creation-denied", "creation-error"]) { + await step( + `explicit creation ${mode} retains input and exact retry operation`, + async (page) => { + await page.goto( + `${origin}/dashboard?fixture=${mode}#workspace-create-title`, + ); + await page.getByLabel("Workspace display name").fill( + "New Workspace", + ); + const bodies: unknown[] = []; + page.on("request", (request) => { + if (request.method() === "POST") { + bodies.push(request.postDataJSON()); + } + }); + await page.getByRole("button", { + name: "Create Workspace", + exact: true, + }).click(); + await page.getByRole("alert").waitFor(); + await page.getByRole("button", { + name: "Retry creation", + exact: true, + }).click(); + await page.getByRole("button", { + name: "Retry creation", + exact: true, + }).waitFor(); + assertEquals(bodies.length, 2); + assertEquals(bodies[0], bodies[1]); + assertEquals( + await page.getByLabel("Workspace display name").inputValue(), + "New Workspace", + ); + assertEquals(new URL(page.url()).pathname, "/dashboard"); + }, + ); + } + } + } + } finally { + await browser.close(); + } + } finally { + assertEquals(await stopOwnedProcesses(processes), []); + } +}); diff --git a/web/workspace/test/auth-browser/fixture-server.ts b/web/workspace/test/auth-browser/fixture-server.ts index 8661f5037..abbeaed7d 100644 --- a/web/workspace/test/auth-browser/fixture-server.ts +++ b/web/workspace/test/auth-browser/fixture-server.ts @@ -3,7 +3,12 @@ import { extname, join, resolve } from "@std/path"; import { setupWebAuthnProjection } from "../hosting-browser/setup-projection.ts"; -const [port, build] = Deno.args; +import { + createdWorkspace, + creationResponse, +} from "../../src/lib/workspace/api/workspace-catalog.test-fixtures.ts"; + +const [port, build, workspaceFixture] = Deno.args; if (!port || !build) throw new Error("Expected port and production build root"); const root = resolve(build); const user = { @@ -51,6 +56,8 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => { sessions.set(id, { mode: selected ?? "anonymous", signedIn: selected === "account" || + selected?.startsWith("workspace-") === true || selected === "admin" || + selected?.startsWith("creation-") === true || selected?.startsWith("addition-") === true, whoamiCalls: 0, selfRegistrationEnabled: selected !== "policy-disabled", @@ -98,17 +105,44 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => { }); } if (path === "/api/auth/registration-policy") { + if (mode === "policy-loading") return await new Promise(() => {}); if (mode === "policy-error") return new Response(null, { status: 503 }); return Response.json({ self_registration_enabled: state.selfRegistrationEnabled, }); } - if (path === "/api/hosting/access") return Response.json({ is_admin: false }); + if (path === "/api/hosting/access") { + return Response.json({ is_admin: mode === "admin" }); + } if (path === "/api/workspaces") { - // fallbackは現行Workspace選択まで。公開トップ/T760の挙動を新設しない。 - return state.signedIn - ? Response.json([]) - : new Response(null, { status: 401 }); + if (!state.signedIn || mode === "workspace-unauthorized") { + return new Response(null, { status: 401 }); + } + if (mode === "workspace-error") return new Response(null, { status: 503 }); + if (mode === "workspace-loading") { + return await new Promise(() => {}); + } + if (request.method === "POST") { + if (mode === "creation-denied") { + return new Response(null, { status: 403 }); + } + if (mode === "creation-error") return new Response(null, { status: 503 }); + return Response.json(creationResponse()); + } + const ids = mode === "workspace-one" + ? ["home-empty"] + : mode === "workspace-many" + ? ["home-empty", "home-member", "home-long"] + : []; + return Response.json(ids.map((workspace_id, i) => ({ + ...createdWorkspace, + workspace_id, + display_name: i === 0 + ? "New Workspace" + : i === 1 + ? "Shared Workspace" + : "Workspace with a long name — international documentation and distributed development", + }))); } if (path === "/api/auth/logout") { state.signedIn = false; @@ -153,6 +187,30 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => { state.signedIn = true; return Response.json({ user }); } + // Workspace画面の既存fixtureを再利用する。認証/登録/作成APIの代わりにはしない。 + if (workspaceFixture && path.startsWith("/api/w/")) { + const target = new URL(path + url.search, workspaceFixture); + if (request.headers.get("upgrade") === "websocket") { + const { socket, response } = Deno.upgradeWebSocket(request); + target.protocol = "ws:"; + const peer = new WebSocket(target); + const pending: string[] = []; + socket.onmessage = (event) => + peer.readyState === WebSocket.OPEN + ? peer.send(event.data) + : pending.push(String(event.data)); + peer.onopen = () => { + for (const frame of pending) peer.send(frame); + }; + peer.onmessage = (event) => { + if (socket.readyState === WebSocket.OPEN) socket.send(event.data); + }; + socket.onclose = () => peer.close(); + peer.onclose = () => socket.close(); + return response; + } + return await fetch(new Request(target, request)); + } if (path.startsWith("/api/")) return new Response(null, { status: 404 }); let file: string; try { diff --git a/web/workspace/test/hosting-browser/hosting-shell.browser.test.ts b/web/workspace/test/hosting-browser/hosting-shell.browser.test.ts index ce0e29d5b..273d52d3c 100644 --- a/web/workspace/test/hosting-browser/hosting-shell.browser.test.ts +++ b/web/workspace/test/hosting-browser/hosting-shell.browser.test.ts @@ -86,7 +86,7 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa } }); await page.goto("http://127.0.0.1:15379/?fixture=admin-on"); - await page.getByRole("heading", { name: "Workspaces", exact: true }) + await page.getByRole("heading", { name: "Yoi", exact: true }) .waitFor(); if ( await page.getByRole("button", { @@ -99,16 +99,11 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa exact: true, }).click(); } - const nav = page.getByRole("navigation", { name: "Global pages" }); + const nav = page.getByRole("navigation", { name: "Your account" }); await nav.getByRole("link", { name: "Hosting settings", exact: true }) .waitFor(); await nav.getByRole("link", { name: "Account", exact: true }).focus(); await page.keyboard.press("Tab"); - assertEquals( - await page.locator(":focus").innerText(), - "Device Login", - ); - await page.keyboard.press("Tab"); assertEquals( await page.locator(":focus").innerText(), "Hosting settings", diff --git a/web/workspace/test/spa-shell.test.ts b/web/workspace/test/spa-shell.test.ts index d4350e545..b75a9196e 100644 --- a/web/workspace/test/spa-shell.test.ts +++ b/web/workspace/test/spa-shell.test.ts @@ -4,6 +4,8 @@ import { load, ssr } from "../src/routes/+layout.ts"; Deno.test("authentication and hosting shells never require Workspace catalog authority", async () => { for ( const path of [ + "/", + "/dashboard", "/activate", "/login", "/register", diff --git a/web/workspace/test/visual-review/empty-workspace-fixture-server.ts b/web/workspace/test/visual-review/empty-workspace-fixture-server.ts index 0d8f19a44..b93695808 100644 --- a/web/workspace/test/visual-review/empty-workspace-fixture-server.ts +++ b/web/workspace/test/visual-review/empty-workspace-fixture-server.ts @@ -11,6 +11,8 @@ import { dashboardFixture } from "../../src/lib/workspace/home/dashboard.test-fi Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const url = new URL(request.url); + if (url.pathname === "/api/auth/whoami") return Response.json({ actor: { user_id: "fixture-user", account_id: "fixture-owner", handle: "alice", display_name: "Alice", auth_method: "browser_session" } }); + if (url.pathname === "/api/hosting/access") return Response.json({ is_admin: false }); if (url.pathname === "/api/workspaces" && request.method === "POST") { return Response.json({ message: diff --git a/web/workspace/test/visual-review/empty-workspace.browser.test.ts b/web/workspace/test/visual-review/empty-workspace.browser.test.ts index a453e1352..24350e902 100644 --- a/web/workspace/test/visual-review/empty-workspace.browser.test.ts +++ b/web/workspace/test/visual-review/empty-workspace.browser.test.ts @@ -61,7 +61,7 @@ Deno.test("production empty Workspace supports optional Git validation, reopenin creationBodies.push(route.request().postDataJSON()); await route.fulfill({ json: creationResponse() }); }); - await page.goto(scenario.baseUrl!); + await page.goto(scenario.baseUrl! + "/dashboard#workspace-create-title"); const name = page.getByLabel("Workspace display name"); await name.fill("New Workspace"); const toggle = page.getByRole("checkbox", { @@ -119,7 +119,7 @@ Deno.test("production empty Workspace supports optional Git validation, reopenin await page.locator("[data-feed-ready=home-active]").waitFor(); assertEquals(await page.locator("main [role=alert]").count(), 0); // Reopening is based on Workspace identity, not a repository record. - await page.goto(scenario.baseUrl!); + await page.goto(scenario.baseUrl! + "/dashboard"); await page.locator('a.workspace-catalog-card[href="/w/home-empty"]') .click(); await page.waitForURL("**/w/home-empty"); diff --git a/web/workspace/test/visual-review/optional-repository-interactions.json b/web/workspace/test/visual-review/optional-repository-interactions.json index 8e0a85800..91ee380fc 100644 --- a/web/workspace/test/visual-review/optional-repository-interactions.json +++ b/web/workspace/test/visual-review/optional-repository-interactions.json @@ -35,7 +35,7 @@ { "id": "catalog", "label": "Create Workspace without a repository", - "path": "/", + "path": "/dashboard#workspace-create-title", "goal": "Reopen a zero-repository Workspace or create a new Workspace without configuring Git.", "dataState": "Opt-in Git fields followed by intentional HTTP400 creation validation failure; no actual resources are created.", "ready": { diff --git a/web/workspace/test/visual-review/optional-repository.json b/web/workspace/test/visual-review/optional-repository.json index 00b45566f..249e0976e 100644 --- a/web/workspace/test/visual-review/optional-repository.json +++ b/web/workspace/test/visual-review/optional-repository.json @@ -35,7 +35,7 @@ { "id": "catalog", "label": "Create Workspace without a repository", - "path": "/", + "path": "/dashboard#workspace-create-title", "goal": "Reopen a zero-repository Workspace or create a new Workspace without configuring Git.", "dataState": "Accessible Workspaces with no repositories and blank creation form.", "ready": {