Phase 10: regression bench, hardening, archive location, native VM kit, go-live docs #55

Merged
spencerm merged 10 commits from phase-10 into main 2026-11-03 21:16:19 +00:00
26 changed files with 999 additions and 31 deletions
Showing only changes of commit a5246e2aa5 - Show all commits
+80
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "./helpers";
import { api, closeAll } from "./shophelpers"; import { api, closeAll } from "./shophelpers";
import type { Json } from "./whhelpers"; import type { Json } from "./whhelpers";
import { admin, badSignin, csvFrom, me, openAdminTab, parseCsv, runId } from "./adminhelpers"; import { admin, badSignin, csvFrom, me, openAdminTab, parseCsv, runId } from "./adminhelpers";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "./helpers";
import { api, closeAll } from "./shophelpers"; import { api, closeAll } from "./shophelpers";
import type { Json } from "./whhelpers"; import type { Json } from "./whhelpers";
import { admin, call, openAdminTab, runId } from "./adminhelpers"; import { admin, call, openAdminTab, runId } from "./adminhelpers";
+2 -1
View File
@@ -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 { api, closeAll, type Actor } from "./shophelpers";
import type { Json } from "./whhelpers"; import type { Json } from "./whhelpers";
import { admin, call, me, openAdminTab, runId } from "./adminhelpers"; import { admin, call, me, openAdminTab, runId } from "./adminhelpers";
+1 -2
View File
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"; import { expect, test, dcUser } from "./helpers";
import { dcUser } from "./helpers";
import { actor, api, closeAll } from "./shophelpers"; import { actor, api, closeAll } from "./shophelpers";
import type { Json } from "./whhelpers"; import type { Json } from "./whhelpers";
import { admin, me, openAdminTab } from "./adminhelpers"; import { admin, me, openAdminTab } from "./adminhelpers";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "./helpers";
import { api, closeAll } from "./shophelpers"; import { api, closeAll } from "./shophelpers";
import type { Json } from "./whhelpers"; import type { Json } from "./whhelpers";
import { admin, badSignin, call, openAdminTab } from "./adminhelpers"; import { admin, badSignin, call, openAdminTab } from "./adminhelpers";
+1 -2
View File
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"; import { expect, test, dcUser } from "./helpers";
import { dcUser } from "./helpers";
import { actor, api, closeAll } from "./shophelpers"; import { actor, api, closeAll } from "./shophelpers";
import { makeDraft, makeProject, makeUnit, runSuffix, type Made } from "./pmhelpers"; import { makeDraft, makeProject, makeUnit, runSuffix, type Made } from "./pmhelpers";
import type { Json } from "./whhelpers"; import type { Json } from "./whhelpers";
+1
View File
@@ -18,6 +18,7 @@ export const A = {
accesslog: "Access log", accesslog: "Access log",
stampverify: "Stamp check", stampverify: "Stamp check",
presence: "Who is in", presence: "Who is in",
storage: "Storage",
}, },
} as const; } as const;
+97
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "./helpers";
import { actor, api, closeAll } from "./shophelpers"; import { actor, api, closeAll } from "./shophelpers";
import { call } from "./adminhelpers"; import { call } from "./adminhelpers";
import type { Json } from "./whhelpers"; import type { Json } from "./whhelpers";
+2 -2
View File
@@ -1,8 +1,8 @@
import { mkdtempSync, writeFileSync } from "node:fs"; import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { expect, test, type Page } from "@playwright/test"; import { type Page } from "@playwright/test";
import { dcUser, signInPassword } from "./helpers"; import { expect, test, dcUser, signInPassword } from "./helpers";
// Phase 2 Clerical scenario on the CI stack. Runs after signin.spec.ts on the one worker. // 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 // clerk.clara sets up project L999 with unit 001, makes a hand draft, attaches a drawing with a
+2 -2
View File
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"; import { type Page } from "@playwright/test";
import { PIN } from "./helpers"; import { expect, test, PIN } from "./helpers";
import { import {
actor, actor,
api, api,
+2 -2
View File
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"; import { type Page } from "@playwright/test";
import { signInPassword } from "./helpers"; import { expect, test, signInPassword } from "./helpers";
import { actor, api, closeAll, stepApi } from "./shophelpers"; import { actor, api, closeAll, stepApi } from "./shophelpers";
import { import {
L, L,
+70 -1
View File
@@ -1,5 +1,74 @@
import { readFileSync } from "node:fs"; 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. // 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"}}} // Shape: {"domain": "pstest.local", "users": {"<sam>": {"password", "emp_no", "display_name"}}}
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "./helpers";
import { actor, api, closeAll, packet, stepApi } from "./shophelpers"; import { actor, api, closeAll, packet, stepApi } from "./shophelpers";
import { import {
L, L,
+2 -2
View File
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"; import { type Page } from "@playwright/test";
import { dcUser } from "./helpers"; import { expect, test, dcUser } from "./helpers";
import { actor, api, closeAll } from "./shophelpers"; import { actor, api, closeAll } from "./shophelpers";
import { L, makeDraft, makeProject, makeUnit, pullWithHeat, runSuffix, type Made } from "./pmhelpers"; import { L, makeDraft, makeProject, makeUnit, pullWithHeat, runSuffix, type Made } from "./pmhelpers";
+156
View File
@@ -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);
});
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"; import { type Page } from "@playwright/test";
import { PIN } from "./helpers"; import { expect, test, PIN } from "./helpers";
import { import {
L as SL, L as SL,
actor, actor,
+96
View File
@@ -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);
});
});
+9 -2
View File
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"; import { type Page } from "@playwright/test";
import { PIN } from "./helpers"; import { expect, test, PIN } from "./helpers";
import { import {
L, L,
actor, actor,
@@ -398,6 +398,13 @@ test.describe.serial("shop", () => {
tile.getByRole("button", { name: L.coTag(label) }), tile.getByRole("button", { name: L.coTag(label) }),
).toBeVisible(); ).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 await banner
.getByRole("button", { name: L.reviewAndVerify(label) }) .getByRole("button", { name: L.reviewAndVerify(label) })
.first() .first()
+2 -1
View File
@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test";
import { import {
expect,
test,
PIN, PIN,
dcUser, dcUser,
enterPin, enterPin,
+74
View File
@@ -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;
});
+211
View File
@@ -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);
});
});
+179 -3
View File
@@ -1,7 +1,11 @@
import { writeFileSync } from "node:fs"; import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; import { type APIRequestContext, type Page } from "@playwright/test";
import { dcUser, signInPassword } from "./helpers"; 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 // 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 // (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 // 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 // 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. // 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 FIXTURE_DIR = process.env.FIXTURE_DIR ?? "/fixtures";
const KEY_WELD_PART = "L900001|WELD-PART|L900-0101"; 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; const POLL_MS = 60_000;
type Json = Record<string, any>; 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 }) => { test("Run now makes project L900, its drafts and attaches the WELD-PART drawing", async ({ page }) => {
writeFileSync(join(FIXTURE_DIR, "state"), "1"); 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 signInPassword(page, "clerk.clara");
await expect(page).toHaveURL(/\/clerical/); await expect(page).toHaveURL(/\/clerical/);
await expect(page.getByText(/from visual/i).first()).toBeVisible(); await expect(page.getByText(/from visual/i).first()).toBeVisible();
@@ -138,6 +156,164 @@ test.describe.serial("VISUAL feed", () => {
expect(pkt.status).toBe("warehouse"); 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 }) => { test("wh.wade releases it to the shop", async ({ page }) => {
await signInPassword(page, "wh.wade"); await signInPassword(page, "wh.wade");
let pkt = await getJson(page.request, `/api/packets/${packetId}`); let pkt = await getJson(page.request, `/api/packets/${packetId}`);
+2 -2
View File
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"; import { type Page } from "@playwright/test";
import { signInPassword } from "./helpers"; import { expect, test, signInPassword } from "./helpers";
import { L, card, column, drag, getJson, makeWarehousePacket, tinyPng, whStage, type MadePacket } from "./whhelpers"; 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. // Phase 4 warehouse scenarios on the CI stack. Runs after the other specs on the one worker.
+3 -2
View File
@@ -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. */ /** 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 content = "0 0 m 612 792 l S";
const objects = [ const objects = [
"<< /Type /Catalog /Pages 2 0 R >>", "<< /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 << >> >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> >>",
`<< /Length ${content.length} >>\nstream\n${content}\nendstream`, `<< /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[] = []; const offsets: number[] = [];
objects.forEach((body, i) => { objects.forEach((body, i) => {
offsets.push(out.length); offsets.push(out.length);