From a5246e2aa5bf32387789d9c8267b6814593db3e0 Mon Sep 17 00:00:00 2001 From: Spencer McGuire Date: Fri, 30 Oct 2026 18:17:19 -0600 Subject: [PATCH] Regression bench: the old build's 38 scenarios mapped in tests/e2e/BENCH.md, 20 new Playwright tests, CSP-violation guard on every test Co-Authored-By: Claude Opus 5.5 --- tests/e2e/BENCH.md | 80 ++++++++ tests/e2e/tests/admin-accesslog.spec.ts | 2 +- tests/e2e/tests/admin-departments.spec.ts | 2 +- tests/e2e/tests/admin-notifications.spec.ts | 3 +- tests/e2e/tests/admin-presence.spec.ts | 3 +- tests/e2e/tests/admin-security.spec.ts | 2 +- tests/e2e/tests/admin-stampverify.spec.ts | 3 +- tests/e2e/tests/adminhelpers.ts | 1 + tests/e2e/tests/archive.spec.ts | 97 +++++++++ tests/e2e/tests/checklists.spec.ts | 2 +- tests/e2e/tests/clerical-draft.spec.ts | 4 +- tests/e2e/tests/close.spec.ts | 4 +- tests/e2e/tests/findbar.spec.ts | 4 +- tests/e2e/tests/helpers.ts | 71 ++++++- tests/e2e/tests/pm.spec.ts | 2 +- tests/e2e/tests/presence.spec.ts | 4 +- tests/e2e/tests/projects.spec.ts | 156 +++++++++++++++ tests/e2e/tests/qc.spec.ts | 4 +- tests/e2e/tests/send.spec.ts | 96 +++++++++ tests/e2e/tests/shop.spec.ts | 11 +- tests/e2e/tests/signin.spec.ts | 3 +- tests/e2e/tests/version.spec.ts | 74 +++++++ tests/e2e/tests/viewer.spec.ts | 211 ++++++++++++++++++++ tests/e2e/tests/visual-feed.spec.ts | 182 ++++++++++++++++- tests/e2e/tests/warehouse.spec.ts | 4 +- tests/e2e/tests/whhelpers.ts | 5 +- 26 files changed, 999 insertions(+), 31 deletions(-) create mode 100644 tests/e2e/BENCH.md create mode 100644 tests/e2e/tests/archive.spec.ts create mode 100644 tests/e2e/tests/projects.spec.ts create mode 100644 tests/e2e/tests/send.spec.ts create mode 100644 tests/e2e/tests/version.spec.ts create mode 100644 tests/e2e/tests/viewer.spec.ts diff --git a/tests/e2e/BENCH.md b/tests/e2e/BENCH.md new file mode 100644 index 0000000..6e782c4 --- /dev/null +++ b/tests/e2e/BENCH.md @@ -0,0 +1,80 @@ +# Regression bench: the old 38 scenarios on the new UI + +The old build's bench is `tests/ps/*.js` in the old repo (read-only, fixtures local only). Its scripts logged values +for a person to read; a script "passed" when it printed no page error. Here every scenario is a Playwright test with +real assertions, on the synthetic fixtures (`tests/fixtures/generator.py`) and the throwaway Samba DC, run by the +`e2e` job in `.gitea/workflows/ci.yml` against `deploy/compose.ci.yaml`. + +Every spec imports `test` from `tests/helpers.ts`. Its CSP guard fails a test on any `securitypolicyviolation` event +in any frame, or a Content Security Policy error on the console, in every browser context the test opens. + +Spec and test names below are `file › test`. "Server" points at the pytest file that holds a rule the old script +checked in the browser. "Dropped" gives the reason: the BRIEF.md "Drop" list, or a mechanism the server replaced. + +| # | Old script | What it checked | New coverage | +|---|---|---|---| +| 1 | t8s | Data book PDF of a filed, sealed packet with a Q.C. reject in its history | close › "R1: the data book and the traceability CSV answer as files"; close › "R1: filed in the data book with the PIN; the seal holds and the filed data book downloads". Server: test_pdf_databook.py | +| 2 | t9s | Departments end to end: assign a stall and welder, checklist, weld, submit; the lead cannot review; route to 09 Hydro; Q.C. accepts; Clerical closes and exports | shop › "tsec2: assign, work at the stall, submit, send to Q.C."; shop › "tcust: ... the lead's read-only review ..."; shop › "skip Q.C.: sup.wes routes a review packet to 09 Hydro"; qc › "accept: ..."; close › R1 tests. First-run admin setup and the Users sheet: dropped (roles are AD groups, Drop list) | +| 3 | tauto | Clean VISUAL drafts go to the warehouse on their own; flagged ones stay; sub-assembly borrows the heat table; warehouse grouping by sent date | Server: test_feed_apply.py `test_auto_release`, `test_paused_stops_auto_release`; test_shop_api.py `test_heat_src_borrowing`. visual-feed › "Run now ..." accepts an auto-released packet. Warehouse grouping: warehouse › "warehouse board" | +| 4 | tcl | Only supervisors of the departments a change order names get the banner and Verify | shop › "tcosup: a change order requiring 03 on the Supervisor screen, verified with a signature" (sup.wes gets it; multi.mia, 06, does not) | +| 5 | tclose | Close in VISUAL, file; filing blocked before the close; move back undoes it | close › "R1: confirmed closed in VISUAL ..."; close › "R2: filing is blocked until the job is closed in VISUAL; move back undoes the close" | +| 6 | tclsync4 | The feed makes packets, projects and units; a second pull updates drafts; the vault attaches the drawing; Sync now | visual-feed › "Run now makes project L900, its drafts and attaches the WELD-PART drawing". Server: test_feed_apply.py, test_feed_api.py. The browser-side apply, `rows.json`, `found.json`, `want.json` and the helper check-in: dropped (PowerShell sync in the Drop list; the worker runs the feed) | +| 7 | tcoappr | VISUAL changes on in-shop packets open change orders for Clerical; staged drawing; approve, hold, reject; old sheets VOID; supervisors verify | visual-feed › "VISUAL moves the drawing to rev 2: a change order, staged, approved"; shop › "tcosup ..."; shop › "tsync: two sessions at once" (two verifies). Server: test_co_apply.py (hold, reject, no second change order for the same rows), test_fingerprint.py | +| 8 | tcosup | Change-order banner, stall tag and rows on the Supervisor screen | shop › "tcosup: a change order requiring 03 on the Supervisor screen, verified with a signature" | +| 9 | tcust | Custody: view only, no writes; send back with a reason; short-line exception; change-order verify exception | shop › "tcust: custody, view only, the lead's read-only review, multi.mia cannot assign 03"; clerical-draft › "wh.wade sees a Clerical draft as view only, and no Send button"; warehouse › "pull flow" (shorts). Server: test_policy_matrix.py, test_policy_shop.py, test_policy_warehouse.py | +| 10 | tdemo | Demo data walkthrough: banner text, thumbnails, welder notice | Dropped: demo mode and the claude.ai build target (Drop list). The banner and the review flow are in shop › "tcosup ..." | +| 11 | tflag | Flagged column before Ready to release, flag reasons on cards; one sync progress bar | visual-feed › "tflag and tflagsearch: the Flagged column, the flag texts and the Flag filter". The helper's progress bar: dropped (PowerShell sync and its heartbeat, Drop list; the From VISUAL panel shows the worker's runs) | +| 12 | tflagsearch | Search and filter flagged drafts by reason; list rows show the flag | visual-feed › "tflag and tflagsearch: ..."; findbar › "tflt and tlist: ..." | +| 13 | tflt | Find bar: search keeps focus, Esc, status and station, shorts chip, list view, sort, saved filters | findbar › "tflt: search, Esc, status and station, clear, the shorts chip"; findbar › "tflt and tlist: the list view, sort, Enter opens, saved filters, only the screen's packets" | +| 14 | tfs | Full-screen drawing: grows, survives a re-render, Esc and Exit leave it, leaving the tab ends it; MTR viewer | viewer › "tfs: full screen from the viewer; Exit, Esc and leaving the tab end it"; viewer › "tmtr: ...". Marking in full screen: shop › "tsec2 ..." marks a weld on the Weld map | +| 15 | thand | Clerical uploads the drawing or the BOM by hand on a flagged draft only, with a reason; not the warehouse; the feed keeps a hand BOM | visual-feed › "thand: Clerical uploads the drawing and the BOM by hand on a flagged draft; the warehouse cannot". Server: test_feed_api.py `test_hand_drawing`, `test_hand_bom`; test_handbom.py | +| 16 | tht | Heat table on the drawing: place, drag, size, live heat, VOID page, Q.C. read only | shop › "tht: the heat table on the drawing" | +| 17 | titp | Digital ITP: template, initials, N/A, customer witness, block signatures, hold callout, sign-off PDF, reopen | qc › "titp: the digital ITP, the hold callout, every block signed, sign-off, reopen"; close › "ITP: signed off, its PDF is filed in the unit folder ..."; close › "C3: an open ITP on the unit blocks filing ..." | +| 18 | tlay2 | KPI tap filters and KPIs stay; list view keeps the header; Filters opens and closes; search keeps focus | findbar › "tlay2: a KPI tap filters the board and the KPIs stay; Filters opens and closes" | +| 19 | tlist | List view shows the same packets as that role's board | findbar › "tflt and tlist: ..." | +| 20 | tmat | Job pull materials land on VISUAL packets: other drawings' lines left off, sub-assemblies kept, no change on a second pull, not overwritten in the shop | visual-feed › "tmat: the work order's materials from VISUAL are the WELD-PART packet's BOM". Server: test_feed_apply.py, test_candidates.py. `materials.json` and the browser apply: dropped (Drop list) | +| 21 | tmotion | Motion switch, reduced motion, a card moved by someone else glides | findbar › "tmotion: the switch, reduced motion, and a card moved by someone else glides" | +| 22 | tmtr | MTR upload (PDF and PNG) per line, view in the viewer, open in a new tab | viewer › "tmtr: a PDF and a PNG MTR, each shown in the MTR viewer and served for a new tab"; warehouse › "pull flow" | +| 23 | tnav | Back goes one level back to the same scroll spot | findbar › "tnav: Back from a packet lands on the home at the same scroll spot" | +| 24 | tpan2 | Touch drags pan a zoomed drawing; after re-renders a drag still pans once | viewer › "tpan2: a mouse drag pans by exactly the drag after re-renders"; viewer › "tpan2: on a touch device each finger drag moves the view" | +| 25 | tpdocs | Packet documents for the data book: Q.C. adds, a non-PDF is refused, read-only for others, taken out with a reason | qc › "tpdocs: a packet document added, refused, seen read-only, taken out" | +| 26 | tpm | PM screen: overview, drill down, tabs, scope, exports, view only | pm › "tpm: overview, projects, drill down, tabs, scope, exports, view only"; pm › "tpm: the period changes the history figures"; pm › "C10: ..." | +| 27 | tpop | Sheets keep their margins on desktop and phone widths | findbar › "tpop: the New packet sheet keeps its margins on desktop and phone widths" | +| 28 | tpr | Print menu kinds, traveler, data book, print logged to the audit | close › "R1: the print menu lists the kinds; a traveler print is logged and shows on the History tab"; close › "ITP: ..." (ITP print); close › "api: print guards and a signed-out read"; admin-stampverify (the printed stamp). Server: test_prints.py, test_pdf_traveler.py | +| 29 | tqcf | Q.C. project and unit folders, a new revision supersedes, non-PDF refused, no Add for the warehouse | qc › "tqcf: project and unit folders, a new revision, a refused file, no Q.C. screen for wh.wade" | +| 30 | trel | Clerical's Released section: tiles per place with overdue and 7+ day counts, list, open, Back | Not covered: the new Clerical home has no Released section (old `released.js`). See the note below | +| 31 | trevchk2 | M-Files rev vs VISUAL rev: flags with drafting status, mismatch callout, a Clerical lead approves with a reason, the flag returns on a new VISUAL rev | visual-feed › "tflag and tflagsearch: ..."; visual-feed › "trevchk2: a Clerical supervisor approves the M-Files rev with a reason and a signature". Server: test_drawing_decide.py (`test_approved_other_rev_still_mismatches` and the rest), test_feed_api.py `test_rev_approve` | +| 32 | tsec2 | Signatures end to end, wrong PIN, cancel, seal, admin reason, idle lock, formula guard, integrity, access log | shop › "tsec2 ..."; signin › "8. three idle minutes lock the screen and the PIN unlocks it"; close › "R1: filed ... with the PIN ..." and "R1: admin.ann reopens with a reason and her password ..."; admin-security; admin-accesslog › "CSV export: a cell that starts with = is guarded". Second-admin approval: dropped (Drop list) | +| 33 | tsend | Heat map answer first; send straight to a department or to Q.C.; send back to Clerical with a reason | send › "tsend: the heat map answer and the drawing come first; straight to a department, straight to Q.C."; send › "tsend: the department's supervisor sees it and sends it back to Clerical with a reason"; clerical-draft › "clerk.clara makes a hand draft and sends it to the warehouse" | +| 34 | tsync | Two screens at once: merges, renumbered weld ids, two verifies kept, a stale step refused, filed packet blocked, long history | shop › "tsync: two sessions at once". Server: test_pipeline_concurrency.py, test_shop_concurrency.py | +| 35 | tsyncsafe | The feed never overwrites shop packets or records and never makes duplicates | Server: test_feed_apply.py (`test_removed_packet_key_is_never_recreated`, `test_hand_customer_is_never_overwritten`, change orders for in-shop packets), test_candidates.py. The browser-side apply it raced: dropped (Drop list) | +| 36 | tunits | Projects and units: many units at once, duplicate refused, move a packet, renumber with an alias, closed unit | projects › "tunits: a new project with three units at once; a duplicate unit is refused"; projects › "tunits: a packet moves to another unit through the two-step picker; the warehouse cannot move one"; projects › "tunits: a Clerical supervisor renumbers a unit; its packet follows and the old number is an alias"; projects › "tunits: a closed unit takes no new packet". Server: test_projects_api.py | +| 37 | tvis | Excel Job Release import in the browser; op-to-department map | Dropped: Excel upload of Job Release Information (Drop list); the feed replaces it (row 6). The resource map: server test_feed_api.py | +| 38 | twh | Warehouse board: stages, hold, drag, drop on Released refused, auto-start on a tick, stage shown to others | warehouse › "warehouse board"; warehouse › "sup.wes sees the warehouse pill and line but no warehouse buttons"; warehouse › "pull flow" | + +## Phase 10 additions beyond the old bench + +- archive › "Admin saves the archive folder under File locations; the check passes" and "a filed packet is copied to + the archive folder" (Gitea issue 7). +- version › "the app page carries the final Content Security Policy and the other headers" and "a new build on the + server shows the banner; it reloads only on Reload" (the update lock in the Drop list is replaced by the banner). +- The CSP guard in `helpers.ts` on every test, and version › "the CSP guard sees a refused inline style and a + refused inline script" to prove the guard catches a violation. + +## Note on trel + +The old Clerical home had a "Released" section (`src/js/released.js` in the old repo): where every released packet +is, by place, with overdue and 7+ day counts. It is not in the Drop list and the new UI does not have it. The find +bar (status and department filters), the Supervisor and Warehouse boards and the PM screen's Aging tab show the same +packets another way. Whether that is enough is a question for Seth; until then this row stays uncovered. + +## Running it + +```bash +python -m tests.fixtures.generator --out tests/fixtures/generated +docker compose -p packetstation-ci --env-file deploy/ci/ci.env -f deploy/compose.yaml -f deploy/compose.ci.yaml up -d --build --wait +docker compose -p packetstation-ci --env-file deploy/ci/ci.env -f deploy/compose.yaml -f deploy/compose.ci.yaml --profile e2e run --rm playwright +``` + +The specs share one database and run in file order on one worker. visual-feed writes the fixture state and edits +`drawings.1.json` before its first run, so the stack must start from freshly generated fixtures. diff --git a/tests/e2e/tests/admin-accesslog.spec.ts b/tests/e2e/tests/admin-accesslog.spec.ts index 55513ef..febfe37 100644 --- a/tests/e2e/tests/admin-accesslog.spec.ts +++ b/tests/e2e/tests/admin-accesslog.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./helpers"; import { api, closeAll } from "./shophelpers"; import type { Json } from "./whhelpers"; import { admin, badSignin, csvFrom, me, openAdminTab, parseCsv, runId } from "./adminhelpers"; diff --git a/tests/e2e/tests/admin-departments.spec.ts b/tests/e2e/tests/admin-departments.spec.ts index 7797c60..0c3cdf9 100644 --- a/tests/e2e/tests/admin-departments.spec.ts +++ b/tests/e2e/tests/admin-departments.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./helpers"; import { api, closeAll } from "./shophelpers"; import type { Json } from "./whhelpers"; import { admin, call, openAdminTab, runId } from "./adminhelpers"; diff --git a/tests/e2e/tests/admin-notifications.spec.ts b/tests/e2e/tests/admin-notifications.spec.ts index 7983ada..eaff278 100644 --- a/tests/e2e/tests/admin-notifications.spec.ts +++ b/tests/e2e/tests/admin-notifications.spec.ts @@ -1,4 +1,5 @@ -import { expect, test, type Locator, type Page } from "@playwright/test"; +import { type Locator, type Page } from "@playwright/test"; +import { expect, test } from "./helpers"; import { api, closeAll, type Actor } from "./shophelpers"; import type { Json } from "./whhelpers"; import { admin, call, me, openAdminTab, runId } from "./adminhelpers"; diff --git a/tests/e2e/tests/admin-presence.spec.ts b/tests/e2e/tests/admin-presence.spec.ts index 09bae13..b1d095b 100644 --- a/tests/e2e/tests/admin-presence.spec.ts +++ b/tests/e2e/tests/admin-presence.spec.ts @@ -1,5 +1,4 @@ -import { expect, test } from "@playwright/test"; -import { dcUser } from "./helpers"; +import { expect, test, dcUser } from "./helpers"; import { actor, api, closeAll } from "./shophelpers"; import type { Json } from "./whhelpers"; import { admin, me, openAdminTab } from "./adminhelpers"; diff --git a/tests/e2e/tests/admin-security.spec.ts b/tests/e2e/tests/admin-security.spec.ts index 3e504ac..c32e550 100644 --- a/tests/e2e/tests/admin-security.spec.ts +++ b/tests/e2e/tests/admin-security.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./helpers"; import { api, closeAll } from "./shophelpers"; import type { Json } from "./whhelpers"; import { admin, badSignin, call, openAdminTab } from "./adminhelpers"; diff --git a/tests/e2e/tests/admin-stampverify.spec.ts b/tests/e2e/tests/admin-stampverify.spec.ts index ee56cab..ee78c4e 100644 --- a/tests/e2e/tests/admin-stampverify.spec.ts +++ b/tests/e2e/tests/admin-stampverify.spec.ts @@ -1,5 +1,4 @@ -import { expect, test } from "@playwright/test"; -import { dcUser } from "./helpers"; +import { expect, test, dcUser } from "./helpers"; import { actor, api, closeAll } from "./shophelpers"; import { makeDraft, makeProject, makeUnit, runSuffix, type Made } from "./pmhelpers"; import type { Json } from "./whhelpers"; diff --git a/tests/e2e/tests/adminhelpers.ts b/tests/e2e/tests/adminhelpers.ts index da09a57..30bb72e 100644 --- a/tests/e2e/tests/adminhelpers.ts +++ b/tests/e2e/tests/adminhelpers.ts @@ -18,6 +18,7 @@ export const A = { accesslog: "Access log", stampverify: "Stamp check", presence: "Who is in", + storage: "Storage", }, } as const; diff --git a/tests/e2e/tests/archive.spec.ts b/tests/e2e/tests/archive.spec.ts new file mode 100644 index 0000000..a9630ca --- /dev/null +++ b/tests/e2e/tests/archive.spec.ts @@ -0,0 +1,97 @@ +import { expect, test } from "./helpers"; +import { admin, call, openAdminTab } from "./adminhelpers"; +import { atCompleteApi, makeCloseProject } from "./closehelpers"; +import type { Crew } from "./qchelpers"; +import { actor, api, closeAll, packet, sign, stepApi, userId } from "./shophelpers"; +import type { Json } from "./whhelpers"; + +// Phase 10 archive location (Gitea issue 7). The CI stack runs with ARCHIVE_ROOT=/archive and an existing folder +// /archive/packets (compose.ci.yaml). admin.ann opens Admin > Storage > File locations, a path outside the root is +// refused, "packets" passes the check and is saved, a packet is filed, and the copy shows as done with nothing failed. +// Clerical cannot read or change the setting. + +const RUN = Date.now().toString(36).toUpperCase().slice(-5); +const PROJECT = `AR${RUN}`; + +test.describe.serial("archive", () => { + test.setTimeout(240_000); + + test("Admin saves the archive folder under File locations; the check passes", async ({ browser }) => { + const ann = await admin(browser); + const page = ann.page; + const panel = await openAdminTab(page, "storage"); + const box = panel.getByRole("region", { name: "File locations" }); + await expect(box).toBeVisible(); + await expect(box.getByText("Archive root: /archive")).toBeVisible(); + const folder = box.getByLabel("Archive folder"); + + // A path that leaves the root is refused by the check + await folder.fill("../etc"); + await box.getByRole("button", { name: "Check", exact: true }).click(); + await expect(box.getByText(/^Check passed/)).toHaveCount(0); + const bad = await call(ann, "post", "/api/admin/file-locations/check", { archive_dir: "../etc" }); + expect(bad.status).toBe(422); + expect(bad.body.code).toBe("bad_path"); + + // packets exists under the root: the check passes, then Save + await folder.fill("packets"); + await box.getByRole("button", { name: "Check", exact: true }).click(); + await expect(box.getByText(/^Check passed\. \/archive\/packets can be written\./)).toBeVisible(); + await box.getByRole("button", { name: "Save", exact: true }).click(); + await expect(box.getByText("Archive folder saved.")).toBeVisible(); + await expect(box.getByTestId("archive-saved")).toContainText("packets"); + await expect(box.getByTestId("archive-saved")).toContainText("/archive/packets"); + await expect(box.getByTestId("archive-last-check")).toContainText("passed"); + + const fl = await api(ann, "get", "/api/admin/file-locations"); + expect(fl.enabled).toBe(true); + expect(fl.root).toBe("/archive"); + expect(fl.archive_dir).toBe("packets"); + expect(fl.last_check?.ok).toBe(true); + await closeAll(ann); + + // Not for Clerical + const clara = await actor(browser, "clerk.clara"); + expect((await call(clara, "get", "/api/admin/file-locations")).status).toBe(403); + expect((await call(clara, "put", "/api/admin/file-locations", { archive_dir: "packets", version: fl.version })).status).toBe(403); + await closeAll(clara); + }); + + test("a filed packet is copied to the archive folder", async ({ browser }) => { + const clara = await actor(browser, "clerk.clara"); + const wade = await actor(browser, "wh.wade"); + const wes = await actor(browser, "sup.wes"); + const andy = await actor(browser, "weld.andy"); + const quinn = await actor(browser, "qc.quinn"); + const crew: Crew = { clara, wade, wes, andy, andyId: await userId(andy, "weld.andy") }; + const proj = await makeCloseProject(clara, PROJECT, "Archive skid"); + const p = await atCompleteApi(crew, quinn, proj.unit, `Archive spool ${RUN}`, "Stall 2"); + await api(clara, "post", `/api/packets/${p.id}/vclosed`, { closed: true }); + const filed = await stepApi(clara, p.id, "file", { sign: sign("clerk.clara") }); + expect(filed.status).toBe("filed"); + expect((await packet(clara, p.id)).seal).toBeTruthy(); + await closeAll(clara, wade, wes, andy, quinn); + + // FILE asks the worker for a copy at once. The Admin section shows it done. + const ann = await admin(browser); + await expect + .poll( + async () => { + const fl = await api(ann, "get", "/api/admin/file-locations"); + const c = fl.copies as Json; + const failed = ((c.recent_failures ?? []) as Json[]).find((f) => Number(f.packet_id) === p.id); + if (failed) return `failed: ${failed.last_error}`; + return c.done >= 1 && c.failed === 0 && c.pending === 0 ? "done" : `pending ${c.pending}`; + }, + { timeout: 120_000, intervals: [2_000, 3_000, 5_000] }, + ) + .toBe("done"); + const panel = await openAdminTab(ann.page, "storage"); + const box = panel.getByRole("region", { name: "File locations" }); + await box.getByRole("button", { name: "Refresh" }).click(); + await expect(box.getByTestId("archive-copies")).toHaveText(/^Copies: [1-9]\d* done, 0 waiting, 0 failed\.$/); + await expect(box.getByRole("table", { name: "Failed copies" })).toHaveCount(0); + await expect(box.getByRole("button", { name: "Retry now" })).toHaveCount(0); + await closeAll(ann); + }); +}); diff --git a/tests/e2e/tests/checklists.spec.ts b/tests/e2e/tests/checklists.spec.ts index 67f228e..1ac2bc0 100644 --- a/tests/e2e/tests/checklists.spec.ts +++ b/tests/e2e/tests/checklists.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./helpers"; import { actor, api, closeAll } from "./shophelpers"; import { call } from "./adminhelpers"; import type { Json } from "./whhelpers"; diff --git a/tests/e2e/tests/clerical-draft.spec.ts b/tests/e2e/tests/clerical-draft.spec.ts index 4b2e675..45e84ec 100644 --- a/tests/e2e/tests/clerical-draft.spec.ts +++ b/tests/e2e/tests/clerical-draft.spec.ts @@ -1,8 +1,8 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { expect, test, type Page } from "@playwright/test"; -import { dcUser, signInPassword } from "./helpers"; +import { type Page } from "@playwright/test"; +import { expect, test, dcUser, signInPassword } from "./helpers"; // Phase 2 Clerical scenario on the CI stack. Runs after signin.spec.ts on the one worker. // clerk.clara sets up project L999 with unit 001, makes a hand draft, attaches a drawing with a diff --git a/tests/e2e/tests/close.spec.ts b/tests/e2e/tests/close.spec.ts index 92572b4..9449454 100644 --- a/tests/e2e/tests/close.spec.ts +++ b/tests/e2e/tests/close.spec.ts @@ -1,5 +1,5 @@ -import { expect, test, type Page } from "@playwright/test"; -import { PIN } from "./helpers"; +import { type Page } from "@playwright/test"; +import { expect, test, PIN } from "./helpers"; import { actor, api, diff --git a/tests/e2e/tests/findbar.spec.ts b/tests/e2e/tests/findbar.spec.ts index 2ad2462..ee572a9 100644 --- a/tests/e2e/tests/findbar.spec.ts +++ b/tests/e2e/tests/findbar.spec.ts @@ -1,5 +1,5 @@ -import { expect, test, type Page } from "@playwright/test"; -import { signInPassword } from "./helpers"; +import { type Page } from "@playwright/test"; +import { expect, test, signInPassword } from "./helpers"; import { actor, api, closeAll, stepApi } from "./shophelpers"; import { L, diff --git a/tests/e2e/tests/helpers.ts b/tests/e2e/tests/helpers.ts index 338a4a0..66491e8 100644 --- a/tests/e2e/tests/helpers.ts +++ b/tests/e2e/tests/helpers.ts @@ -1,5 +1,74 @@ import { readFileSync } from "node:fs"; -import { expect, type Locator, type Page } from "@playwright/test"; +import { test as base, expect, type Browser, type BrowserContext, type Locator, type Page } from "@playwright/test"; + +// ---------------------------------------------------------------- CSP guard +// +// Every spec imports `test` from here. The guard fails a test when any page it opened reports a Content Security +// Policy violation: a `securitypolicyviolation` event in any frame, or a CSP error on the console (workers and +// anything the event misses). It covers the default `context` and `page` fixtures and every context a test makes +// with browser.newContext() or browser.newPage(). + +const CSP_CONSOLE = /Content Security Policy|Refused to (load|apply|execute|connect|frame|create|evaluate)/i; +const guarded = new WeakSet(); + +async function guardContext(ctx: BrowserContext, log: string[]): Promise { + if (guarded.has(ctx)) return; + guarded.add(ctx); + await ctx.exposeBinding("__psCspViolation", ({ frame }, text: string) => { + log.push(`${text} (on ${frame.url()})`); + }); + await ctx.addInitScript(() => { + document.addEventListener( + "securitypolicyviolation", + (e) => { + const w = window as unknown as { __psCspViolation?: (t: string) => Promise }; + try { + void w.__psCspViolation?.( + `securitypolicyviolation: ${e.effectiveDirective || e.violatedDirective} blocked ${e.blockedURI || "(inline)"}` + + ` at ${e.sourceFile || "?"}:${e.lineNumber}`, + ); + } catch { + // a frame torn down mid-report + } + }, + true, + ); + }); + const watch = (page: Page) => { + page.on("console", (m) => { + if (m.type() === "error" && CSP_CONSOLE.test(m.text())) log.push(`console: ${m.text()} (on ${page.url()})`); + }); + }; + ctx.pages().forEach(watch); + ctx.on("page", watch); +} + +export const test = base.extend<{ cspGuard: string[] }>({ + cspGuard: [ + async ({ browser }, use) => { + const log: string[] = []; + const orig = browser.newContext; + browser.newContext = async function (this: Browser, ...args: Parameters) { + const ctx = await orig.apply(this, args); + await guardContext(ctx, log); + return ctx; + }; + try { + await use(log); + } finally { + browser.newContext = orig; + } + expect(log, "Content Security Policy violations").toEqual([]); + }, + { auto: true }, + ], + context: async ({ context, cspGuard }, use) => { + await guardContext(context, cspGuard); + await use(context); + }, +}); + +export { expect }; // users.json is written by deploy/samba/seed.sh on the ci-dc-export volume. // Shape: {"domain": "pstest.local", "users": {"": {"password", "emp_no", "display_name"}}} diff --git a/tests/e2e/tests/pm.spec.ts b/tests/e2e/tests/pm.spec.ts index 8fc0746..3a62d2a 100644 --- a/tests/e2e/tests/pm.spec.ts +++ b/tests/e2e/tests/pm.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./helpers"; import { actor, api, closeAll, packet, stepApi } from "./shophelpers"; import { L, diff --git a/tests/e2e/tests/presence.spec.ts b/tests/e2e/tests/presence.spec.ts index 7cc8d69..b205702 100644 --- a/tests/e2e/tests/presence.spec.ts +++ b/tests/e2e/tests/presence.spec.ts @@ -1,5 +1,5 @@ -import { expect, test, type Page } from "@playwright/test"; -import { dcUser } from "./helpers"; +import { type Page } from "@playwright/test"; +import { expect, test, dcUser } from "./helpers"; import { actor, api, closeAll } from "./shophelpers"; import { L, makeDraft, makeProject, makeUnit, pullWithHeat, runSuffix, type Made } from "./pmhelpers"; diff --git a/tests/e2e/tests/projects.spec.ts b/tests/e2e/tests/projects.spec.ts new file mode 100644 index 0000000..5c19e08 --- /dev/null +++ b/tests/e2e/tests/projects.spec.ts @@ -0,0 +1,156 @@ +import { expect, test } from "./helpers"; +import { makeDraft, runSuffix } from "./pmhelpers"; +import { actor, api, closeAll, packet, type Actor } from "./shophelpers"; +import { getJson, type Json } from "./whhelpers"; + +// Phase 10, the old tunits on the new UI. clerk.clara makes a project with three units at once (the Add units sheet's +// "Several units"), a duplicate unit number is refused, a packet moves to another unit through the two-step picker, +// and a closed unit takes no new packet. clerk.leo (Clerical supervisor) renumbers a unit; the packet follows, the +// old number stays an alias and cannot be taken again. clara has no Renumber, and the warehouse cannot move a packet. + +let RUN = ""; +let PROJECT = ""; +let projectId = 0; +const units: Record = {}; +let packetId = 0; + +async function unitsOf(a: Actor): Promise { + const p = await getJson(a.page.request, `/api/projects/${projectId}`); + return (p.units ?? []) as Json[]; +} + +test.describe.serial("projects and units", () => { + test("tunits: a new project with three units at once; a duplicate unit is refused", async ({ browser }, info) => { + RUN = runSuffix(info.project.name); + PROJECT = `U${RUN}`; + const clara = await actor(browser, "clerk.clara"); + const page = clara.page; + await page.goto("/clerical"); + await page.getByRole("button", { name: "New project" }).click(); + const proj = page.getByRole("dialog", { name: "New project" }); + // typed in lower case: the number is kept in capitals + await proj.getByLabel("Project number").fill(PROJECT.toLowerCase()); + await proj.getByLabel("Project name").fill("CI units"); + await proj.getByLabel("Customer", { exact: true }).fill("CI Customer"); + await proj.getByRole("button", { name: "Create project" }).click(); + + const add = page.getByRole("dialog", { name: "Add units" }); + await expect(add).toBeVisible(); + await add.getByRole("group", { name: "One or several" }).getByRole("button", { name: "Several units" }).click(); + await add.getByLabel("How many").fill("3"); + await add.getByLabel("Starting lot").fill("001"); + await expect(add.getByText(`Numbers: ${PROJECT}001, ${PROJECT}002, ${PROJECT}003`)).toBeVisible(); + await add.getByLabel("Description (all of them)").fill("CI separator"); + await add.getByRole("button", { name: "Add", exact: true }).click(); + await expect(page).toHaveURL(/\/clerical\/projects\/\d+$/); + projectId = Number(new URL(page.url()).pathname.split("/").pop()); + await expect(page.getByRole("heading", { level: 1, name: `${PROJECT} · CI units` })).toBeVisible(); + for (const lot of ["001", "002", "003"]) { + await expect(page.getByRole("row", { name: new RegExp(`${PROJECT}${lot}`) })).toBeVisible(); + } + for (const u of await unitsOf(clara)) units[String(u.lot)] = u; + expect(Object.keys(units).sort()).toEqual(["001", "002", "003"]); + + // The same unit number again is refused with the reason + await page.getByRole("button", { name: "Add units" }).click(); + await expect(add).toBeVisible(); + await add.getByRole("group", { name: "One or several" }).getByRole("button", { name: "One unit" }).click(); + await add.getByLabel("Lot", { exact: true }).fill("002"); + await add.getByRole("button", { name: "Add", exact: true }).click(); + await expect(add.getByRole("alert")).toHaveText(`${PROJECT}002 already exists as a unit.`); + await add.getByRole("button", { name: "Cancel" }).click(); + expect((await unitsOf(clara)).length).toBe(3); + await closeAll(clara); + }); + + test("tunits: a packet moves to another unit through the two-step picker; the warehouse cannot move one", async ({ + browser, + }) => { + const clara = await actor(browser, "clerk.clara"); + const u1 = units["001"] as Json; + const made = await makeDraft(clara, { id: Number(u1.id), number: String(u1.number), lot: "001" }, `Move spool ${RUN}`); + packetId = made.id; + const page = clara.page; + await page.goto(`/packets/${packetId}`); + await page.getByRole("button", { name: "Move", exact: true }).click(); + const sheet = page.getByRole("dialog", { name: "Move to another unit" }); + await expect(sheet).toBeVisible(); + // The project is picked already (the packet's own); step two is the unit + await expect(sheet.getByText(PROJECT, { exact: true }).first()).toBeVisible(); + // Moving to its own unit is refused in the sheet + await sheet.getByLabel("Unit", { exact: true }).selectOption({ label: `${PROJECT}001 · CI separator` }); + await sheet.getByRole("button", { name: "Move packet" }).click(); + await expect(sheet.getByRole("alert")).toHaveText("The packet is in that unit already."); + await sheet.getByLabel("Unit", { exact: true }).selectOption({ label: `${PROJECT}002 · CI separator` }); + await sheet.getByLabel("Reason (optional)").fill("Built on the second unit"); + await sheet.getByRole("button", { name: "Move packet" }).click(); + await expect(page.getByText("Packet moved.")).toBeVisible(); + await expect.poll(async () => String((await packet(clara, packetId)).unit.number)).toBe(`${PROJECT}002`); + await closeAll(clara); + + // The warehouse may not move a packet (server check) + const wade = await actor(browser, "wh.wade"); + const u3 = units["003"] as Json; + await api(wade, "post", `/api/packets/${packetId}/move`, { unit_id: u3.id }, { status: 403 }); + expect(String((await packet(wade, packetId)).unit.number)).toBe(`${PROJECT}002`); + await closeAll(wade); + }); + + test("tunits: a Clerical supervisor renumbers a unit; its packet follows and the old number is an alias", async ({ + browser, + }) => { + const clara = await actor(browser, "clerk.clara"); + const u2 = units["002"] as Json; + await clara.page.goto(`/clerical/units/${u2.id}`); + await expect(clara.page.getByRole("heading", { level: 1, name: new RegExp(`^${PROJECT}002`) })).toBeVisible(); + await expect(clara.page.getByRole("button", { name: "Renumber" })).toHaveCount(0); + await closeAll(clara); + + const leo = await actor(browser, "clerk.leo"); + const page = leo.page; + await page.goto(`/clerical/units/${u2.id}`); + await page.getByRole("button", { name: "Renumber" }).click(); + const sheet = page.getByRole("dialog", { name: "Renumber unit" }); + await expect(sheet).toBeVisible(); + await sheet.getByLabel("New lot").fill("010"); + await sheet.getByRole("button", { name: "Change number" }).click(); + await expect(sheet.getByRole("alert")).toHaveText("Give a reason."); + await sheet.getByLabel("Reason").fill("Customer tag numbers changed"); + await sheet.getByRole("button", { name: "Change number" }).click(); + await expect(page.getByText(`Unit renumbered to ${PROJECT}010`)).toBeVisible(); + await expect(page.getByRole("heading", { level: 1, name: new RegExp(`^${PROJECT}010`) })).toBeVisible(); + // The packet follows its unit + expect(String((await packet(leo, packetId)).unit.number)).toBe(`${PROJECT}010`); + // The old number still finds the unit, as an alias, and cannot be used again + const found = await getJson(page.request, `/api/lookup/number?n=${PROJECT}002`); + expect(found.kind).toBe("unit"); + expect(Number(found.id)).toBe(Number(u2.id)); + expect(found.alias).toBeTruthy(); + const again = await api(leo, "post", `/api/projects/${projectId}/units`, { lot: "002" }, { status: 409 }); + expect(String(again.detail ?? again.message ?? JSON.stringify(again))).toContain(`old number of ${PROJECT}010`); + await closeAll(leo); + }); + + test("tunits: a closed unit takes no new packet", async ({ browser }) => { + const clara = await actor(browser, "clerk.clara"); + const page = clara.page; + const u3 = units["003"] as Json; + await page.goto(`/clerical/units/${u3.id}`); + await expect(page.getByRole("button", { name: "Add packet" })).toBeVisible(); + await page.getByRole("button", { name: "Edit unit" }).click(); + const sheet = page.getByRole("dialog", { name: "Edit unit" }); + await sheet.getByLabel("Status").selectOption({ label: "Closed" }); + await sheet.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByText("Unit saved")).toBeVisible(); + await expect(page.getByText(/· Closed$/).first()).toBeVisible(); + await expect(page.getByRole("button", { name: "Add packet" })).toHaveCount(0); + // The server refuses a packet on it too + const r = await page.request.post("/api/packets", { + headers: { Origin: new URL(page.url()).origin, "X-Requested-With": "PacketStation" }, + data: { unit_id: u3.id, dept_code: "03", op: "WELD-PART", title: `Closed ${RUN}` }, + }); + expect(r.status(), await r.text()).toBeGreaterThanOrEqual(400); + expect(r.status()).toBeLessThan(500); + await closeAll(clara); + }); +}); diff --git a/tests/e2e/tests/qc.spec.ts b/tests/e2e/tests/qc.spec.ts index 8da976e..a955d31 100644 --- a/tests/e2e/tests/qc.spec.ts +++ b/tests/e2e/tests/qc.spec.ts @@ -1,5 +1,5 @@ -import { expect, test, type Page } from "@playwright/test"; -import { PIN } from "./helpers"; +import { type Page } from "@playwright/test"; +import { expect, test, PIN } from "./helpers"; import { L as SL, actor, diff --git a/tests/e2e/tests/send.spec.ts b/tests/e2e/tests/send.spec.ts new file mode 100644 index 0000000..2ea1a61 --- /dev/null +++ b/tests/e2e/tests/send.spec.ts @@ -0,0 +1,96 @@ +import { type Page } from "@playwright/test"; +import { expect, test } from "./helpers"; +import { cardOf, findBar, makeDraft, makeProject, makeUnit, runSuffix, searchBox, type Made } from "./pmhelpers"; +import { actor, closeAll, packet, signWithPassword } from "./shophelpers"; +import { onePagePdf } from "./whhelpers"; + +// Phase 10, the old tsend on the new UI. clerk.clara answers "Heat map required?" and attaches the drawing before a +// draft can go anywhere; she sends one straight to a department (skipping the warehouse) with a note, and one straight +// to Q.C. sup.wes finds the first on the Supervisor screen and sends it back to Clerical with a reason. + +let RUN = ""; +let one: Made; +let two: Made; + +async function attachDrawing(page: Page): Promise { + await page.getByLabel("Attach drawing (PDF)").setInputFiles({ name: "send.pdf", mimeType: "application/pdf", buffer: onePagePdf() }); + await signWithPassword(page, "clerk.clara"); + await expect(page.getByText(/Drawing attached/)).toBeVisible(); +} + +test.describe.serial("send a draft", () => { + test("tsend: the heat map answer and the drawing come first; straight to a department, straight to Q.C.", async ({ + browser, + }, info) => { + RUN = runSuffix(info.project.name); + const clara = await actor(browser, "clerk.clara"); + const project = await makeProject(clara, `T${RUN}`); + const unit = await makeUnit(clara, Number(project.id), "001"); + one = await makeDraft(clara, unit, `Send dept ${RUN}`); + two = await makeDraft(clara, unit, `Send qc ${RUN}`); + const page = clara.page; + + // Nothing can be sent before the heat map question is answered and a drawing is on + await page.goto(`/packets/${one.id}`); + await expect(page.getByTestId("status-pill")).toHaveText("Ready to release"); + for (const name of ["Send to warehouse", "Send to a department", "Send to Q.C."]) { + await expect(page.getByRole("button", { name, exact: true })).toBeDisabled(); + } + await expect(page.getByText("Say whether this packet needs a heat map.").first()).toBeVisible(); + await attachDrawing(page); + const hm = page.getByRole("group", { name: "Heat map required?" }); + await hm.getByRole("button", { name: "No" }).click(); + await expect(hm.getByRole("button", { name: "No" })).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByText("Not required", { exact: true })).toBeVisible(); + + // Straight to a department, with a note + const toDept = page.getByRole("button", { name: "Send to a department", exact: true }); + await expect(toDept).toBeEnabled(); + await toDept.click(); + const sheet = page.getByRole("dialog", { name: "Send straight to a department" }); + await expect(sheet.getByText("Skips the warehouse parts pull.", { exact: false })).toBeVisible(); + await sheet.getByLabel("Department").selectOption("03"); + await sheet.getByLabel("Note (optional, they will see it)").fill("Parts already staged at the station"); + await sheet.getByRole("button", { name: "Send", exact: true }).click(); + await expect(page.getByTestId("status-pill")).toHaveText("Ready to assign"); + let p = await packet(clara, one.id); + expect(p.status).toBe("ready"); + expect(p.dept.code).toBe("03"); + expect(p.heat_map).toBe(false); + + // Straight to Q.C. + await page.goto(`/packets/${two.id}`); + await attachDrawing(page); + await page.getByRole("group", { name: "Heat map required?" }).getByRole("button", { name: "Yes" }).click(); + await page.getByRole("button", { name: "Send to Q.C.", exact: true }).click(); + const qc = page.getByRole("dialog", { name: "Send straight to Q.C." }); + await expect(qc.getByText("Skips the warehouse and the shop.", { exact: false })).toBeVisible(); + await qc.getByLabel("Note (optional, Q.C. will see it)").fill("Inspect the as-received spool"); + await qc.getByRole("button", { name: "Send to Q.C." }).click(); + await expect(page.getByTestId("status-pill")).toHaveText("Q.C. inspection"); + p = await packet(clara, two.id); + expect(p.status).toBe("qc"); + expect(p.heat_map).toBe(true); + await closeAll(clara); + }); + + test("tsend: the department's supervisor sees it and sends it back to Clerical with a reason", async ({ browser }) => { + const wes = await actor(browser, "sup.wes"); + const page = wes.page; + await page.goto("/supervisor"); + await expect(findBar(page)).toBeVisible(); + await searchBox(page).fill(one.title); + await expect(cardOf(page, one)).toBeVisible(); + await page.goto(`/packets/${one.id}`); + await page.getByRole("button", { name: "Send back to Clerical", exact: true }).click(); + const sheet = page.getByRole("dialog", { name: "Send back to Clerical" }); + await sheet.getByLabel("Reason").fill("short"); + await sheet.getByRole("button", { name: "Send back to Clerical" }).click(); + await expect(sheet.getByRole("alert")).toHaveText("Give a reason of at least 8 characters."); + await sheet.getByLabel("Reason").fill("Wrong department, this is code work"); + await sheet.getByRole("button", { name: "Send back to Clerical" }).click(); + await expect(page.getByTestId("status-pill")).toHaveText("Ready to release"); + expect((await packet(wes, one.id)).status).toBe("draft"); + await closeAll(wes); + }); +}); diff --git a/tests/e2e/tests/shop.spec.ts b/tests/e2e/tests/shop.spec.ts index c21da02..b58e1c2 100644 --- a/tests/e2e/tests/shop.spec.ts +++ b/tests/e2e/tests/shop.spec.ts @@ -1,5 +1,5 @@ -import { expect, test, type Page } from "@playwright/test"; -import { PIN } from "./helpers"; +import { type Page } from "@playwright/test"; +import { expect, test, PIN } from "./helpers"; import { L, actor, @@ -398,6 +398,13 @@ test.describe.serial("shop", () => { tile.getByRole("button", { name: L.coTag(label) }), ).toBeVisible(); + // tcl: only the departments the change order names get the banner. multi.mia supervises 06, not 03. + const mia = await actor(browser, "multi.mia"); + await mia.page.goto("/supervisor"); + await expect(mia.page.getByText(L.supervisorHead)).toBeVisible(); + await expect(mia.page.getByRole("alert", { name: L.coBanner })).toHaveCount(0); + await closeAll(mia); + await banner .getByRole("button", { name: L.reviewAndVerify(label) }) .first() diff --git a/tests/e2e/tests/signin.spec.ts b/tests/e2e/tests/signin.spec.ts index 8340fe9..807acd6 100644 --- a/tests/e2e/tests/signin.spec.ts +++ b/tests/e2e/tests/signin.spec.ts @@ -1,5 +1,6 @@ -import { expect, test } from "@playwright/test"; import { + expect, + test, PIN, dcUser, enterPin, diff --git a/tests/e2e/tests/version.spec.ts b/tests/e2e/tests/version.spec.ts new file mode 100644 index 0000000..26e6ff9 --- /dev/null +++ b/tests/e2e/tests/version.spec.ts @@ -0,0 +1,74 @@ +import { expect, test, signInPassword } from "./helpers"; + +// Phase 10: the page's security headers as served, and the build-version banner (it replaces the old in-app update +// lock, which is in the Drop list). The banner test answers /api/version with another build after load and brings +// the window back into focus; the banner shows and nothing reloads until Reload is pressed. + +const CSP = + "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' blob:; font-src 'self'; connect-src 'self'; " + + "worker-src 'self'; frame-src 'none'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; + +test("the app page carries the final Content Security Policy and the other headers", async ({ page }) => { + const r = await page.request.get("/"); + expect(r.status()).toBe(200); + const h = r.headers(); + expect(h["content-security-policy"]).toBe(CSP); + expect(h["content-security-policy"]).not.toContain("unsafe-inline"); + expect(h["x-frame-options"]).toBe("DENY"); + expect(h["x-content-type-options"]).toBe("nosniff"); + // the built index.html has no inline script or style for the policy to refuse + const html = await r.text(); + expect(html).not.toMatch(/]*\bsrc=)[^>]*>/i); + expect(html).not.toMatch(/ { + await signInPassword(page, "clerk.clara"); + await expect(page).toHaveURL(/\/clerical/); + const first = (await (await page.request.get("/api/version")).json()) as Record; + expect(first.build ?? first.version).toBeTruthy(); + await expect(page.getByTestId("version-banner")).toHaveCount(0); + + await page.route("**/api/version", async (route) => { + const res = await route.fetch(); + const body = (await res.json()) as Record; + await route.fulfill({ response: res, json: { ...body, build: `${String(body.build ?? body.version)}-next` } }); + }); + await page.evaluate(() => { + (window as unknown as { __psMark?: number }).__psMark = 1; + }); + // The banner checks at most once every 10 seconds; then a focus asks again + await page.waitForTimeout(10_500); + await page.evaluate(() => window.dispatchEvent(new Event("focus"))); + const banner = page.getByTestId("version-banner"); + await expect(banner).toBeVisible(); + await expect(banner).toHaveText(/Packet Station was updated\. Reload to get the new version\./); + await expect(banner).toHaveAttribute("role", "status"); + // No reload of its own: the page is the same document + await page.waitForTimeout(1_000); + expect(await page.evaluate(() => (window as unknown as { __psMark?: number }).__psMark)).toBe(1); + + await page.unroute("**/api/version"); + await Promise.all([page.waitForEvent("load"), banner.getByRole("button", { name: "Reload" }).click()]); + expect(await page.evaluate(() => (window as unknown as { __psMark?: number }).__psMark ?? null)).toBeNull(); + await expect(page.getByTestId("version-banner")).toHaveCount(0); +}); + +test("the CSP guard sees a refused inline style and a refused inline script", async ({ page, cspGuard }) => { + await page.goto("/signin"); + await page.evaluate(() => { + const s = document.createElement("style"); + s.textContent = "body { outline: 1px solid red; }"; + document.head.appendChild(s); + const j = document.createElement("script"); + j.textContent = "window.__psInline = 1;"; + document.head.appendChild(j); + }); + await expect.poll(() => cspGuard.filter((l) => l.includes("securitypolicyviolation")).length).toBeGreaterThanOrEqual(2); + await expect.poll(() => cspGuard.filter((l) => l.startsWith("console:")).length).toBeGreaterThanOrEqual(2); + expect(await page.evaluate(() => (window as unknown as { __psInline?: number }).__psInline ?? null)).toBeNull(); + // Seen and checked here: empty the log so the guard itself passes this test + await page.waitForTimeout(500); + cspGuard.length = 0; +}); diff --git a/tests/e2e/tests/viewer.spec.ts b/tests/e2e/tests/viewer.spec.ts new file mode 100644 index 0000000..e295ae2 --- /dev/null +++ b/tests/e2e/tests/viewer.spec.ts @@ -0,0 +1,211 @@ +import { type CDPSession, type Locator, type Page } from "@playwright/test"; +import { expect, test, signInPassword } from "./helpers"; +import { + actor, + closeAll, + drawing, + drawingReady, + makeShopDraft, + packetTab, + pullAndRelease, + type Actor, + type ShopPacket, +} from "./shophelpers"; +import { getJson, onePagePdf, tinyPng } from "./whhelpers"; + +// Phase 10, the old tfs, tpan2 and tmtr on the new UI. sup.wes opens the Heat map of a packet ready to assign: Full +// screen and back (button, Esc, leaving the tab); the drawing pans by mouse after re-renders by exactly the drag, +// so no listeners pile up; on a touch device four finger drags each move the view. wh.wade uploads a PDF and a PNG +// MTR on a warehouse packet and opens each in the MTR viewer (pdf.js on a canvas, no frame); "Open in new tab" serves +// the file with the PDF policy. + +const RUN = Date.now().toString(36).toUpperCase().slice(-5); +const PROJECT = `V${RUN}`; +const VIEWER = "Heat map"; +let ready: ShopPacket; +let atWh: ShopPacket; + +function viewerBox(page: Page): Locator { + return page.getByRole("group", { name: VIEWER, exact: true }); +} + +/** The stage's translate as [x, y]. */ +async function pan(page: Page): Promise<[number, number]> { + const t = await viewerBox(page) + .locator(".stage") + .evaluate((el) => (el as HTMLElement).style.transform); + const m = /translate\((-?[\d.]+)px,\s*(-?[\d.]+)px\)/.exec(t); + if (!m) throw new Error(`no translate in "${t}"`); + return [Number(m[1]), Number(m[2])]; +} + +async function openHeatMap(page: Page, id: number): Promise { + await page.goto(`/packets/${id}`); + await packetTab(page, /^Heat map/).click(); + await drawingReady(page, VIEWER); +} + +async function canvasPoint(page: Page, fx: number, fy: number): Promise<{ x: number; y: number }> { + const c = drawing(page, VIEWER); + await c.scrollIntoViewIfNeeded(); + const b = await c.boundingBox(); + if (!b) throw new Error("drawing not visible"); + return { x: Math.round(b.x + b.width * fx), y: Math.round(b.y + b.height * fy) }; +} + +async function touchDrag(cdp: CDPSession, from: { x: number; y: number }, dx: number, dy: number): Promise { + await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: from.x, y: from.y }] }); + for (let i = 1; i <= 8; i++) { + await cdp.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [{ x: from.x + (dx * i) / 8, y: from.y + (dy * i) / 8 }], + }); + } + await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); +} + +test.describe.serial("drawing viewer and MTRs", () => { + test("setup: a packet ready to assign and one at the warehouse", async ({ browser }) => { + const clara = await actor(browser, "clerk.clara"); + const wade = await actor(browser, "wh.wade"); + ready = await makeShopDraft(clara, PROJECT, `Viewer spool ${RUN}`); + await pullAndRelease(wade, ready.id); + atWh = await makeShopDraft(clara, PROJECT, `MTR spool ${RUN}`); + await closeAll(clara, wade); + }); + + test("tfs: full screen from the viewer; Exit, Esc and leaving the tab end it", async ({ browser }) => { + const wes = await actor(browser, "sup.wes"); + const page = wes.page; + await openHeatMap(page, ready.id); + const box = viewerBox(page); + const before = await box.boundingBox(); + const body = page.locator("body"); + + await box.getByRole("button", { name: "Full screen", exact: true }).click(); + await expect(body).toHaveClass(/\bfs-view\b/); + await expect(box.getByRole("button", { name: "Exit full screen", exact: true })).toBeVisible(); + const big = await box.boundingBox(); + expect(big && before && big.width * big.height > before.width * before.height).toBe(true); + await expect(page.locator('[data-ready="1"]').and(box)).toBeVisible(); + await box.getByRole("button", { name: "Exit full screen", exact: true }).click(); + await expect(body).not.toHaveClass(/\bfs-view\b/); + + await box.getByRole("button", { name: "Full screen", exact: true }).click(); + await expect(body).toHaveClass(/\bfs-view\b/); + await page.keyboard.press("Escape"); + await expect(body).not.toHaveClass(/\bfs-view\b/); + + await box.getByRole("button", { name: "Full screen", exact: true }).click(); + await expect(body).toHaveClass(/\bfs-view\b/); + await packetTab(page, /^Materials/).click(); + await expect(body).not.toHaveClass(/\bfs-view\b/); + await closeAll(wes); + }); + + test("tpan2: a mouse drag pans by exactly the drag after re-renders", async ({ browser }) => { + const wes = await actor(browser, "sup.wes"); + const page = wes.page; + await openHeatMap(page, ready.id); + const box = viewerBox(page); + // Re-render the viewer a few times: zoom, fit, full screen in and out + for (let i = 0; i < 2; i++) { + await box.getByRole("button", { name: "Zoom in", exact: true }).click(); + await box.getByRole("button", { name: "Fit drawing" }).click(); + await box.getByRole("button", { name: "Full screen", exact: true }).click(); + await box.getByRole("button", { name: "Exit full screen", exact: true }).click(); + } + await box.getByRole("button", { name: "Zoom in", exact: true }).click(); + for (const [dx, dy] of [ + [60, 30], + [-40, 25], + [30, -50], + ] as const) { + const p0 = await pan(page); + const from = await canvasPoint(page, 0.5, 0.5); + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move(from.x + dx / 2, from.y + dy / 2, { steps: 4 }); + await page.mouse.move(from.x + dx, from.y + dy, { steps: 4 }); + await page.mouse.up(); + const p1 = await pan(page); + expect(Math.abs(p1[0] - p0[0] - dx)).toBeLessThanOrEqual(1); + expect(Math.abs(p1[1] - p0[1] - dy)).toBeLessThanOrEqual(1); + } + await closeAll(wes); + }); + + test("tpan2: on a touch device each finger drag moves the view", async ({ browser, browserName }) => { + test.skip(browserName !== "chromium", "touch is sent through the Chrome DevTools Protocol"); + const ctx = await browser.newContext({ hasTouch: true, viewport: { width: 1024, height: 768 } }); + const page = await ctx.newPage(); + const wes: Actor = { sam: "sup.wes", ctx, page }; + await signInPassword(page, "sup.wes"); + await openHeatMap(page, ready.id); + await viewerBox(page).getByRole("button", { name: "Zoom in", exact: true }).click(); + const cdp = await ctx.newCDPSession(page); + for (const [dx, dy] of [ + [50, 0], + [0, 40], + [-30, -30], + [45, 20], + ] as const) { + const p0 = await pan(page); + await touchDrag(cdp, await canvasPoint(page, 0.5, 0.5), dx, dy); + const p1 = await pan(page); + expect(Math.abs(p1[0] - p0[0] - dx)).toBeLessThanOrEqual(2); + expect(Math.abs(p1[1] - p0[1] - dy)).toBeLessThanOrEqual(2); + } + await closeAll(wes); + }); + + test("tmtr: a PDF and a PNG MTR, each shown in the MTR viewer and served for a new tab", async ({ browser }) => { + const wade = await actor(browser, "wh.wade"); + const page = wade.page; + await page.goto(`/packets/${atWh.id}`); + await packetTab(page, /^Materials/).click(); + const r1 = page.getByRole("row").filter({ hasText: "4in pipe SCH40" }); + const r2 = page.getByRole("row").filter({ hasText: "4in flange" }); + await page + .getByLabel("Line 1 MTR file", { exact: true }) + .setInputFiles({ name: "mtr-1.pdf", mimeType: "application/pdf", buffer: onePagePdf(`MTR ${RUN}`) }); + await expect(r1.getByRole("button", { name: "MTR", exact: true })).toBeVisible(); + await page + .getByLabel("Line 2 MTR file", { exact: true }) + .setInputFiles({ name: "mtr-2.png", mimeType: "image/png", buffer: tinyPng() }); + await expect(r2.getByRole("button", { name: "MTR", exact: true })).toBeVisible(); + const p = await getJson(page.request, `/api/packets/${atWh.id}`); + const bom = p.bom as Array>; + expect(bom.find((b) => b.item === 1)?.mtr_asset_id).toBeTruthy(); + expect(bom.find((b) => b.item === 2)?.mtr_asset_id).toBeTruthy(); + + // The PDF: drawn by pdf.js in the sheet, no frame + await r1.getByRole("button", { name: "MTR", exact: true }).click(); + let sheet = page.getByRole("dialog", { name: /^MTR · item 1/ }); + await expect(sheet).toBeVisible(); + await expect(sheet.getByRole("img", { name: /Drawing page 1/ }).first()).toBeVisible(); + await expect(sheet.locator("iframe, object, embed")).toHaveCount(0); + const href = await sheet.getByRole("link", { name: "Open in new tab" }).first().getAttribute("href"); + expect(href).toBeTruthy(); + const pdf = await page.request.get(String(href)); + expect(pdf.status()).toBe(200); + expect(pdf.headers()["content-type"]).toContain("application/pdf"); + expect((await pdf.body()).subarray(0, 4).toString("latin1")).toBe("%PDF"); + const pdfCsp = pdf.headers()["content-security-policy"] ?? ""; + expect(pdfCsp).toContain("object-src 'self'"); + expect(pdfCsp).toContain("frame-ancestors 'none'"); + await sheet.getByRole("button", { name: "Close", exact: true }).click(); + await expect(sheet).toBeHidden(); + + // The PNG: shown as an image + await r2.getByRole("button", { name: "MTR", exact: true }).click(); + sheet = page.getByRole("dialog", { name: /^MTR · item 2/ }); + const img = sheet.getByRole("img", { name: /^MTR · item 2/ }); + await expect(img).toBeVisible(); + await expect.poll(() => img.evaluate((el) => (el as HTMLImageElement).naturalWidth)).toBeGreaterThan(0); + const png = await page.request.get(String(await sheet.getByRole("link", { name: "Open in new tab" }).first().getAttribute("href"))); + expect(png.status()).toBe(200); + expect(png.headers()["content-type"]).toContain("image/png"); + await closeAll(wade); + }); +}); diff --git a/tests/e2e/tests/visual-feed.spec.ts b/tests/e2e/tests/visual-feed.spec.ts index 2a33dc3..38a28a5 100644 --- a/tests/e2e/tests/visual-feed.spec.ts +++ b/tests/e2e/tests/visual-feed.spec.ts @@ -1,7 +1,11 @@ -import { writeFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; -import { dcUser, signInPassword } from "./helpers"; +import { type APIRequestContext, type Page } from "@playwright/test"; +import { cardOf, column } from "./closehelpers"; +import { expect, test, dcUser, signInPassword } from "./helpers"; +import { expectShowing, filtersButton, fltSelect, pickProject } from "./pmhelpers"; +import { actor, closeAll, packetTab, signWithPassword } from "./shophelpers"; +import { onePagePdf } from "./whhelpers"; // Phase 3 VISUAL feed scenario on the CI stack. The worker reads the synthetic fixture files // (tests/fixtures/generator.py) mounted at /fixtures; this container mounts them read-write so the @@ -9,9 +13,18 @@ import { dcUser, signInPassword } from "./helpers"; // drafts, the chained drawing run attaches the WELD-PART drawing, the packet goes to the warehouse and // wh.wade releases it to the shop. State 2 moves that drawing to rev 2: the next run opens a change // order, the drawing run stages the rev 2 PDF, and clara approves it with her password. +// +// Phase 10 (old tflag, tflagsearch, trevchk2, thand, tmat), in state 1 before the drawing moves: the Flagged column +// and its flag texts, the Flag filter, a rev mismatch approved by a Clerical supervisor (clerk.leo) with a reason +// and a signature, the drawing and the BOM uploaded by hand on a flagged draft, and the VISUAL materials on the +// WELD-PART packet. As in the server tests (test_feed_api.py), drawings.1.json loses L900-0201's revisions before +// the first run, so M-Files has no rev 2 and the rev mismatch stays flagged. const FIXTURE_DIR = process.env.FIXTURE_DIR ?? "/fixtures"; const KEY_WELD_PART = "L900001|WELD-PART|L900-0101"; +const KEY_TWO_OPS = "L900001|WELD-CODE|L900-0201"; // VISUAL rev 2, M-Files rev 3: rev mismatch +const KEY_DRAFTING = "L900002|LAYOUT|L900-0501"; // in Drafting review: not released +const KEY_NOT_FOUND = "L900002|WELD-PART|L900-0701"; // not in M-Files const POLL_MS = 60_000; type Json = Record; @@ -79,6 +92,11 @@ test.describe.serial("VISUAL feed", () => { test("Run now makes project L900, its drafts and attaches the WELD-PART drawing", async ({ page }) => { writeFileSync(join(FIXTURE_DIR, "state"), "1"); + // M-Files has no rev 2 of L900-0201 at all (the server tests' set_revisions): the rev mismatch stays flagged + const dwgFile = join(FIXTURE_DIR, "drawings.1.json"); + const dwgs = JSON.parse(readFileSync(dwgFile, "utf-8")) as Json; + delete dwgs["L900-0201"].revisions; + writeFileSync(dwgFile, JSON.stringify(dwgs, null, 1)); await signInPassword(page, "clerk.clara"); await expect(page).toHaveURL(/\/clerical/); await expect(page.getByText(/from visual/i).first()).toBeVisible(); @@ -138,6 +156,164 @@ test.describe.serial("VISUAL feed", () => { expect(pkt.status).toBe("warehouse"); }); + test("tflag and tflagsearch: the Flagged column, the flag texts and the Flag filter", async ({ browser }) => { + const clara = await actor(browser, "clerk.clara"); + const page = clara.page; + // The chained drawing run flags the three drafts + await expect + .poll(async () => (await packetByKey(page, KEY_TWO_OPS)).flags, { timeout: POLL_MS, intervals: [1_000, 2_000] }) + .toContain("rev_mismatch"); + const rev = await packetByKey(page, KEY_TWO_OPS); + const nr = await packetByKey(page, KEY_DRAFTING); + const nf = await packetByKey(page, KEY_NOT_FOUND); + expect(nr.flags).toContain("not_released"); + expect(nf.flags).toContain("not_found"); + expect(rev.flag).toMatch(/^Rev mismatch/); + expect(nf.flag).toBe("Drawing not in M-Files - check the drawing number in VISUAL"); + + await page.goto("/clerical"); + await pickProject(page, "L900"); + const flagged = column(page, "Flagged"); + await expect(cardOf(flagged, rev.id)).toContainText(String(rev.flag)); + await expect(cardOf(flagged, nr.id)).toContainText(String(nr.flag)); + await expect(cardOf(flagged, nf.id)).toContainText("Drawing not in M-Files"); + // The clean WELD-PART packet is not flagged + await expect(cardOf(flagged, packetId)).toHaveCount(0); + + // The Flag filter narrows the board to one reason + const main = page.locator("main"); + await filtersButton(page).click(); + await fltSelect(page, "Flag").selectOption({ label: "Rev mismatch with M-Files" }); + await expect(cardOf(main, rev.id)).toBeVisible(); + await expect(cardOf(main, nf.id)).toHaveCount(0); + await expectShowing(page, 1); + await fltSelect(page, "Flag").selectOption({ label: "Drawing not found in M-Files" }); + await expect(cardOf(main, nf.id)).toBeVisible(); + await expect(cardOf(main, rev.id)).toHaveCount(0); + await fltSelect(page, "Flag").selectOption({ label: "Not flagged" }); + await expect(cardOf(main, nf.id)).toHaveCount(0); + await expect(cardOf(main, rev.id)).toHaveCount(0); + await closeAll(clara); + }); + + test("trevchk2: a Clerical supervisor approves the M-Files rev with a reason and a signature", async ({ browser }) => { + const clara = await actor(browser, "clerk.clara"); + const pkt = await packetByKey(clara.page, KEY_TWO_OPS); + // The staged M-Files copy is there before anyone can approve it + await expect + .poll( + async () => String((await getJson(clara.page.request, `/api/packets/${pkt.id}`)).visual?.staged_rev?.rev ?? ""), + { timeout: POLL_MS, intervals: [1_000, 2_000] }, + ) + .toBe("3"); + await clara.page.goto(`/packets/${pkt.id}`); + const box = clara.page.getByRole("region", { name: "From VISUAL" }); + await expect(box.getByText("A Clerical supervisor can approve rev 3 for this packet.")).toBeVisible(); + await expect(box.getByRole("button", { name: /^Approve rev/ })).toHaveCount(0); + await closeAll(clara); + + const leo = await actor(browser, "clerk.leo"); + const page = leo.page; + await page.goto(`/packets/${pkt.id}`); + await page + .getByRole("region", { name: "From VISUAL" }) + .getByRole("button", { name: "Approve rev 3 for this packet" }) + .click(); + const sheet = page.getByRole("dialog", { name: "Approve rev 3 for L900-0201" }); + await expect(sheet).toBeVisible(); + await sheet.getByLabel("Reason").fill("Engineering confirmed rev 3 is right"); + await sheet.getByRole("button", { name: "Approve and attach" }).click(); + await signWithPassword(page, "clerk.leo"); + await expect(page.getByText("Rev 3 approved by hand").first()).toBeVisible(); + const after = await getJson(page.request, `/api/packets/${pkt.id}`); + expect(after.flags).not.toContain("rev_mismatch"); + expect(after.drawing_state).toBe("attached"); + expect(String(after.visual.rev_ok.rev)).toBe("3"); + expect(String(after.visual.rev_ok.vis_rev)).toBe("2"); + expect(((after.pages ?? []) as Json[]).some((pg) => pg.type === "drawing" && String(pg.rev) === "3" && !pg.void)).toBe( + true, + ); + await closeAll(leo); + }); + + test("thand: Clerical uploads the drawing and the BOM by hand on a flagged draft; the warehouse cannot", async ({ + browser, + }) => { + const wade = await actor(browser, "wh.wade"); + const nf = await packetByKey(wade.page, KEY_NOT_FOUND); + await wade.page.goto(`/packets/${nf.id}`); + await expect(wade.page.getByTestId("status-pill")).toBeVisible(); + await expect(wade.page.getByRole("button", { name: "Upload drawing" })).toHaveCount(0); + await expect(wade.page.getByRole("button", { name: "Upload BOM" })).toHaveCount(0); + await closeAll(wade); + + const clara = await actor(browser, "clerk.clara"); + const page = clara.page; + await page.goto(`/packets/${nf.id}`); + await expect(page.getByText("Flagged - Clerical can upload the drawing or the BOM by hand.")).toBeVisible(); + + // The drawing: a reason is needed, then the signature + await page.getByRole("button", { name: "Upload drawing" }).click(); + let sheet = page.getByRole("dialog", { name: "Upload the drawing" }); + await sheet.getByLabel("File (PDF, up to 25 MB)").setInputFiles({ + name: "L900-0701 hand.pdf", + mimeType: "application/pdf", + buffer: onePagePdf(`hand drawing ${Date.now()}`), + }); + await sheet.getByRole("button", { name: "Upload and sign" }).click(); + await expect(sheet.getByRole("alert")).toHaveText("Say why it is uploaded by hand."); + await sheet.getByLabel("Why by hand (kept on the packet)").fill("Drafting sent the PDF by email"); + await sheet.getByRole("button", { name: "Upload and sign" }).click(); + await signWithPassword(page, "clerk.clara"); + await expect(page.getByText("Drawing attached. Check it, then send it on.")).toBeVisible(); + const handLine = page.getByText(/^Drawing uploaded by hand: L900-0701 hand\.pdf/); + await expect(handLine).toBeVisible(); + await expect(handLine).toContainText("Drafting sent the PDF by email"); + let p = await getJson(page.request, `/api/packets/${nf.id}`); + expect(p.drawing_state).toBe("hand"); + expect(p.flags).toContain("hand_drawing"); + expect(p.flags).not.toContain("not_found"); + expect(p.visual.manual_dwg.reason).toBe("Drafting sent the PDF by email"); + + // The BOM, as CSV + await page.getByRole("button", { name: "Upload BOM" }).click(); + sheet = page.getByRole("dialog", { name: "Upload the BOM" }); + await sheet.getByLabel("File (XLSX or CSV, up to 25 MB)").setInputFiles({ + name: "hand_bom.csv", + mimeType: "text/csv", + buffer: Buffer.from("Item,Qty,Part ID,Description\n1,6,PIPE-8-SCH40,8in pipe\n2,2,FLG-8-150,8in flange\n", "utf-8"), + }); + await sheet.getByLabel("Why by hand (kept on the packet)").fill("No materials in VISUAL yet"); + await sheet.getByRole("button", { name: "Upload and sign" }).click(); + await signWithPassword(page, "clerk.clara"); + await expect(page.getByText("BOM uploaded. Check it, then send it on.")).toBeVisible(); + p = await getJson(page.request, `/api/packets/${nf.id}`); + expect(((p.bom ?? []) as Json[]).map((b) => b.part_id)).toEqual(["PIPE-8-SCH40", "FLG-8-150"]); + expect(p.flags).toContain("hand_bom"); + expect(p.flag).toBe("Drawing and BOM uploaded by hand - check it and send it on"); + await expect(page.getByRole("button", { name: "Use VISUAL's BOM again" })).toBeVisible(); + await closeAll(clara); + }); + + test("tmat: the work order's materials from VISUAL are the WELD-PART packet's BOM", async ({ browser }) => { + const clara = await actor(browser, "clerk.clara"); + const page = clara.page; + const p = await getJson(page.request, `/api/packets/${packetId}`); + const parts = ((p.bom ?? []) as Json[]).map((b) => String(b.part_id)); + // seq 20's lines: the MAIN warehouse line, the WIP sub-assembly and the line with a blank drawing. The line that + // names another drawing stays off, and seq 10's lines belong to the LAYOUT packet. + expect(parts).toEqual(expect.arrayContaining(["PIPE-6-SCH40", "L900-01-SUB", "WOL-1-6"])); + expect(parts).not.toContain("ELB-6-90"); + expect(parts).not.toContain("FLG-6-150"); + await page.goto(`/packets/${packetId}`); + await packetTab(page, /^Materials/).click(); + const panel = page.getByRole("tabpanel"); + await expect(panel.getByText("PIPE-6-SCH40").first()).toBeVisible(); + await expect(panel.getByText("L900-01-SUB").first()).toBeVisible(); + await expect(panel.getByText("ELB-6-90")).toHaveCount(0); + await closeAll(clara); + }); + test("wh.wade releases it to the shop", async ({ page }) => { await signInPassword(page, "wh.wade"); let pkt = await getJson(page.request, `/api/packets/${packetId}`); diff --git a/tests/e2e/tests/warehouse.spec.ts b/tests/e2e/tests/warehouse.spec.ts index cb58caa..98c50e6 100644 --- a/tests/e2e/tests/warehouse.spec.ts +++ b/tests/e2e/tests/warehouse.spec.ts @@ -1,5 +1,5 @@ -import { expect, test, type Page } from "@playwright/test"; -import { signInPassword } from "./helpers"; +import { type Page } from "@playwright/test"; +import { expect, test, signInPassword } from "./helpers"; import { L, card, column, drag, getJson, makeWarehousePacket, tinyPng, whStage, type MadePacket } from "./whhelpers"; // Phase 4 warehouse scenarios on the CI stack. Runs after the other specs on the one worker. diff --git a/tests/e2e/tests/whhelpers.ts b/tests/e2e/tests/whhelpers.ts index 98b5032..df18dd5 100644 --- a/tests/e2e/tests/whhelpers.ts +++ b/tests/e2e/tests/whhelpers.ts @@ -72,7 +72,8 @@ async function sendJson(page: Page, method: "post" | "patch", url: string, data: } /** A minimal valid one-page PDF, built from raw bytes with a correct xref table. */ -export function onePagePdf(): Buffer { +/** A one-page PDF. A `label` goes into a comment line, so the file has its own sha256 (the server dedupes by it). */ +export function onePagePdf(label?: string): Buffer { const content = "0 0 m 612 792 l S"; const objects = [ "<< /Type /Catalog /Pages 2 0 R >>", @@ -80,7 +81,7 @@ export function onePagePdf(): Buffer { "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> >>", `<< /Length ${content.length} >>\nstream\n${content}\nendstream`, ]; - let out = "%PDF-1.4\n"; + let out = label ? `%PDF-1.4\n%${label.replace(/[\r\n]/g, " ")}\n` : "%PDF-1.4\n"; const offsets: number[] = []; objects.forEach((body, i) => { offsets.push(out.length);