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); });