From d066f3963fe712868860de178a486316e991f77e Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 1 Oct 2026 15:14:57 +0900 Subject: [PATCH] fix: separate workspace settings display and editing --- tools/web-ux/README.md | 10 + .../workspace_home_fixture_server.ts | 40 +- .../workspace_settings_shell_test.ts | 301 ++++++++++ tools/web-ux/deno.json | 2 +- .../web-ux/scenarios/workspace-settings.json | 137 +++++ web/workspace/README.md | 1 + .../settings/WorkspaceIdentitySettings.svelte | 396 +++++++++++++ .../settings/identity.test-fixtures.ts | 125 ++++ .../w/[workspaceId]/settings/+page.svelte | 398 +------------ .../settings/settings.browser.spec.ts | 532 ++++++++++++++++++ web/workspace/tests/workspace-model.test.ts | 10 +- 11 files changed, 1551 insertions(+), 401 deletions(-) create mode 100644 tools/web-ux/browser-tests/workspace_settings_shell_test.ts create mode 100644 tools/web-ux/scenarios/workspace-settings.json create mode 100644 web/workspace/src/lib/workspace/settings/WorkspaceIdentitySettings.svelte create mode 100644 web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts create mode 100644 web/workspace/src/routes/w/[workspaceId]/settings/settings.browser.spec.ts diff --git a/tools/web-ux/README.md b/tools/web-ux/README.md index 6562db442..9b7cef87d 100644 --- a/tools/web-ux/README.md +++ b/tools/web-ux/README.md @@ -198,6 +198,16 @@ Home browser regression (`browser-tests/workspace_home_shell_test.ts`, included also checks 320px, both themes, keyboard record navigation, bounded loading, independent slow/error feeds, explicit refresh, and absence of Host requests. +## Workspace Identity settings fixture + +`scenarios/workspace-settings.json` uses the same owned fixture server with synthetic metadata, +public identity, and deletion-preflight responses. It covers owner, member, long name, pending +provisioning, and intentional signing-identity HTTP503 states at 1440px, 768px, and 390px. +Build `web/workspace` before capture. No real keys, accounts, or deletion requests are used. +`browser-tests/workspace_settings_shell_test.ts` also checks 320px and both themes, Edit/Save/Cancel, +keyboard disclosure/focus return, full public bundle access, scoped errors, and exact-name deletion +confirmation. Interaction screenshots are saved under `target/web-ux/settings-interactions`. + ## Adding a scenario 1. Name the concrete user task and expected data state; do not write “looks correct”. diff --git a/tools/web-ux/browser-tests/workspace_home_fixture_server.ts b/tools/web-ux/browser-tests/workspace_home_fixture_server.ts index d66e0ece8..b1bf91a9b 100644 --- a/tools/web-ux/browser-tests/workspace_home_fixture_server.ts +++ b/tools/web-ux/browser-tests/workspace_home_fixture_server.ts @@ -1,9 +1,14 @@ import { extname, join, normalize } from "jsr:@std/path@1.1.4"; import { dashboardFixture } from "../../../web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts"; +import { settingsFixtureHandler } from "../../../web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts"; +const settingsFixture = settingsFixtureHandler(); + const port = Number(Deno.args[0]); const buildRoot = Deno.args[1]; -if (!Number.isInteger(port) || !buildRoot) throw new Error("usage: server "); +if (!Number.isInteger(port) || !buildRoot) { + throw new Error("usage: server "); +} const names: Record = { "home-owner": "Workspace Home Review", "home-member": "Shared Workspace", @@ -25,6 +30,8 @@ const mime: Record = { Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const url = new URL(request.url); if (url.pathname === "/health") return new Response("ok"); + const settingsResponse = await settingsFixture(request); + if (settingsResponse) return settingsResponse; if (url.pathname === "/api/workspaces") { return json( Object.entries(names).map(([workspace_id, display_name]) => ({ @@ -69,13 +76,26 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { 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: [] }, + 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: [] }); + return json({ + workspace_id: workspaceId, + items: [], + source: "fixture", + diagnostics: [], + }); } if (path === "/working-directories") { return json({ workspace_id: workspaceId, items: [], diagnostics: [] }); @@ -110,7 +130,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { record_source: "fixture", }); } - if (path === "/protocol/ws" && request.headers.get("upgrade") === "websocket") { + 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)); @@ -135,12 +157,16 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { 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 }); + 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" }, + headers: { + "content-type": mime[extname(file)] ?? "application/octet-stream", + }, }); } catch { return new Response(await Deno.readFile(join(buildRoot, "index.html")), { diff --git a/tools/web-ux/browser-tests/workspace_settings_shell_test.ts b/tools/web-ux/browser-tests/workspace_settings_shell_test.ts new file mode 100644 index 000000000..b323a1c24 --- /dev/null +++ b/tools/web-ux/browser-tests/workspace_settings_shell_test.ts @@ -0,0 +1,301 @@ +/// +import { assert, assertEquals } from "@std/assert"; +import { dirname, fromFileUrl, join } from "@std/path"; +import { chromium, type Page } from "playwright"; +import { metadataFixture } from "../../../web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts"; +const root = join(dirname(fromFileUrl(import.meta.url)), "../../.."); +const evidence = join(root, "target/web-ux/settings-interactions"); +async function ready(page: Page, owner = true) { + await page.locator("[data-name-ready=true]").waitFor(); + if (owner) await page.locator("[data-identity-ready=true]").waitFor(); +} +async function noOverflow(page: Page) { + assertEquals( + await page.locator("main").evaluate((main) => ({ + page: document.documentElement.scrollWidth > innerWidth, + main: main.scrollWidth > main.clientWidth, + })), + { page: false, main: false }, + ); +} +Deno.test("production Workspace Settings separates read/edit, discloses details, and preserves keyboard and destructive safety", 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)); + await Deno.mkdir(evidence, { recursive: true }); + 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 started = false; + for (let n = 0; n < 100; n++) { + try { + const response = await fetch(`${url}/health`); + await response.body?.cancel(); + if (response.ok) { + started = true; + break; + } + } catch { /* starting */ } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + assert(started); + 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 mutations: 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) => { + if (request.method() !== "GET") mutations.push(request.url()); + }); + for ( + const workspace of ["home-owner", "home-member", "home-long"] + ) { + const owner = workspace !== "home-member"; + await page.goto(`${url}/w/${workspace}/settings`); + await ready(page, owner); + assertEquals(await page.locator("main h1").allTextContents(), [ + "Workspace Identity", + ]); + assertEquals( + await page.locator("main input:visible, main textarea:visible") + .count(), + 0, + ); + assertEquals(await page.locator("main details[open]").count(), 0); + assertEquals(await page.locator("main .badge").count(), 0); + assertEquals( + await page.getByRole("button", { name: "Copy bundle" }).count(), + owner ? 1 : 0, + ); + const edit = page.getByRole("button", { name: "Edit name" }); + assert((await edit.boundingBox())!.y < 900); + await noOverflow(page); + const saved = await page.locator(".saved-name").innerText(); + await edit.focus(); + await page.keyboard.press("Enter"); + const input = page.getByRole("textbox", { + name: "New display name", + }); + assertEquals( + await input.evaluate((node) => node === document.activeElement), + true, + ); + assertEquals(await input.inputValue(), saved); + await input.fill("Discard draft"); + if (workspace === "home-long") { + await page.screenshot({ + path: join(evidence, `edit-${colorScheme}-${width}.png`), + }); + } + await page.getByRole("button", { name: "Cancel", exact: true }) + .click(); + assertEquals( + await edit.evaluate((node) => node === document.activeElement), + true, + ); + assertEquals( + await page.locator(".saved-name").innerText(), + saved, + ); + const technical = page.getByText("Technical details", { + exact: true, + }); + await technical.focus(); + await page.keyboard.press("Enter"); + assertEquals( + await page.getByText("Source", { exact: true }).isVisible(), + true, + ); + await page.keyboard.press("Enter"); + assertEquals( + await page.getByText("Source", { exact: true }).isVisible(), + false, + ); + if (owner) { + const bundle = page.getByText("Public bundle and key details", { + exact: true, + }); + await bundle.focus(); + await page.keyboard.press("Enter"); + assertEquals( + await page.getByRole("textbox", { + name: "Public identity bundle", + }).isVisible(), + true, + ); + assertEquals( + await page.getByRole("textbox", { + name: "Public identity bundle", + }).getAttribute("readonly"), + "", + ); + await noOverflow(page); + if (workspace === "home-owner") { + await page.screenshot({ + path: join(evidence, `bundle-${colorScheme}-${width}.png`), + }); + } + await bundle.click(); + const danger = page.locator(".deletion > summary"); + await danger.focus(); + await page.keyboard.press("Enter"); + await page.getByRole("button", { + name: "Review deletion impact", + }).click(); + const confirmation = page.getByRole("textbox", { + name: /^Type .* to confirm$/, + }); + await confirmation.waitFor(); + assertEquals( + await confirmation.evaluate((node) => node === document.activeElement), + true, + ); + const deletion = page.getByRole("button", { + name: "Delete Workspace", + exact: true, + }); + assertEquals(await deletion.isDisabled(), true); + await confirmation.fill("wrong"); + assertEquals(await deletion.isDisabled(), true); + await confirmation.fill(saved); + assertEquals(await deletion.isDisabled(), false); + await noOverflow(page); + if (workspace === "home-owner") { + await page.screenshot({ + path: join(evidence, `delete-${colorScheme}-${width}.png`), + }); + } + await page.getByRole("button", { name: "Cancel", exact: true }) + .click(); + assertEquals( + await danger.evaluate((node) => node === document.activeElement), + true, + ); + assertEquals(await page.locator(".deletion[open]").count(), 0); + } + } + assertEquals(mutations, []); + assertEquals(errors, []); + await page.getByRole("button", { name: "Edit name" }).click(); + const renamed = `Confirmed name ${width} ${colorScheme}`; + await page.getByRole("textbox", { name: "New display name" }).fill( + renamed, + ); + await page.getByRole("button", { name: "Save name" }).click(); + await page.getByText("Workspace name saved.", { exact: true }) + .waitFor(); + assertEquals( + await page.locator(".saved-name").innerText(), + renamed, + ); + assertEquals(await page.locator("main input:visible").count(), 0); + assertEquals(mutations.length, 1); + assertEquals(errors, []); + // Restore the synthetic name so every width actually exercises long text. + const currentResponse = await context.request.get(`${url}/api/w/home-long/settings`); + const current = await currentResponse.json(); + const reset = await context.request.put(`${url}/api/w/home-long/settings`, { + data: { + display_name: metadataFixture("home-long").display_name, + revision: current.revision, + }, + }); + assertEquals(reset.status(), 200); + } finally { + await context.close(); + } + } + } + const page = await browser.newPage({ + viewport: { width: 390, height: 844 }, + }); + try { + await page.goto(`${url}/w/home-empty/settings`); + await ready(page); + await page.getByText("Not provisioned", { exact: true }).waitFor(); + await page.getByRole("button", { name: "Provision identity" }).click(); + await page.getByRole("button", { name: "Copy bundle" }).waitFor(); + await page.goto(`${url}/w/home-error/settings`); + await ready(page); + assertEquals( + await page.getByRole("button", { name: "Edit name" }).isVisible(), + true, + ); + assertEquals( + await page.getByRole("button", { name: "Retry identity" }) + .isVisible(), + true, + ); + await page.goto(`${url}/w/home-owner/settings`); + await ready(page); + await page.route("**/settings", async (route) => { + if (route.request().method() === "PUT") { + await route.fulfill({ + status: 409, + body: "Workspace metadata changed. Reload the saved name.", + }); + } else await route.continue(); + }); + await page.getByRole("button", { name: "Edit name" }).click(); + await page.getByRole("textbox", { name: "New display name" }).fill( + "Keep this draft", + ); + await page.getByRole("button", { name: "Save name" }).click(); + await page.getByRole("alert").waitFor(); + assertEquals( + await page.getByRole("textbox", { name: "New display name" }) + .inputValue(), + "Keep this draft", + ); + await page.screenshot({ + path: join(evidence, "save-error-mobile.png"), + }); + await page.getByRole("button", { name: "Reload saved name" }).click(); + await page.getByRole("button", { name: "Edit name" }).waitFor(); + assertEquals(await page.getByRole("alert").count(), 0); + } finally { + await page.close(); + } + } finally { + await browser.close(); + } + } finally { + try { + server.kill("SIGTERM"); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + } + await server.status; + } +}); diff --git a/tools/web-ux/deno.json b/tools/web-ux/deno.json index cd003fc81..c76d7545a 100644 --- a/tools/web-ux/deno.json +++ b/tools/web-ux/deno.json @@ -11,7 +11,7 @@ "web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts", "check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts", "test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests", - "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_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 browser-tests/workspace_settings_shell_test.ts" }, "fmt": { "lineWidth": 100, diff --git a/tools/web-ux/scenarios/workspace-settings.json b/tools/web-ux/scenarios/workspace-settings.json new file mode 100644 index 000000000..e038c719e --- /dev/null +++ b/tools/web-ux/scenarios/workspace-settings.json @@ -0,0 +1,137 @@ +{ + "schemaVersion": 1, + "id": "workspace-settings", + "title": "Workspace Identity settings disclosure", + "baseUrl": "http://127.0.0.1:15282", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [ + { + "id": "fixture", + "label": "Synthetic Workspace permission projections", + "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 settings", + "path": "/w/home-owner/settings", + "goal": "Read saved name and identity state without permanent edit forms or public JSON.", + "dataState": "Active public identity and saved name.", + "ready": { + "kind": "selector", + "selector": "main:has(#workspace-settings-title):not(:has-text(\"Loading\"))" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Saved settings" + } + ] + }, + { + "id": "member", + "label": "Member settings", + "path": "/w/home-member/settings", + "goal": "Keep permitted metadata editing without owner identity or deletion controls.", + "dataState": "All owner permissions false; metadata read/write remains available under existing Backend contract.", + "ready": { + "kind": "selector", + "selector": "main:has(#workspace-settings-title):not(:has-text(\"Loading\"))" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Member view" + } + ] + }, + { + "id": "long", + "label": "Long name", + "path": "/w/home-long/settings", + "goal": "Keep controls in the viewport and preserve complete long names.", + "dataState": "Long Workspace display name and active identity.", + "ready": { + "kind": "selector", + "selector": "main:has(#workspace-settings-title):not(:has-text(\"Loading\"))" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Long name" + } + ] + }, + { + "id": "pending", + "label": "Pending identity", + "path": "/w/home-empty/settings", + "goal": "Show provisioning attention without pretending the identity is active.", + "dataState": "Identity pending_provisioning; metadata available.", + "ready": { + "kind": "selector", + "selector": "main:has(#workspace-settings-title):not(:has-text(\"Loading\"))" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Provisioning required" + } + ] + }, + { + "id": "error", + "label": "Identity failure", + "path": "/w/home-error/settings", + "goal": "Keep name usable when public identity fails and offer scoped recovery.", + "dataState": "Intentional signing identity HTTP503 with its expected console diagnostic.", + "ready": { + "kind": "selector", + "selector": "main:has(#workspace-settings-title):not(:has-text(\"Loading\"))" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Scoped error" + } + ] + } + ], + "processes": [ + { + "id": "settings-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/workspace_home_fixture_server.ts", + "15282", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15282/health" + } + ] +} diff --git a/web/workspace/README.md b/web/workspace/README.md index 1eb1e98d1..d4f7b3827 100644 --- a/web/workspace/README.md +++ b/web/workspace/README.md @@ -95,6 +95,7 @@ Use semantic tokens from `app.css`. Do not introduce component-local color, spac - `src/routes/w/[workspaceId]/+layout.svelte`: Workspace Header and Sidebar registration. - `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration. - `src/lib/workspace/home/`: bounded, read-only work dashboard feeds and their presentation. +- `src/lib/workspace/settings/WorkspaceIdentitySettings.svelte`: Workspace-scoped name editing, public identity disclosure, and deletion confirmation. - `src/lib/workspace/header/`: Header frame, context, and overrides. - `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack. - `src/lib/workspace/markdown/`: shared safe Markdown parsing plus presentation-specific document renderers. diff --git a/web/workspace/src/lib/workspace/settings/WorkspaceIdentitySettings.svelte b/web/workspace/src/lib/workspace/settings/WorkspaceIdentitySettings.svelte new file mode 100644 index 000000000..2ebfa3788 --- /dev/null +++ b/web/workspace/src/lib/workspace/settings/WorkspaceIdentitySettings.svelte @@ -0,0 +1,396 @@ + + +
+

Workspace Identity

+
+
+

Display name

+ {#if workspaceMetadata && !editing} + + {/if} +
+ {#if loading}

Loading workspace settings…

{/if} + {#if metadataError} + + + {/if} + {#if workspaceMetadata} + {#if editing} +
{ event.preventDefault(); void submitWorkspaceName(); }} aria-label="Edit Workspace name"> + +

Current: {workspaceMetadata.display_name}

+ {#if nameError}{/if} +
+ + + {#if nameError}{/if} +
+
+ {:else} +

{workspaceMetadata.display_name}

+ {/if} + {#if nameNotice}

{nameNotice}

{/if} + {#each diagnostics.filter((item) => item.severity !== 'info') as diagnostic} +

{diagnostic.message}

+ {/each} +
+ Technical details +
+
Source
{workspaceMetadata.source}
+
Revision
{workspaceMetadata.revision}
+
+ {#each diagnostics.filter((item) => item.severity === 'info') as diagnostic}

{diagnostic.message}

{/each} +
+ {/if} +
+ + {#if canDelete} + +
+
+

Public identity

+ {#if signingIdentity?.public_bundle}{/if} +
+ {#if identityLoading}

Loading identity…

{/if} + {#if identityError}{/if} + {#if !identityLoading && !signingIdentity}{/if} + {#if signingIdentity?.identity.state === 'pending_provisioning'} +

Not provisioned

+

Provision a signing identity before connecting a Runtime.

+ + {:else if signingIdentity?.public_bundle} +

Active

+ {#if identityCopied}

Public bundle copied.

{/if} + {#if copyError}{/if} +
+ Public bundle and key details +

Use this public bundle when connecting a Runtime. It contains no private key material.

+
+
Key
{signingIdentity.identity.key_id}
+
Fingerprint
{signingIdentity.identity.public_key_fingerprint}
+
Revision
{signingIdentity.identity.revision}
+
+ +
+ {/if} +
+ +
{ if (!event.currentTarget.open && !deletionSubmitting) deletionOpen = false; }}> + {deletionSubmitting ? 'Deleting Workspace…' : 'Delete Workspace'} +

Deleting this Workspace permanently removes its Workers, Workdirs, repositories, configuration, Memory, Tickets, and audit data. This cannot be undone.

+ {#if !deletionOpen} + + {:else} +
+

Delete {deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? workspaceMetadata?.display_name ?? workspaceName}?

+ {#if deletionLoading}

Loading deletion impact…

{/if} + {#if deletionPreflight} +
    +
  • {deletionPreflight.resources.workers} Workers
  • {deletionPreflight.resources.workdirs} Workdirs
  • +
  • {deletionPreflight.resources.repositories} repositories
  • {deletionPreflight.resources.runtime_bindings} Runtime bindings
  • +
  • {deletionPreflight.resources.secrets} secret records
  • {deletionPreflight.resources.artifacts} artifacts
  • +
+ {#each deletionPreflight.blockers as blocker}{/each} + {#if !deletionRequest} + + {/if} + {/if} + {#if deletionOperation} +

Deletion state: {deletionOperation.state}

+ {#each deletionOperation.blockers as blocker}{/each} + {/if} + {#if deletionError}{/if} +
+ + {#if deletionPreflight || deletionRequest} + + {:else if !deletionLoading} + + {/if} +
+
+ {/if} +
+ {/if} +
+ + diff --git a/web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts b/web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts new file mode 100644 index 000000000..2ddacf317 --- /dev/null +++ b/web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts @@ -0,0 +1,125 @@ +// Synthetic public data only; shared by component tests and production browser fixtures. +export function metadataFixture(workspaceId = "home-owner") { + return { + workspace_id: workspaceId, + display_name: workspaceId === "home-long" + ? "Workspace with a long name — international documentation and distributed development" + : "Workspace Settings Review", + created_at: "2026-01-01T00:00:00Z", + revision: "2026-01-02T00:00:00Z", + source: "server_db", + diagnostics: [], + }; +} +export function identityFixture(workspaceId = "home-owner", pending = false) { + const identity = { + workspace_id: workspaceId, + key_id: "fixture-workspace-key", + algorithm: "ed25519", + revision: 1, + state: pending ? "pending_provisioning" : "active", + created_at: "2026-01-01T00:00:00Z", + }; + if (pending) return { identity }; + const publicKey = "yoi-ed25519-pub:v1:synthetic-public-key"; + const fingerprint = "SHA256:synthetic-public-fingerprint-for-ui-review"; + return { + identity: { + ...identity, + public_key: publicKey, + public_key_fingerprint: fingerprint, + provisioned_at: identity.created_at, + }, + public_bundle: { + workspace_id: workspaceId, + backend_url: "https://workspace.example.test", + key_id: identity.key_id, + algorithm: identity.algorithm, + public_key: publicKey, + public_key_fingerprint: fingerprint, + revision: 1, + }, + }; +} +export function deletionFixture(workspaceId = "home-owner") { + return { + workspace_id: workspaceId, + display_name: metadataFixture(workspaceId).display_name, + expected_revision: metadataFixture(workspaceId).revision, + can_delete: true, + blockers: [], + resources: { + workers: 2, + workdirs: 1, + repositories: 1, + runtime_bindings: 1, + secrets: 0, + artifacts: 0, + }, + }; +} +export function settingsFixtureHandler() { + const names = new Map>(); + const provisioned = new Set(); + return async (request: Request): Promise => { + const url = new URL(request.url); + const match = url.pathname.match(/^\/api\/w\/([^/]+)\/settings(.*)$/); + if (match) { + const [, id, path] = match; + if (!path) { + const current = names.get(id) ?? metadataFixture(id); + if (request.method === "PUT") { + const body = await request.json(); + if (body.revision !== current.revision) { + return new Response( + "Workspace metadata changed. Reload the saved name.", + { status: 409 }, + ); + } + const next = { + ...current, + display_name: String(body.display_name).trim(), + revision: String(Number.parseInt(current.revision, 10) + 1), + }; + names.set(id, next); + return Response.json({ workspace: next, diagnostics: [] }); + } + return Response.json(current); + } + if (path === "/signing-identity/provision") { + provisioned.add(id); + return Response.json(identityFixture(id)); + } + if (path === "/signing-identity") { + if (id === "home-error") { + return new Response("Public identity is temporarily unavailable.", { + status: 503, + }); + } + return Response.json( + identityFixture(id, id === "home-empty" && !provisioned.has(id)), + ); + } + } + const deletion = url.pathname.match( + /^\/api\/workspaces\/([^/]+)\/deletion$/, + ); + if (deletion) { + if (request.method !== "GET") { + return new Response("Fixture does not delete Workspaces.", { + status: 409, + }); + } + return Response.json({ + ...deletionFixture(deletion[1]), + ...(names.has(deletion[1]) + ? { + display_name: names.get(deletion[1])!.display_name, + expected_revision: names.get(deletion[1])!.revision, + } + : {}), + }); + } + return null; + }; +} diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte index 8841e6be1..2de8f71f9 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte @@ -1,395 +1,17 @@ - - Workspace settings · Yoi Workspace - +Workspace Identity · Settings · Yoi -
-
-
-

editable

-

Workspace Identity

-
- Backend scoped -
- - {#if loading} -

Loading workspace settings…

- {:else} -
{ event.preventDefault(); void submitWorkspaceName(); }}> - -

Workspace id: {workspaceMetadata?.workspace_id ?? workspaceId}

- -
- -
-
-
Source
-
{workspaceMetadata?.source ?? 'unknown'}
-
-
-
Revision
-
{workspaceMetadata?.revision ?? 'unknown'}
-
-
- {/if} - - {#if message} -

{message}

- {/if} - -
- -{#if data.workspace?.permissions.delete_workspace} -
-
-
-

Workspace public identity

-

Use this public bundle when connecting a Runtime to this Workspace.

-
- {#if signingIdentity?.public_bundle} - - {/if} -
- {#if identityError} -

{identityError}

- {/if} - {#if identityLoading} -

Loading identity…

- {:else if signingIdentity?.identity.state === 'pending_provisioning'} -

This existing Workspace needs one explicit signing identity provisioning operation.

- - {:else if signingIdentity?.public_bundle} - - - {/if} -
- -
-
-

Danger zone

-

Deleting this Workspace permanently removes its Workers, Workdirs, repositories, configuration, Memory, Tickets, and audit data.

-
- -
+{#if data.workspace} + {#key `${data.workspace.workspace_id}:${data.workspace.permissions.delete_workspace}`} + + {/key} {/if} - -{#if deletionOpen} - -{/if} - - diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/settings.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/settings/settings.browser.spec.ts new file mode 100644 index 000000000..498a162f5 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/settings/settings.browser.spec.ts @@ -0,0 +1,532 @@ +// @vitest-environment happy-dom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import type { ComponentProps } from "svelte"; +import { goto, invalidate } from "$app/navigation"; +import Settings from "./+page.svelte"; +import { + deletionFixture, + identityFixture, + metadataFixture, + settingsFixtureHandler, +} from "$lib/workspace/settings/identity.test-fixtures"; +vi.mock( + "$app/navigation", + () => ({ goto: vi.fn(async () => {}), invalidate: vi.fn(async () => {}) }), +); +vi.mock( + "$lib/workspace/multiplexer", + () => ({ disposeWorkspaceMultiplexer: vi.fn() }), +); +vi.mock( + "$lib/workspace/sidebar/worker-subscription", + () => ({ disposeWorkspaceWorkersStore: vi.fn() }), +); +type Data = ComponentProps["data"]; +function data(id = "home-owner", owner = true): Data { + return { + workspace: { + workspace_id: id, + display_name: metadataFixture(id).display_name, + permissions: { delete_workspace: owner }, + }, + } as Data; +} +function mount(value = data()) { + return render(Settings, { + props: { + data: value, + params: { workspaceId: value.workspace!.workspace_id }, + }, + }); +} +function mockApi( + override?: (request: Request) => Promise | Response | null, +) { + const handler = settingsFixtureHandler(); + const mock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request( + new URL(String(input), "https://fixture.test"), + init, + ); + return await override?.(request) ?? await handler(request) ?? + new Response("Unexpected fixture request", { status: 404 }); + }); + vi.stubGlobal("fetch", mock); + return mock; +} +async function openName() { + await fireEvent.click( + await screen.findByRole("button", { name: "Edit name" }), + ); + return screen.getByLabelText("New display name") as HTMLInputElement; +} +async function openDeletion() { + await screen.findByText("Active", { exact: true }); + await fireEvent.click( + screen.getByText("Delete Workspace", { selector: "summary" }), + ); + await fireEvent.click( + screen.getByRole("button", { name: "Review deletion impact" }), + ); + return await screen.findByLabelText( + `Type ${metadataFixture().display_name} to confirm`, + ) as HTMLInputElement; +} +function operation(state = "succeeded", operationId = "op-test") { + return { + operation_id: operationId, + workspace_id: "home-owner", + display_name: metadataFixture().display_name, + state, + resources: deletionFixture().resources, + child_operation_ids: [], + blockers: [], + failure_category: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + completed_at: null, + }; +} +afterEach(() => { + cleanup(); + sessionStorage.clear(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +test("saved settings have no editable form, badges or raw data expanded; each technical scope is disclosed", async () => { + const api = mockApi(); + mount(); + await screen.findByText("Active", { exact: true }); + expect(screen.getByText(metadataFixture().display_name)).toBeTruthy(); + expect(document.querySelector("form")).toBeNull(); + expect(document.querySelector("input, textarea:not([readonly])")).toBeNull(); + // happy-dom does not implement native details visibility; Chromium verifies it. + expect(document.querySelector("textarea")?.closest("details")?.open).toBe( + false, + ); + expect(document.querySelectorAll("details[open]")).toHaveLength(0); + expect(document.querySelector(".badge")).toBeNull(); + expect(api.mock.calls).toHaveLength(2); + expect(api.mock.calls.some(([path]) => String(path).includes("deletion"))) + .toBe(false); +}); + +test("Edit focuses the field; Cancel discards the draft and returns focus without mutation", async () => { + const api = mockApi(); + mount(); + const input = await openName(); + expect(document.activeElement).toBe(input); + await fireEvent.input(input, { target: { value: "Discard this" } }); + await fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Edit name" }), + ); + expect((await openName()).value).toBe(metadataFixture().display_name); + expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true); +}); + +test("save uses the current revision, publishes confirmed name and invalidates the Workspace header", async () => { + const api = mockApi(); + mount(); + await fireEvent.input(await openName(), { + target: { value: "Renamed Workspace" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Save name" })); + await screen.findByText("Workspace name saved."); + expect(screen.getByText("Renamed Workspace")).toBeTruthy(); + expect(document.querySelector("form")).toBeNull(); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Edit name" }), + ); + expect( + JSON.parse(String( + api.mock.calls.find(([, init]) => init?.method === "PUT")![1]!.body, + )), + ).toEqual({ + display_name: "Renamed Workspace", + revision: metadataFixture().revision, + }); + expect(invalidate).toHaveBeenCalledWith("/api/w/home-owner/workspace"); +}); + +test("failed save preserves the draft and saved name; Reload saved name recovers from revision conflicts", async () => { + mockApi((request) => + request.method === "PUT" + ? new Response("Revision conflict", { status: 409 }) + : null + ); + mount(); + const input = await openName(); + await fireEvent.input(input, { target: { value: "Keep draft" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save name" })); + expect((await screen.findByRole("alert")).textContent).toBe( + "Revision conflict", + ); + expect(input.value).toBe("Keep draft"); + expect(screen.getByText(`Current: ${metadataFixture().display_name}`)) + .toBeTruthy(); + await fireEvent.click( + screen.getByRole("button", { name: "Reload saved name" }), + ); + await screen.findByRole("button", { name: "Edit name" }); + expect(screen.queryByRole("alert")).toBeNull(); + expect((await openName()).value).toBe(metadataFixture().display_name); +}); + +test("pending save disables duplicate submit and cancel while preserving the current value", async () => { + let release!: (response: Response) => void; + const held = new Promise((resolve) => release = resolve); + const api = mockApi((request) => request.method === "PUT" ? held : null); + mount(); + await fireEvent.input(await openName(), { target: { value: "New name" } }); + await fireEvent.click(screen.getByRole("button", { name: "Save name" })); + expect( + (screen.getByRole("button", { name: "Saving…" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole("button", { name: "Cancel" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + await fireEvent.submit(document.querySelector("form")!); + expect(api.mock.calls.filter(([, init]) => init?.method === "PUT")) + .toHaveLength(1); + release( + Response.json({ + workspace: { ...metadataFixture(), display_name: "New name" }, + diagnostics: [], + }), + ); + await screen.findByText("Workspace name saved."); +}); + +test("metadata and public identity load independently and offer scoped retries", async () => { + let failName = true; + mockApi((request) => + failName && new URL(request.url).pathname.endsWith("/settings") + ? new Response("Name unavailable", { status: 503 }) + : null + ); + mount(); + await screen.findByText("Active", { exact: true }); + await screen.findByText("Name unavailable"); + expect(screen.queryByRole("button", { name: "Edit name" })).toBeNull(); + failName = false; + await fireEvent.click(screen.getByRole("button", { name: "Retry name" })); + await screen.findByRole("button", { name: "Edit name" }); +}); + +test("identity failure does not block name editing or masquerade as pending provisioning", async () => { + let fail = true; + mockApi((request) => + fail && request.url.endsWith("/signing-identity") + ? new Response("Identity unavailable", { status: 503 }) + : null + ); + mount(); + await screen.findByRole("button", { name: "Edit name" }); + await screen.findByText("Identity unavailable"); + expect(screen.queryByRole("button", { name: "Provision identity" })) + .toBeNull(); + fail = false; + await fireEvent.click(screen.getByRole("button", { name: "Retry identity" })); + await screen.findByText("Active", { exact: true }); +}); + +test("non-owner retains metadata editing but never fetches or shows owner-only identity/deletion", async () => { + const api = mockApi(); + mount(data("home-member", false)); + await screen.findByRole("button", { name: "Edit name" }); + expect(screen.queryByRole("heading", { name: "Public identity" })).toBeNull(); + expect(screen.queryByText("Delete Workspace")).toBeNull(); + expect(api).toHaveBeenCalledTimes(1); +}); + +test("pending provisioning is explicit, only provisions on action and focuses the resulting copy action", async () => { + const api = mockApi(); + mount(data("home-empty")); + await screen.findByText("Not provisioned"); + expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true); + await fireEvent.click( + screen.getByRole("button", { name: "Provision identity" }), + ); + const copy = await screen.findByRole("button", { name: "Copy bundle" }); + expect(document.activeElement).toBe(copy); + expect(screen.getByText("Active", { exact: true })).toBeTruthy(); +}); + +test("copy uses the exact public bundle; clipboard failure offers manual selection rather than losing data", async () => { + mockApi(); + const writeText = vi.fn().mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("Denied")); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + mount(); + const button = await screen.findByRole("button", { name: "Copy bundle" }); + await fireEvent.click(button); + await screen.findByText("Public bundle copied."); + expect(writeText).toHaveBeenCalledWith( + JSON.stringify(identityFixture().public_bundle, null, 2), + ); + await fireEvent.click(button); + expect((await screen.findByRole("alert")).textContent).toContain( + "select the bundle manually", + ); + expect(document.querySelector("textarea")!.value).toBe( + JSON.stringify(identityFixture().public_bundle, null, 2), + ); +}); + +test("deletion is deferred, requires exact current name, and Cancel restores disclosure focus", async () => { + const api = mockApi(); + mount(); + const input = await openDeletion(); + expect(document.activeElement).toBe(input); + const confirm = screen.getByRole("button", { + name: "Delete Workspace", + }) as HTMLButtonElement; + expect(confirm.disabled).toBe(true); + await fireEvent.input(input, { target: { value: "Wrong name" } }); + expect(confirm.disabled).toBe(true); + await fireEvent.input(input, { + target: { value: metadataFixture().display_name }, + }); + expect(confirm.disabled).toBe(false); + await fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(document.activeElement?.textContent).toBe("Delete Workspace"); + expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true); +}); + +test("deletion blockers and preflight failure do not permit a destructive request", async () => { + let fail = true; + const api = mockApi((request) => + request.url.endsWith("/deletion") + ? fail + ? new Response("Preflight unavailable", { status: 503 }) + : Response.json({ + ...deletionFixture(), + can_delete: false, + blockers: [{ + kind: "worker_removal_blocked", + resource_kind: "worker", + resource_key: "W-7", + message: "Worker W-7 is running.", + }], + }) + : null + ); + mount(); + await screen.findByText("Active", { exact: true }); + await fireEvent.click( + screen.getByText("Delete Workspace", { selector: "summary" }), + ); + await fireEvent.click( + screen.getByRole("button", { name: "Review deletion impact" }), + ); + await screen.findByRole("button", { name: "Retry deletion check" }); + expect(screen.queryByRole("button", { name: "Delete Workspace" })).toBeNull(); + fail = false; + await fireEvent.click( + screen.getByRole("button", { name: "Retry deletion check" }), + ); + await screen.findByText("Worker W-7 is running."); + expect( + (screen.getByRole("button", { + name: "Delete Workspace", + }) as HTMLButtonElement).disabled, + ).toBe(true); + expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true); +}); + +test("deletion retry reuses the persisted operation id and confirmation, then redirects only after success", async () => { + let attempts = 0; + const api = mockApi(async (request) => { + if (request.method === "POST" && request.url.endsWith("/deletion")) { + const body = await request.json(); + return ++attempts === 1 + ? new Response("Connection lost", { status: 503 }) + : Response.json(operation("queued", body.operation_id)); + } + if (request.url.includes("/workspace-deletions/")) { + return Response.json( + operation("succeeded", request.url.split("/").pop()), + ); + } + return null; + }); + mount(); + await fireEvent.input(await openDeletion(), { + target: { value: metadataFixture().display_name }, + }); + await fireEvent.click( + screen.getByRole("button", { name: "Delete Workspace" }), + ); + await screen.findByRole("button", { name: "Retry deletion" }); + expect(goto).not.toHaveBeenCalled(); + const stored = sessionStorage.getItem("yoi:workspace-deletion:home-owner"); + expect(stored).toBeTruthy(); + await fireEvent.click(screen.getByRole("button", { name: "Retry deletion" })); + await waitFor(() => expect(goto).toHaveBeenCalledWith("/")); + const requests = api.mock.calls.filter(([, init]) => init?.method === "POST"); + expect(requests[0][1]!.body).toBe(requests[1][1]!.body); + expect(sessionStorage.getItem("yoi:workspace-deletion:home-owner")) + .toBeNull(); +}); + +test("reload resumes persisted deletion without sending another POST", async () => { + sessionStorage.setItem( + "yoi:workspace-deletion:home-owner", + JSON.stringify({ + operation_id: "op-test", + expected_revision: metadataFixture().revision, + confirmation: metadataFixture().display_name, + }), + ); + const api = mockApi((request) => + request.url.includes("/workspace-deletions/") + ? Response.json(operation("failed")) + : null + ); + mount(); + await screen.findByText("Deletion state: failed"); + expect(document.querySelector("details.deletion")?.hasAttribute("open")).toBe( + true, + ); + expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true); +}); + +test("Workspace switch resets drafts and ignores old load/mutation completions", async () => { + let release!: (response: Response) => void; + const held = new Promise((resolve) => release = resolve); + mockApi((request) => request.method === "PUT" ? held : null); + const view = mount(); + await fireEvent.input(await openName(), { + target: { value: "Old scope draft" }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Save name" })); + await view.rerender({ + data: data("home-long"), + params: { workspaceId: "home-long" }, + }); + await screen.findByText(metadataFixture("home-long").display_name); + release( + Response.json({ + workspace: { ...metadataFixture(), display_name: "Old scope draft" }, + diagnostics: [], + }), + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(screen.queryByText("Old scope draft")).toBeNull(); + expect(invalidate).not.toHaveBeenCalled(); + expect(goto).not.toHaveBeenCalled(); +}); + +test("slow identity keeps the name usable and old identity responses cannot leak across Workspaces", async () => { + let release!: (response: Response) => void; + const held = new Promise((resolve) => release = resolve); + mockApi((request) => + request.url.endsWith("/home-owner/settings/signing-identity") ? held : null + ); + const view = mount(); + await screen.findByRole("button", { name: "Edit name" }); + expect(screen.getByText("Loading identity…")).toBeTruthy(); + await view.rerender({ + data: data("home-empty"), + params: { workspaceId: "home-empty" }, + }); + await screen.findByText("Not provisioned"); + release(Response.json(identityFixture())); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(screen.queryByText("Active", { exact: true })).toBeNull(); + expect(screen.getByText("Not provisioned")).toBeTruthy(); +}); + +test("a completed deletion request from an unmounted Workspace cannot navigate away from the new scope", async () => { + const request = { + operation_id: "op-test", + expected_revision: metadataFixture().revision, + confirmation: metadataFixture().display_name, + }; + sessionStorage.setItem( + "yoi:workspace-deletion:home-owner", + JSON.stringify(request), + ); + let release!: (response: Response) => void; + const held = new Promise((resolve) => release = resolve); + mockApi((request) => + request.url.includes("/workspace-deletions/") ? held : null + ); + const view = mount(); + await screen.findByText("Deleting…"); + await view.rerender({ + data: data("home-member", false), + params: { workspaceId: "home-member" }, + }); + await screen.findByRole("button", { name: "Edit name" }); + release(Response.json(operation())); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(goto).not.toHaveBeenCalled(); + expect(sessionStorage.getItem("yoi:workspace-deletion:home-owner")).not + .toBeNull(); +}); + +test("name editing and deletion confirmation cannot overlap", async () => { + mockApi(); + mount(); + await openName(); + await fireEvent.click( + screen.getByText("Delete Workspace", { selector: "summary" }), + ); + expect( + (screen.getByRole("button", { + name: "Review deletion impact", + }) as HTMLButtonElement).disabled, + ).toBe(true); + await fireEvent.click( + screen.getByRole("button", { name: "Cancel" }), + ); + await fireEvent.click( + screen.getByRole("button", { name: "Review deletion impact" }), + ); + await screen.findByLabelText( + `Type ${metadataFixture().display_name} to confirm`, + ); + expect( + (screen.getByRole("button", { name: "Edit name" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + await fireEvent.click( + screen.getByRole("button", { name: "Cancel" }), + ); + expect( + (screen.getByRole("button", { name: "Edit name" }) as HTMLButtonElement) + .disabled, + ).toBe(false); +}); + +test("untrusted names and errors remain text, not interpreted HTML", async () => { + const name = ""; + mockApi((request) => + request.url.endsWith("/settings") + ? Response.json({ ...metadataFixture(), display_name: name }) + : request.url.endsWith("/signing-identity") + ? new Response("", { status: 503 }) + : null + ); + mount(); + await screen.findByText(name); + expect((await screen.findByRole("alert")).textContent).toBe( + "", + ); + expect(document.querySelector("img, script")).toBeNull(); +}); diff --git a/web/workspace/tests/workspace-model.test.ts b/web/workspace/tests/workspace-model.test.ts index 7ae598119..fc83d2ac7 100644 --- a/web/workspace/tests/workspace-model.test.ts +++ b/web/workspace/tests/workspace-model.test.ts @@ -313,11 +313,11 @@ Deno.test("Workspace deletion DTOs fail closed and preserve durable operation st }); Deno.test("Workspace settings exposes owner-gated typed destructive confirmation", async () => { - const source = await Deno.readTextFile( - new URL( - "../src/routes/w/[workspaceId]/settings/+page.svelte", - import.meta.url, - ), + const source = (await Promise.all([ + "../src/routes/w/[workspaceId]/settings/+page.svelte", + "../src/lib/workspace/settings/WorkspaceIdentitySettings.svelte", + ].map((path) => Deno.readTextFile(new URL(path, import.meta.url))))).join( + "\n", ); for ( const token of [