e2e/zz-cx-workflow.spec.ts
Rama main · réplica de lectura del repositorio original.
import { openPartFilters, clickProjectAction, navigateWorkspace, openProjectSection } from "./workspace-controls";
import { expect, test, type Page } from "@playwright/test";
async function startProject(page: Page, name: string) {
await page.goto("/");
await page.getByLabel("Workspace password").fill("demo-password-please-change");
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(page.getByRole("heading", { name: "Projects", exact: true })).toBeVisible();
await navigateWorkspace(page, "Projects");
await clickProjectAction(page, "New project");
await page.getByLabel("Project name", { exact: true }).fill(name);
await page.getByLabel("Project goal", { exact: true }).fill("Synthetic maker workflow acceptance.");
await page.getByRole("button", { name: "Create project", exact: true }).click();
await expect(page.getByRole("heading", { name, exact: true })).toBeVisible();
await expect(page.getByRole("region", { name: "Next project action", exact: true })).toHaveCount(1);
await expect(page.locator(".dossier-column .build-approach-card")).toHaveCount(0);
}
async function addRequirement(page: Page, name: string) {
await openProjectSection(page, "Parts");
await page.locator(".bom-section").getByRole("button", { name: /Add first part|Add part/u }).click();
await page.getByLabel("Part name", { exact: true }).fill(name);
await page.getByRole("button", { name: "Add part", exact: true }).click();
}
for (const width of [1440, 320]) test(`maker can correct, remove, restore and hand off a project at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 850 });
const name = `CX workshop ${width}`;
await startProject(page, name);
await addRequirement(page, "Enclosure screws");
await page.getByRole("button", { name: "Edit requirement Enclosure screws", exact: true }).click();
await page.getByLabel("Requirement name", { exact: true }).fill("M3 enclosure screws");
await page.getByLabel("Required quantity", { exact: true }).fill("8");
await page.getByLabel("Specification and notes", { exact: true }).fill("Check length against the drawing.");
await page.getByRole("button", { name: "Save requirement", exact: true }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await page.reload();
await expect(page.locator(".bom-row").filter({ hasText: "M3 enclosure screws" })).toContainText("8 pieces");
await page.getByRole("button", { name: "Edit requirement M3 enclosure screws", exact: true }).click();
await page.getByRole("dialog").getByText("Remove requirement", { exact: true }).click();
await page.getByLabel("I want to remove this requirement from the plan").check();
await page.getByRole("button", { name: "Remove from plan", exact: true }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.locator(".bom-row")).toHaveCount(0);
await page.reload();
await page.getByText("Removed requirements", { exact: true }).click();
await page.getByRole("button", { name: "Restore requirement M3 enclosure screws", exact: true }).click();
await expect(page.locator(".bom-row")).toHaveCount(1);
await clickProjectAction(page, "Edit project");
await page.getByLabel("Project name", { exact: true }).fill(`${name} revised`);
await page.getByLabel("Project goal and brief").fill("Build and test a reusable enclosure fixture.");
await page.getByLabel("Project stage", { exact: true }).selectOption("building");
await page.getByRole("button", { name: "Save project", exact: true }).click();
await expect(page.getByRole("heading", { name: `${name} revised`, exact: true })).toBeVisible();
await page.reload();
await expect(page.locator(".project-stage")).toContainText("Building");
await expect(page.locator(".bom-row")).toHaveCount(1);
await clickProjectAction(page, "Export project");
const jsonEvent = page.waitForEvent("download");
await page.getByRole("menuitem", { name: "Download project brief JSON", exact: true }).click();
const jsonDownload = await jsonEvent;
const chunks: Buffer[] = []; for await (const chunk of (await jsonDownload.createReadStream())!) chunks.push(Buffer.from(chunk));
const handoff = JSON.parse(Buffer.concat(chunks).toString("utf8"));
expect(handoff.project).toMatchObject({ name: `${name} revised`, stage: "building" });
expect(handoff.requirements).toHaveLength(1);
expect(handoff.requirements[0]).toMatchObject({ name: "M3 enclosure screws", quantity: 8 });
await clickProjectAction(page, "Export project");
const csvEvent = page.waitForEvent("download");
await page.getByRole("menuitem", { name: "Download requirements CSV", exact: true }).click();
const csvDownload = await csvEvent; expect(csvDownload.suggestedFilename()).toMatch(/\.csv$/u);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
});
test("a committed requirement stays saved when readiness refresh fails", async ({ page }) => {
await startProject(page, "CX committed create");
let failGaps = false;
await page.route("**/project-revisions/*/gaps", async (route) => {
if (failGaps) await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { code: "unavailable", message: "Synthetic refresh failure" } }) });
else await route.continue();
});
failGaps = true;
await addRequirement(page, "Saved despite refresh outage");
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.locator(".bom-row").filter({ hasText: "Saved despite refresh outage" })).toHaveCount(1);
await expect(page.getByRole("alert").filter({ hasText: "Saved despite refresh outage was saved" })).toBeVisible();
await expect(page.locator(".project-readiness-error")).toContainText("Stock results are unavailable");
failGaps = false;
await page.reload();
await expect(page.locator(".bom-row").filter({ hasText: "Saved despite refresh outage" })).toHaveCount(1);
});
test("an unacknowledged create retries the same command without duplicating the requirement", async ({ page }) => {
await startProject(page, "CX create retry");
let first = true; const keys: string[] = [];
await page.route("**/project-revisions/*/bom", async (route) => {
if (route.request().method() !== "POST") { 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 addRequirement(page, "Exactly one support bracket");
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByLabel("Part name", { exact: true })).toHaveValue("Exactly one support bracket");
await page.getByRole("button", { name: "Try saving again", exact: true }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
expect(keys).toHaveLength(2); expect(keys[0]).toBe(keys[1]);
await page.reload();
await expect(page.locator(".bom-row").filter({ hasText: "Exactly one support bracket" })).toHaveCount(1);
});
test("a rejected requirement correction preserves its draft", async ({ page }) => {
await startProject(page, "CX correction conflict"); await addRequirement(page, "Prototype bracket");
await page.getByRole("button", { name: "Edit requirement Prototype bracket", exact: true }).click();
await page.getByLabel("Required quantity", { exact: true }).fill("12");
await page.route("**/bom-lines/*", (route) => route.fulfill({ status: 409, contentType: "application/json", body: JSON.stringify({ error: { code: "version_conflict", message: "Another editor changed this requirement. Reload before overwriting." } }) }));
await page.getByRole("button", { name: "Save requirement", exact: true }).click();
await expect(page.getByRole("dialog").getByRole("alert")).toContainText("Another editor");
await expect(page.getByLabel("Required quantity", { exact: true })).toHaveValue("12");
});
test("an incomplete acknowledgement is not presented as a confirmed save", async ({ page }) => {
await startProject(page, "CX malformed acknowledgement");
let first = true; const keys: string[] = [];
await page.route("**/project-revisions/*/bom", async (route) => {
if (route.request().method() !== "POST") { await route.continue(); return; }
keys.push(route.request().headers()["idempotency-key"]!);
if (first) {
first = false; const response = await route.fetch(); const body = await response.json();
await route.fulfill({ response, json: { ...body, data: { ...body.data, version: 0 } } });
} else await route.continue();
});
await addRequirement(page, "Acknowledged only once");
await expect(page.getByRole("dialog").getByRole("alert")).toContainText("did not confirm");
await page.getByRole("button", { name: "Try saving again", exact: true }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
expect(keys[0]).toBe(keys[1]); await page.reload();
await expect(page.locator(".bom-row").filter({ hasText: "Acknowledged only once" })).toHaveCount(1);
});
test("large project filters help find parts without changing readiness or the plan", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await startProject(page, "CX larger project");
for (const name of ["Stainless M3 screw", "Nylon M3 spacer", "Cable gland", "Rubber foot", "Steel washer", "Panel nut", "Plastic clip", "Board standoff"]) await addRequirement(page, name);
await expect(page.locator(".bom-row")).toHaveCount(8);
await page.getByLabel("Search project requirements", { exact: true }).fill("M3 stainless");
await expect(page.locator(".bom-row")).toHaveCount(1);
await expect(page.locator(".bom-row")).toContainText("Stainless M3 screw");
await expect(page.locator(".parts-filter-result").getByRole("status")).toContainText("Showing 1 of 8 requirements");
await openPartFilters(page);
await page.getByLabel("Filter project requirements", { exact: true }).selectOption("optional");
await expect(page.locator(".bom-row")).toHaveCount(0);
await expect(page.getByText("No parts match these filters.", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Clear requirement filters", exact: true }).click();
await expect(page.locator(".bom-row")).toHaveCount(8);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.reload(); await expect(page.locator(".bom-row")).toHaveCount(8);
});