e2e/zzzzzzz-acceptance-resilience.spec.ts
Rama main · réplica de lectura del repositorio original.
import { openBuildTool, clickProjectAction, navigateWorkspace, openProject, openProjectSection } from "./workspace-controls";
import { expect, test, type Page } from "@playwright/test";
import { randomUUID } from "node:crypto";
import AxeBuilder from "@axe-core/playwright";
import { mkdtemp, rm } from "node:fs/promises";
import { createApp } from "../apps/server/dist/app.js";
let closeFixture: (() => Promise<void>) | undefined;
test.afterEach(async () => { await closeFixture?.(); closeFixture = undefined; });
// Each scenario owns a temporary durable SQLite workspace, never a live service.
async function fixture(page: Page, reserve = true) {
const directory = await mkdtemp("/tmp/benchledger-acceptance-");
const app = await createApp({ demo: false, dataDir: directory, publicBaseUrl: "http://127.0.0.1", logger: false, auth: { sessionSecret: randomUUID().repeat(2) } });
closeFixture = async () => { const closed = app.close(); app.server.closeAllConnections(); await closed; await rm(directory, { recursive: true, force: true }); };
const base = await app.listen({ port: 0, host: "127.0.0.1" });
await page.goto(base);
await expect(page.getByRole("heading", { name: "Projects", exact: true })).toBeVisible();
const id = `accept-${randomUUID()}`, revision = `${id}-r1`, itemId = `${id}-stock`;
const csrf = (await page.context().cookies()).find((cookie) => cookie.name === "forge_csrf")!.value;
const post = async (path: string, data: object) => {
const csrf = (await page.context().cookies()).find((cookie) => cookie.name === "forge_csrf")!.value;
const response = await page.request.post(`${base}/api/v1${path}`, { headers: { "x-csrf-token": csrf, "idempotency-key": randomUUID() }, data });
expect(response.status(), await response.text()).toBeLessThan(300);
return response.json();
};
await post("/projects/with-initial-revision", { project: { id, name: `Acceptance fixture ${id}`, status: "building" }, revision: { id: revision, name: "Initial", status: "concept", fabricationRoute: "none" } });
await post("/inventory", { id: itemId, name: "Synthetic acceptance fasteners", kind: "fastener", quantity: 10, unit: "each", tags: [], links: [], evidence: { state: "physically_counted", source: "Synthetic fixture only" } });
const line = (await post(`/project-revisions/${revision}/bom`, { name: "Mounting fasteners", requiredQuantity: 4, unit: "each", role: "consumed", itemId, optional: false, constraints: {}, alternatives: [] })).data;
if (reserve) await post(`/project-revisions/${revision}/reservations`, { lineId: line.id, itemId, quantity: 4 });
await page.goto(`${base}/#/projects/${id}/reconciliation`); await page.reload();
await expect(page.locator(".reconciliation-shell")).toBeVisible();
return { id, revision, itemId, lineId: line.id, post, base };
}
async function result(page: Page) {
await page.getByRole("button", { name: "Add result", exact: true }).click();
await page.getByLabel("What happened", { exact: true }).selectOption("consumed");
await page.getByLabel("Quantity for result 1", { exact: true }).fill("4");
await page.getByLabel("How did you check for result 1", { exact: true }).selectOption("physically_counted");
}
const tab = openProjectSection;
test("used-stock results survive attempted navigation until a deliberate discard", async ({ page }) => {
await fixture(page); await result(page); await tab(page, "Requirements");
await expect(page.getByRole("alertdialog")).toBeVisible();
await page.getByRole("button", { name: "Keep editing", exact: true }).click();
await expect(page.getByLabel("Quantity for result 1")).toHaveValue("4");
});
test("final stock approval traps focus, supports Escape and keeps the page inert", async ({ page }) => {
await fixture(page); await result(page);
await page.getByRole("button", { name: "Review changes", exact: true }).click();
await page.getByRole("button", { name: "Apply stock changes", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Apply these changes?", exact: true });
await expect(dialog.getByRole("button", { name: "Go back", exact: true })).toBeFocused();
for (let i = 0; i < 5; i++) { await page.keyboard.press("Tab"); expect(await dialog.evaluate((node) => node.contains(document.activeElement))).toBe(true); }
await page.keyboard.press("Escape"); await expect(dialog).toHaveCount(0);
await expect(page.getByRole("button", { name: "Apply stock changes", exact: true })).toBeFocused();
});
test("a committed stock update is read-only and the original preview is not editable", async ({ page }) => {
const { itemId, base } = await fixture(page); await result(page);
await page.getByRole("button", { name: "Review changes", exact: true }).click();
await page.getByRole("button", { name: "Apply stock changes", exact: true }).click();
await page.getByRole("dialog", { name: "Apply these changes?" }).getByRole("button", { name: "Apply stock changes", exact: true }).click();
await expect(page.locator(".reconciliation-committed")).toBeVisible();
await expect(page.getByLabel("Quantity for result 1")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Remove result 1" })).toHaveCount(0);
await expect(page.getByLabel("Saved result 1", { exact: true })).toContainText("4 each");
await expect(page.getByLabel("Saved result 1", { exact: true })).toContainText("Stock item: Synthetic acceptance fasteners");
await expect(page.getByRole("heading", { name: "Recorded stock changes", exact: true })).toBeVisible();
await expect(page.locator(".reconciliation-preview-details")).toContainText("1 stock record changed. Review the quantities below.");
await expect(page.locator(".reconciliation-preview-details")).not.toContainText("will change");
const movements = page.getByRole("table", { name: "Saved stock movements", exact: true });
await expect(movements).toBeVisible();
await expect(movements.getByRole("columnheader", { name: "Recorded", exact: true })).toBeVisible();
await expect(page.getByRole("table", { name: "Proposed stock quantities", exact: true })).toHaveCount(0);
const stock = await (await page.request.get(`${base}/api/v1/inventory/${itemId}`)).json(); expect(stock.quantity).toBe(6);
});
test("a lost review response freezes its inputs and resolves by an unchanged retry", async ({ page }) => {
await fixture(page); await result(page); let first = true; const keys: string[] = [];
await page.route("**/reconciliation", async (route) => {
if (route.request().method() !== "PUT") { await route.continue(); return; }
keys.push(route.request().headers()["idempotency-key"]!);
if (first) { first = false; await route.fetch(); await route.abort("failed"); } else await route.continue();
});
await page.getByRole("button", { name: "Review changes", exact: true }).click();
await expect(page.getByRole("button", { name: "Retry unchanged review", exact: true })).toBeVisible();
await expect(page.getByLabel("Quantity for result 1")).toBeDisabled();
await tab(page, "Files"); await expect(page.getByRole("alertdialog", { name: "Finish the pending save" })).toBeVisible();
await page.getByRole("button", { name: "Keep editing", exact: true }).click();
await page.getByRole("button", { name: "Retry unchanged review", exact: true }).click();
await expect(page.getByRole("button", { name: "Apply stock changes", exact: true })).toBeEnabled();
expect(keys).toHaveLength(2); expect(keys[0]).toBe(keys[1]);
});
test("a lost stock-commit response retries once without consuming stock twice", async ({ page }) => {
const { base, itemId } = await fixture(page); await result(page);
await page.getByRole("button", { name: "Review changes", exact: true }).click();
let first = true; const keys: string[] = [];
await page.route("**/reconciliation/commit", async (route) => {
keys.push(route.request().headers()["idempotency-key"]!);
if (first) { first = false; await route.fetch(); await route.abort("failed"); } else await route.continue();
});
await page.getByRole("button", { name: "Apply stock changes", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Apply these changes?" });
await dialog.getByRole("button", { name: "Apply stock changes", exact: true }).click();
await expect(dialog.getByRole("alert")).toBeVisible(); await expect(dialog.getByRole("button", { name: "Go back" })).toBeDisabled();
await page.keyboard.press("Escape"); await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Retry unchanged stock update", exact: true }).click();
await expect(page.locator(".reconciliation-committed")).toBeVisible(); await expect(dialog).toHaveCount(0);
expect(keys).toHaveLength(2); expect(keys[0]).toBe(keys[1]);
const stock = await (await page.request.get(`${base}/api/v1/inventory/${itemId}`)).json(); expect(stock.quantity).toBe(6);
});
test("review-in-flight cannot overwrite edits made during its request", async ({ page }) => {
await fixture(page); await result(page);
let release: (() => void) | undefined;
await page.route("**/reconciliation", async (route) => {
if (route.request().method() !== "PUT") { await route.continue(); return; }
const response = await route.fetch(); await new Promise<void>((resolve) => { release = resolve; }); await route.fulfill({ response });
});
await page.getByRole("button", { name: "Review changes", exact: true }).click();
await expect(page.getByLabel("Quantity for result 1")).toBeDisabled();
await expect(page.getByRole("button", { name: "Remove result 1" })).toBeDisabled();
await expect.poll(() => Boolean(release)).toBe(true); release!();
await expect(page.getByRole("button", { name: "Apply stock changes", exact: true })).toBeEnabled();
await expect(page.getByLabel("Quantity for result 1")).toHaveValue("4");
});
test("an unconfirmed quote selection cannot be removed by a list filter", async ({ page }) => {
const { id, revision, post } = await fixture(page, false);
const line = (await post(`/project-revisions/${revision}/bom`, { name: "Source a connector", requiredQuantity: 1, unit: "each", role: "consumed", optional: false, constraints: {}, alternatives: [] })).data;
await post(`/projects/${id}/revisions/${revision}/requirement-offers`, { bomLineId: line.id, expectedBomLineVersion: 1, supplier: "Synthetic supplier", title: "Test-only connector", url: "https://supplier.example/fixture", packageQuantity: 1, packageUnit: "each", priceMinor: 200, currency: "EUR", observedAt: new Date().toISOString() });
await tab(page, "Shopping list"); await page.getByLabel("I checked that this quoted item meets the current requirement").check();
let first = true; const keys: string[] = [];
await page.route("**/offer-choice", async (route) => {
keys.push(route.request().headers()["idempotency-key"]!);
if (first) { first = false; await route.fetch(); await route.abort("failed"); } else await route.continue();
});
await page.getByRole("button", { name: "Use reviewed quote", exact: true }).click();
await expect(page.getByRole("button", { name: "Retry unchanged selection", exact: true })).toBeVisible();
await expect(page.getByLabel("Search quote requirements")).toBeDisabled();
await expect(page.getByRole("button", { name: "All requirements", exact: true })).toBeDisabled();
await expect(page.getByRole("button", { name: "Refresh supplier quotes", exact: true })).toBeDisabled();
await page.getByRole("button", { name: "Retry unchanged selection", exact: true }).click();
await expect(page.getByRole("button", { name: "Clear quote selection", exact: true })).toBeVisible();
expect(keys[0]).toBe(keys[1]); await expect(page.getByLabel("Search quote requirements")).toBeEnabled();
});
test("inspection confirmation isolates focus and preserves the exact reviewed input on retry", async ({ page }) => {
const { id, revision, post, base } = await fixture(page, false); const itemId = `${id}-led`;
await post("/inventory", { id: itemId, name: "Synthetic inspected LED", kind: "electronic", quantity: 3, unit: "each", tags: [], links: [], evidence: { state: "delivered_uncounted" } });
await post(`/project-revisions/${revision}/bom`, { name: "Test illumination", itemId, requiredQuantity: 1, unit: "each", role: "consumed", optional: false, constraints: {}, alternatives: [] });
await page.goto(`${base}/#/projects/${id}/plan`); await page.reload();
await page.getByRole("button", { name: /^Check Synthetic inspected LED/u }).click();
const dialog = page.getByRole("dialog", { name: "Record the result", exact: true });
await expect(page.getByRole("combobox", { name: "Inspection result", exact: true })).toBeFocused();
await page.getByRole("combobox", { name: "Inspection result", exact: true }).selectOption("confirmed");
await page.getByLabel("Observed quantity (pieces)", { exact: true }).fill("3"); await page.getByRole("combobox", { name: "How did you check?", exact: true }).selectOption("Physical check");
await dialog.getByRole("button", { name: "Preview changes", exact: true }).click();
const review = page.getByRole("dialog", { name: "Review the result", exact: true });
await expect(review.getByRole("button", { name: "Confirm result", exact: true })).toBeVisible();
await expect(review.getByLabel("Observed quantity (pieces)", { exact: true })).toHaveValue("3");
await expect(review.getByRole("region", { name: "Server preview", exact: true })).toContainText("nothing has changed yet");
let first = true; const keys: string[] = [];
await page.route("**/completion-commit", async (route) => { keys.push(route.request().headers()["idempotency-key"]!); if (first) { first = false; await route.fetch(); await route.abort("failed"); } else await route.continue(); });
await review.getByRole("button", { name: "Confirm result", exact: true }).click();
await expect(review.getByRole("button", { name: "Retry unchanged result", exact: true })).toBeVisible();
await expect(page.getByRole("combobox", { name: "Inspection result", exact: true })).toBeDisabled();
await page.keyboard.press("Escape"); await expect(review).toBeVisible();
await review.getByRole("button", { name: "Retry unchanged result", exact: true }).click();
await expect(review).toHaveCount(0); expect(keys).toHaveLength(2); expect(keys[0]).toBe(keys[1]);
});
test("project navigation protects a stock draft before refreshing external updates from the gallery", async ({ page }) => {
const { id, revision, post } = await fixture(page);
await result(page); await navigateWorkspace(page, "Projects");
await expect(page.getByRole("alertdialog")).toBeVisible();
await page.getByRole("button", { name: "Keep editing", exact: true }).click();
await expect(page.getByLabel("Quantity for result 1")).toHaveValue("4");
await tab(page, "Parts"); await page.getByRole("button", { name: "Discard changes and leave", exact: true }).click();
await post(`/project-revisions/${revision}/bom`, { name: "Requirement added by another client", requiredQuantity: 1, unit: "each", role: "consumed", optional: false, constraints: {}, alternatives: [] });
const writes: string[] = [];
page.on("request", request => { if (request.url().includes("/api/") && !["GET", "HEAD", "OPTIONS"].includes(request.method())) writes.push(request.url()); });
await navigateWorkspace(page, "Projects");
await page.getByRole("button", { name: "Filters and view options", exact: true }).click();
await page.getByRole("button", { name: "Refresh workspace", exact: true }).click();
await expect(page.getByRole("button", { name: "Refresh workspace", exact: true })).toBeEnabled();
await openProject(page, `Acceptance fixture ${id}`, "Parts");
await expect(page.locator(".bom-row").filter({ hasText: "Requirement added by another client" })).toBeVisible();
expect(writes).toEqual([]);
});
test("a failed stock-results retry preserves saved parts and recovers when the service returns", async ({ page }) => {
await fixture(page, false); await tab(page, "Parts");
await page.route("**/project-revisions/*/gaps", route => route.fulfill({ status: 503, json: { error: { code: "unavailable", message: "Synthetic readiness outage" } } }));
await page.getByRole("button", { name: "Edit requirement Mounting fasteners", exact: true }).click();
await page.getByLabel("Specification and notes", { exact: true }).fill("Synthetic saved part remains visible during the outage.");
await page.getByRole("button", { name: "Save requirement", exact: true }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
const unavailable = page.locator(".project-readiness-error");
await expect(unavailable).toContainText("Stock results are unavailable");
await page.route("**/workspace", route => route.fulfill({ status: 503, json: { error: { code: "unavailable", message: "Synthetic workspace outage" } } }));
const failedRefresh = page.waitForResponse(response => new URL(response.url()).pathname === "/api/v1/workspace" && response.status() === 503);
await unavailable.getByRole("button", { name: "Retry stock results", exact: true }).click();
await failedRefresh;
await expect(unavailable).toBeVisible();
await expect(page.locator(".bom-row").filter({ hasText: "Mounting fasteners" })).toBeVisible();
await page.unroute("**/workspace"); await page.unroute("**/project-revisions/*/gaps");
await unavailable.getByRole("button", { name: "Retry stock results", exact: true }).click();
await expect(unavailable).toHaveCount(0);
await expect(page.locator(".bom-row").filter({ hasText: "Mounting fasteners" })).toBeVisible();
});
test("explicit navigation discard resets the local build editor when the project is reopened", async ({ page }) => {
const { id } = await fixture(page, false); await tab(page, "Build");
await page.getByRole("button", { name: "Parts and print plates, optional", exact: true }).click();
await openBuildTool(page, "print"); await page.getByRole("button", { name: "Create build plan", exact: true }).click();
await page.getByLabel("Build plan name", { exact: true }).fill("Unsaved local name");
await navigateWorkspace(page, "Inventory");
await page.getByRole("button", { name: "Discard changes and leave", exact: true }).click();
await expect(page.getByRole("heading", { name: "Inventory", exact: true })).toBeVisible();
await openProject(page, `Acceptance fixture ${id}`, "Build");
const optionalPlan = page.getByRole("button", { name: "Parts and print plates, optional", exact: true });
if (await optionalPlan.getAttribute("aria-expanded") === "false") await optionalPlan.click();
await expect(page.getByRole("button", { name: "Create build plan", exact: true })).toBeVisible();
await expect(page.getByLabel("Build plan name", { exact: true })).toHaveCount(0);
});
test("workstream pagination cannot drop a dirty assignment", async ({ page }) => {
const { id, post } = await fixture(page, false);
for (let i = 0; i < 21; i++) await post(`/projects/${id}/workstreams`, { name: `Workstream ${i.toString().padStart(2, "0")}`, kind: "assembly" });
await tab(page, "Build steps"); await openBuildTool(page, "tasks"); await page.locator(".workstream-row > [data-disclosure='trigger']").first().click();
await page.locator(".workstream-row").first().getByRole("textbox", { name: "Task group notes", exact: true }).fill("Keep this assignment");
await page.getByRole("button", { name: "Next task groups", exact: true }).click();
await expect(page.getByRole("alertdialog")).toBeVisible(); await page.getByRole("button", { name: "Keep editing", exact: true }).click();
await expect(page.locator(".workstream-row").first().getByRole("textbox", { name: "Task group notes", exact: true })).toHaveValue("Keep this assignment");
});
for (const colour of ["light", "dark"] as const) test(`durable stock review and approval are accessible at phone width in ${colour}`, async ({ page }) => {
await page.emulateMedia({ colorScheme: colour, reducedMotion: "reduce" });
await page.setViewportSize({ width: 320, height: 800 });
await fixture(page); await result(page);
await expect(page.locator("h1")).toHaveCount(1);
const audit = async () => {
const report = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"]).analyze();
expect(report.violations.map((item) => ({ id: item.id, targets: item.nodes.map((node) => node.target) }))).toEqual([]);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
};
await audit(); await page.getByRole("button", { name: "Review changes", exact: true }).click();
await page.getByRole("button", { name: "Apply stock changes", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Apply these changes?" });
await expect(dialog.getByRole("button", { name: "Apply stock changes", exact: true })).toBeInViewport();
await audit(); await page.keyboard.press("Escape"); await expect(dialog).toHaveCount(0);
});
test("sets stock aside through the UI before recording actual use", async ({ page }) => {
const { itemId, base } = await fixture(page, false);
await tab(page, "Build steps");
await openBuildTool(page, "stock");
await page.getByLabel("Requirement and confirmed stock").selectOption({ label: "Mounting fasteners — Synthetic acceptance fasteners" });
await page.getByLabel("Quantity to set aside (each)", { exact: true }).fill("4");
await page.getByRole("button", { name: "Review stock to set aside", exact: true }).click();
let stock = await (await page.request.get(`${base}/api/v1/inventory/${itemId}`)).json();
expect(stock.quantity).toBe(10); expect(stock.availableQuantity).toBe(10);
await page.getByRole("button", { name: "Confirm set aside", exact: true }).click();
await expect(page.getByText("Stock set aside. Record what was actually used after the build.", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Release stock", exact: true })).toBeEnabled();
stock = await (await page.request.get(`${base}/api/v1/inventory/${itemId}`)).json();
expect(stock.quantity).toBe(10); expect(stock.availableQuantity).toBe(6);
await page.getByRole("button", { name: "Record actual stock use", exact: true }).click();
await result(page);
await page.getByRole("button", { name: "Review changes", exact: true }).click();
await page.getByRole("button", { name: "Apply stock changes", exact: true }).click();
await page.getByRole("dialog", { name: "Apply these changes?", exact: true }).getByRole("button", { name: "Apply stock changes", exact: true }).click();
await expect(page.locator(".reconciliation-committed")).toBeVisible();
stock = await (await page.request.get(`${base}/api/v1/inventory/${itemId}`)).json();
expect(stock.quantity).toBe(6); expect(stock.availableQuantity).toBe(6);
});
test("mobile build tables keep neighbouring stock controls and warnings within the viewport", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.emulateMedia({ colorScheme: "dark", reducedMotion: "reduce" });
await fixture(page, false); await tab(page, "Build steps");
await page.getByRole("button", { name: "Parts and print plates, optional", exact: true }).click();
await openBuildTool(page, "print"); await page.getByRole("button", { name: "Create build plan", exact: true }).click();
await page.getByRole("button", { name: "Add build part", exact: true }).click();
await page.getByLabel("Build part 1 name", { exact: true }).fill("Sensor mounting plate");
await page.getByLabel("Build part 1 quantity", { exact: true }).fill("2");
await page.getByRole("button", { name: "Review build plan", exact: true }).click();
await page.getByRole("button", { name: "Save build plan", exact: true }).click();
await expect(page.getByRole("heading", { name: "Before you build", exact: true })).toBeVisible();
await expect(page.getByText("Sensor mounting plate: no versioned build file is attached.", { exact: true })).toBeVisible();
// The approved build page groups its controls into five sequential steps.
// Check the steps and their actions, not just the outer list's bounding box.
await expect(page.getByRole("list", { name: "Build sequence", exact: true }).locator(":scope > li")).toHaveCount(5);
const bounds = await page.locator('.build-workspace > *, .build-workspace .build-journey > li, .build-workspace .build-journey-step > button, .build-workspace .dialog-actions button:visible, [aria-label="Build checks"]').evaluateAll((elements) => elements.map((element) => {
const rect = element.getBoundingClientRect();
return { text: element.textContent?.slice(0, 50), left: rect.left, right: rect.right, viewport: innerWidth };
}));
expect(bounds.length).toBeGreaterThan(4);
for (const element of bounds) { expect(element.left, JSON.stringify(element)).toBeGreaterThanOrEqual(0); expect(element.right, JSON.stringify(element)).toBeLessThanOrEqual(element.viewport); }
await page.getByRole("button", { name: "Record actual stock use", exact: true }).scrollIntoViewIfNeeded();
await expect(page.getByRole("button", { name: "Record actual stock use", exact: true })).toBeInViewport();
});