fix: separate workspace settings display and editing
This commit is contained in:
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user