fix: separate workspace settings display and editing

This commit is contained in:
2026-10-01 15:14:57 +09:00
parent 8e0dad98e8
commit d066f3963f
11 changed files with 1551 additions and 401 deletions
+10
View File
@@ -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”.
@@ -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 <port> <build-root>");
if (!Number.isInteger(port) || !buildRoot) {
throw new Error("usage: server <port> <build-root>");
}
const names: Record<string, string> = {
"home-owner": "Workspace Home Review",
"home-member": "Shared Workspace",
@@ -25,6 +30,8 @@ const mime: Record<string, string> = {
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")), {
@@ -0,0 +1,301 @@
/// <reference lib="dom" />
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;
}
});
+1 -1
View File
@@ -11,7 +11,7 @@
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts 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,
@@ -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"
}
]
}