Files
yoi/tools/web-ux/browser-tests/workdir_removal_shell_test.ts
Hare c78f7f7d80 feat: replace revision counters with domain state contracts
Use content digests, key fingerprints, binding and trust identities, immutable mutation receipts, and observation tokens instead of state-update ordinals across API, storage, Runtime, and Web contracts.

Preserve historical replay and secret material through frozen migrations; preflight uncertain removals before cutover, resume provisioning receipts, validate encrypted migration candidates, and align fresh/upgraded schemas. Include reviewed regression fixes, generated artifacts, and validation evidence.
2026-10-10 15:55:20 +09:00

485 lines
18 KiB
TypeScript

/// <reference lib="dom" />
import { assert, assertEquals, assertStringIncludes } from "@std/assert";
import { dirname, fromFileUrl, join } from "@std/path";
import { chromium, type Page } from "playwright";
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
const root = join(dirname(fromFileUrl(import.meta.url)), "../../..");
const evidence = join(root, "target/web-ux/t730-browser");
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("ordinary removal refreshes failed and successful inventory, supports exact retry, and preserves permission and live guards across viewports and themes", async () => {
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 = await startOwnedProcesses(
[{
id: "workdirs",
command: Deno.execPath(),
cwd: root,
args: [
"run",
"--allow-net",
"--allow-read",
"tools/web-ux/browser-tests/workspace_home_fixture_server.ts",
String(port),
"web/workspace/build",
],
readyUrl: `${url}/health`,
}],
join(root, "tools/web-ux/scenarios/workdir-removal-light.json"),
join(evidence, "process-logs"),
[],
);
const observations: unknown[] = [];
const injectedConsoleErrors: string[] = [];
const injectedUnexpectedErrors: string[] = [];
const observeInjectedFailure = (page: Page) => {
page.on("console", (message) => {
if (message.type() === "error") {
injectedConsoleErrors.push(message.text());
}
});
page.on(
"pageerror",
(error) => injectedUnexpectedErrors.push(String(error)),
);
page.on(
"requestfailed",
(request) => injectedUnexpectedErrors.push(request.url()),
);
};
const control = async (value: unknown) => {
const response = await fetch(`${url}/fixture-workdirs-control`, {
method: "POST",
body: JSON.stringify(value),
});
assert(response.ok);
await response.body?.cancel();
};
const state = async () => await (await fetch(`${url}/fixture-workdirs`)).json();
const browser = await chromium.launch({ headless: true });
try {
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390, 320]) {
await control({ reset: true });
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(String(error)));
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("requestfailed", (request) => errors.push(request.url()));
const route = `${url}/w/home-owner/settings/runtimes/fixture-runtime/workdirs`;
await page.goto(route);
const deletion = page.getByRole("button", {
name: "Delete retry-dir",
exact: true,
});
await deletion.waitFor();
assert(
await deletion.isEnabled(),
"past pending/unknown state permanently disabled ordinary removal",
);
assert(
await page.getByRole("button", {
name: "Delete dirty-dir",
exact: true,
}).isDisabled(),
);
assert(
await page.getByRole("button", {
name: "Delete occupied-dir",
exact: true,
}).isDisabled(),
);
await noOverflow(page);
assertEquals(await page.locator("thead th").allTextContents(), [
"Workdir",
"Repository",
"Checkout",
"Status",
"Cleanliness",
"Occupied by",
"Action",
]);
assertEquals(await page.locator("tbody td:first-child button").count(), 0);
assertEquals(
await page.locator("tbody td").first().evaluate((cell) => ({
padding: getComputedStyle(cell).padding,
secondaryDisplay: getComputedStyle(cell.querySelector("small")!).display,
})),
{ padding: "10px 8px", secondaryDisplay: "block" },
"Shared table density and secondary identity styling must remain applied",
);
assertEquals(await deletion.innerText(), "");
assertEquals(await deletion.locator("svg").count(), 1);
assertEquals(
await deletion.evaluate((button) => {
const r = button.getBoundingClientRect();
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
return {
width: r.width / rem,
height: r.height / rem,
column: (button.closest("td") as HTMLTableCellElement).cellIndex,
};
}),
{ width: 2, height: 2, column: 6 },
"Delete must remain a compact icon in the dedicated Action column",
);
await page.screenshot({
path: join(evidence, `${colorScheme}-${width}-initial.png`),
});
// Narrow screens keep the table scrollable, rather than moving actions into the name.
await deletion.scrollIntoViewIfNeeded();
await noOverflow(page);
// Hold the actual request boundary to verify duplicate gestures cannot dispatch another DELETE.
let release!: () => void;
const gate = new Promise<void>((resolve) => release = resolve);
let observed!: () => void;
const entered = new Promise<void>((resolve) => observed = resolve);
let deletes = 0;
await page.route(
"**/working-directories/retry-dir",
async (intercept) => {
deletes++;
observed();
await gate;
await intercept.continue();
},
);
await deletion.focus();
await page.keyboard.press("Enter");
await page.locator("button[aria-label='Delete retry-dir'][aria-busy='true'] .spinner")
.waitFor();
assert(await deletion.isDisabled());
await page.keyboard.press("Enter");
await entered;
assertEquals(deletes, 1);
await page.screenshot({
path: join(evidence, `${colorScheme}-${width}-deleting.png`),
});
release();
await page.getByRole("alert").filter({
hasText: "mounted filesystem",
}).waitFor();
const retry = page.getByRole("button", {
name: "Retry Delete retry-dir",
exact: true,
});
await retry.waitFor();
await page.waitForFunction(() =>
!(document.querySelector(
"button[aria-label='Retry Delete retry-dir']",
) as HTMLButtonElement)?.disabled
);
const failed = await state();
const beforeGets = failed.requests.filter((
r: { workspace: string; method: string },
) => r.workspace === "home-owner" && r.method === "GET");
assertEquals(
beforeGets.filter((r: { path: string }) => r.path.endsWith("/working-directories"))
.length,
2,
);
assertEquals(
beforeGets.filter((r: { path: string }) => r.path.endsWith("/cleanup-plan")).length,
2,
);
assertEquals(await page.locator("tbody tr").count(), 3);
await noOverflow(page);
await page.screenshot({
path: join(evidence, `${colorScheme}-${width}-mount-failure.png`),
});
await control({ mountReleased: true });
await retry.focus();
await page.keyboard.press("Enter");
await page.getByRole("status").filter({
hasText: "Deletion confirmed",
}).waitFor();
await page.waitForFunction(() => document.querySelectorAll("tbody tr").length === 2);
assertEquals(
await page.getByRole("button", { name: /retry-dir/ }).count(),
0,
);
assert(
await page.getByRole("button", {
name: "Delete dirty-dir",
exact: true,
}).isDisabled(),
);
const final = await state();
const mutations = final.requests.filter((r: { method: string }) => r.method === "DELETE");
assertEquals(mutations.length, 2);
assertEquals(
mutations[0],
mutations[1],
"ordinary retry changed its request",
);
const gets = final.requests.filter((
r: { workspace: string; method: string },
) => r.workspace === "home-owner" && r.method === "GET");
assertEquals(
gets.filter((r: { path: string }) => r.path.endsWith("/working-directories")).length,
3,
);
assertEquals(
gets.filter((r: { path: string }) => r.path.endsWith("/cleanup-plan")).length,
3,
);
await page.screenshot({
path: join(evidence, `${colorScheme}-${width}-removed.png`),
});
await page.goto(
`${url}/w/home-member/settings/runtimes/fixture-runtime/workdirs`,
);
await page.locator("tbody tr").first().waitFor();
assertEquals(
await page.getByRole("button", { name: /Delete/ }).count(),
0,
);
await noOverflow(page);
await page.screenshot({
path: join(evidence, `${colorScheme}-${width}-member.png`),
});
await page.goto(
`${url}/w/home-empty/settings/runtimes/fixture-runtime/workdirs`,
);
await page.getByText("No workdirs are visible for this Runtime.")
.waitFor();
await noOverflow(page);
assertEquals(errors, []);
observations.push({
colorScheme,
width,
requests: final.requests,
errors,
});
} finally {
await context.close();
}
}
}
// Lost/HTTP failure responses still trigger fresh inventory and eligibility. Never echo provider bodies.
await control({ reset: true });
const page = await browser.newPage();
observeInjectedFailure(page);
await page.goto(
`${url}/w/home-error/settings/runtimes/fixture-runtime/workdirs`,
);
await page.getByRole("button", { name: "Delete retry-dir", exact: true })
.click();
await page.getByRole("alert").filter({ hasText: "provider is unavailable" })
.waitFor();
await page.waitForFunction(() =>
!(document.querySelector(
"button[aria-label='Delete retry-dir']",
) as HTMLButtonElement)?.disabled
);
assert(
!(await page.locator("main").innerText()).includes("Synthetic private"),
);
const failed = await state();
assertEquals(
failed.requests.filter((r: { method: string; path: string }) =>
r.method === "GET" && r.path.endsWith("/working-directories")
).length,
2,
);
assertEquals(
failed.requests.filter((r: { method: string; path: string }) =>
r.method === "GET" && r.path.endsWith("/cleanup-plan")
).length,
2,
);
await page.screenshot({ path: join(evidence, "http-failure.png") });
await page.close();
// Plan refresh failure cannot leave stale delete authority enabled; manual Refresh recovers it.
await control({ reset: true });
const stale = await browser.newPage();
observeInjectedFailure(stale);
let planGets = 0;
let planFails = true;
await stale.route("**/cleanup-plan", async (route) => {
planGets++;
if (planGets > 1 && planFails) {
await route.fulfill({
status: 503,
json: { message: "Synthetic private path" },
});
} else await route.continue();
});
await stale.goto(
`${url}/w/home-owner/settings/runtimes/fixture-runtime/workdirs`,
);
await stale.getByRole("button", { name: "Delete retry-dir", exact: true })
.click();
await stale.getByRole("alert").filter({ hasText: "could not be refreshed" })
.waitFor();
assert(
await stale.getByRole("button", {
name: "Retry Delete retry-dir",
exact: true,
}).isDisabled(),
);
assert(
!(await stale.locator("main").innerText()).includes("Synthetic private"),
);
await stale.screenshot({ path: join(evidence, "refresh-failure.png") });
planFails = false;
await stale.getByRole("button", { name: "Refresh", exact: true }).click();
await stale.waitForFunction(() =>
!(document.querySelector(
"button[aria-label='Retry Delete retry-dir']",
) as HTMLButtonElement)?.disabled
);
await stale.close();
// Current dirty changes after a failure override a retryable historical result.
await control({ reset: true });
const changed = await browser.newPage();
let changedGets = 0;
await changed.route("**/cleanup-plan", async (route) => {
const response = await route.fetch();
const plan = await response.json();
if (++changedGets > 1) plan.workdirs[0].action = "workdir_dirty_discard";
await route.fulfill({ response, json: plan });
});
await changed.goto(
`${url}/w/home-owner/settings/runtimes/fixture-runtime/workdirs`,
);
await changed.getByRole("button", { name: "Delete retry-dir", exact: true })
.click();
await changed.getByRole("button", {
name: "Retry Delete retry-dir",
exact: true,
}).waitFor();
await changed.waitForFunction(() =>
document.querySelector("button[aria-label='Retry Delete retry-dir']")?.getAttribute("title")
?.includes("Changes are protected")
);
assert(
await changed.getByRole("button", {
name: "Retry Delete retry-dir",
exact: true,
}).isDisabled(),
);
assertStringIncludes(
await changed.getByRole("button", { name: "Retry Delete retry-dir", exact: true })
.getAttribute("title") ?? "",
"Changes are protected",
);
await changed.screenshot({
path: join(evidence, "new-changes-protected.png"),
});
await changed.close();
// A malformed response may follow a real side effect. Inventory still removes the now-absent row.
await control({ reset: true, mountReleased: true });
const lost = await browser.newPage();
observeInjectedFailure(lost);
await lost.route("**/working-directories/retry-dir", async (route) => {
await route.fetch();
await route.fulfill({
status: 200,
json: {
message: "Synthetic private path",
failure_category: "/private/path token=synthetic",
},
});
});
await lost.goto(
`${url}/w/home-owner/settings/runtimes/fixture-runtime/workdirs`,
);
await lost.getByRole("button", { name: "Delete retry-dir", exact: true })
.click();
await lost.getByRole("alert").filter({
hasText: "Removal could not be confirmed",
}).waitFor();
await lost.waitForFunction(() => document.querySelectorAll("tbody tr").length === 2);
assert(
!(await lost.locator("main").innerText()).includes("Synthetic private"),
);
assert(!(await lost.locator("main").innerText()).includes("/private/path"));
await lost.screenshot({
path: join(evidence, "malformed-response-refreshed.png"),
});
await lost.close();
// Even a failed initial inventory can recover into a confirmed empty list with manual Refresh.
const emptyRecovery = await browser.newPage();
observeInjectedFailure(emptyRecovery);
let inventoryFails = true;
await emptyRecovery.route(
"**/runtimes/fixture-runtime/working-directories",
async (route) => {
if (inventoryFails) {
await route.fulfill({
status: 503,
json: { message: "Synthetic private path" },
});
} else await route.continue();
},
);
await emptyRecovery.goto(
`${url}/w/home-empty/settings/runtimes/fixture-runtime/workdirs`,
);
await emptyRecovery.getByText("Workdir inventory is unavailable.")
.waitFor();
inventoryFails = false;
await emptyRecovery.getByRole("button", { name: "Refresh", exact: true })
.click();
await emptyRecovery.getByText("No workdirs are visible for this Runtime.")
.waitFor();
await emptyRecovery.close();
assertEquals(injectedUnexpectedErrors, []);
assert(
injectedConsoleErrors.every((message) => message.includes("503")),
"unexpected injected failure console diagnostic",
);
await Deno.writeTextFile(
join(evidence, "evidence.json"),
JSON.stringify(
{
observations,
injectedConsoleErrors,
injectedUnexpectedErrors,
failureRefresh: failed.requests,
refreshFailureRecovered: true,
newChangesProtected: true,
malformedResponseReconciled: true,
emptyInventoryRecovered: true,
expectedFailureDiagnostics: [
"Synthetic DELETE HTTP 503 on home-error",
"Synthetic cleanup-plan HTTP 503 after removal",
"Malformed DELETE result after fixture physical removal",
"Synthetic initial inventory HTTP 503",
],
},
null,
2,
),
);
} finally {
await browser.close();
assertEquals(await stopOwnedProcesses(server), []);
}
});