Ctrl K
ComunidadGuías…

e2e/zzzzzzz-acceptance-resilience.spec.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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();
});