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 <[email protected]>
This commit is contained in:
@@ -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.
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -18,6 +18,7 @@ export const A = {
|
||||
accesslog: "Access log",
|
||||
stampverify: "Stamp check",
|
||||
presence: "Who is in",
|
||||
storage: "Storage",
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<BrowserContext>();
|
||||
|
||||
async function guardContext(ctx: BrowserContext, log: string[]): Promise<void> {
|
||||
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<void> };
|
||||
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<Browser["newContext"]>) {
|
||||
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": {"<sam>": {"password", "emp_no", "display_name"}}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<string, Json> = {};
|
||||
let packetId = 0;
|
||||
|
||||
async function unitsOf(a: Actor): Promise<Json[]> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
PIN,
|
||||
dcUser,
|
||||
enterPin,
|
||||
|
||||
@@ -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(/<script(?![^>]*\bsrc=)[^>]*>/i);
|
||||
expect(html).not.toMatch(/<style\b/i);
|
||||
expect(html).not.toMatch(/\sstyle="/i);
|
||||
});
|
||||
|
||||
test("a new build on the server shows the banner; it reloads only on Reload", async ({ page }) => {
|
||||
await signInPassword(page, "clerk.clara");
|
||||
await expect(page).toHaveURL(/\/clerical/);
|
||||
const first = (await (await page.request.get("/api/version")).json()) as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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;
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<Record<string, unknown>>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, any>;
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user