Files
PacketStation/tests/e2e/tests/pmhelpers.ts
T
spencermandClaude Opus 5.5 a82e056b6a
ci / frontend (pull_request) Successful in 1m11s
ci / server (pull_request) Successful in 12m2s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Successful in 6m1s
Phase 9: Admin completion, reconcile, notifications, integrity, backups
Admin: Departments (name and stations; add shows the three groups IT must
create), Profiles (welder stamp, station limits, active) with a CSV import
that previews and applies only when clean, Checklists (weld and plain),
Security (session, lockout and rate-limit settings with bounds, alerts for
the last 24 hours, the integrity result), Access log with filters and a
guarded CSV, Who is in, Stamp check, Notifications, and the employeeID
flags. Every Admin change is audited.

Worker: ad-reconcile (nightly and on "refresh from AD now"), session-refresh
(15 minutes), integrity (seals, asset hashes, missing and orphan files, the
audit chain), backup-check, notify-send and notify-digest. Notifications
route per event type to AD groups, users, mailboxes or the department
Supervisor group, immediately or in a daily digest, over STARTTLS verified
against the PESCO CA, with dry run on the test stack.

Backups: scripts/backup/ps-backup.sh and ps-restore.sh with a systemd timer
at 02:00 America/Denver. Restore rehearsed on the test stack 2026-10-21.
CI: pip-audit and npm audit. pypdf 6.21.1 and certifi 2026.10.19.

Migration 0010. Closes the app part of I6, M3, O1, O2 and S3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 18:27:09 -06:00

419 lines
15 KiB
TypeScript

import { readFileSync } from "node:fs";
import { expect, type Download, type Locator, type Page } from "@playwright/test";
import { dcUser } from "./helpers";
import { api, sign, stepApi, type Actor } from "./shophelpers";
import { getJson, onePagePdf, writeHeaders, type Json } from "./whhelpers";
// Helpers for the phase 8 scenarios (Project Management, the find bar, presence, motion, Back and the sheets):
// projects, units and packets made through the API, the find bar and PM locators, and the UI labels the specs
// depend on (L). The labels are the texts of the phase 8 design contract ("Labels for the Playwright specs" and the
// vocabulary); the main session adjusts them here in one place.
export const L = {
// Project Management
pm: {
eyebrow: "Project management · Status and reporting",
head: "Where every job stands",
tabs: {
overview: "Overview",
projects: "Projects & units",
aging: "Bottlenecks & aging",
hours: "Hours vs target",
quality: "Quality & issues",
find: "Find jobs",
},
pdf: "PDF report",
csvAll: "Excel (CSV) — all",
csv: "Excel (CSV)",
details: "Details",
kpis: [
"Open packets",
"Late units",
"Units at risk",
"Warehouse holds",
"Open change orders",
"Open shorts",
/Q\.C\. rejects · /,
"Packets over target hours",
/Filed · /,
"Average lead time, created → filed",
] as (string | RegExp)[],
plainWords: "In plain words",
pipeline: "Pipeline",
bars: "Open packets by step",
attention: "Units late or at risk",
scope: "Scope:",
nothing: "Nothing in scope.",
allOnTrack: "Every unit with packets is on track.",
period: "Period",
periods: { "30": "Last 30 days", "90": "Last 90 days", "365": "Last 12 months", all: "All time" },
health: { ok: "On track", risk: "At risk", late: "Late", done: "Complete", empty: "No packets yet" },
viewOnly: "View only.",
titles: {
projects: "Projects and units",
agingSteps: "Time in the current step (open packets)",
agingOpen: "Every open packet, longest waiting first",
hoursDept: "By department",
hoursOver: "Packets over target",
qualityHolds: "Warehouse holds",
qualityShorts: "Open shorts",
},
csvFirst: /^PESCO Packet Station — /,
},
// the find bar
find: {
bar: "findbar", // data-testid of the bar (a region named "Find jobs")
search: "Find a job",
project: "Project", // the project select (ProjectUnitSelect)
allProjects: "All projects",
unit: "Unit",
filters: "Filters",
board: "Board",
list: "List",
dept: "Department",
status: "Status",
station: "Station",
worker: "Worker",
flag: "Flag",
statusAtStation: "At a station (assigned / working)",
chips: {
co: "Change order open",
short: "Shorts open",
overdue: "Project overdue",
week: "Due within 7 days",
mine: "Mine",
},
showing: "Showing",
count: "flt-count", // data-testid of "Showing n packets"
clear: /^Clear filters/,
listHead: "Jobs",
sortBy: "Sort by",
sortProject: "Project / lot",
empty: "No packets match these filters.",
},
// KPI tiles on the Supervisor home
kpi: {
ready: "Ready to assign",
atStations: "At stations",
review: "Waiting on review",
},
homes: {
supervisor: "/supervisor",
clerical: "/clerical",
pm: "/pm",
},
newPacket: "New packet",
back: /Back$/,
presence: "presence", // data-testid of the callout (role status)
alsoHere: "Also in this packet now:",
roles: { supervisor: "Supervisor", stall: "Weld Stall", pm: "Project Mgmt" },
motionOn: "Motion on",
motionOff: "Motion off",
} as const;
/** A run suffix that differs per Playwright project, so the chromium and ipad runs never share a number. */
export function runSuffix(projectName: string): string {
// Letters only, so no digit in it can match a lot or a count the specs search for ("lot 54").
const base = Date.now()
.toString(36)
.toUpperCase()
.slice(-5)
.replace(/\d/g, (d) => "QRSTUVWXYZ".charAt(Number(d)));
return projectName === "chromium" ? base : `${base}${projectName.slice(0, 1).toUpperCase()}`;
}
/** Today plus `days`, as YYYY-MM-DD in the site's calendar (America/Denver), as the server counts due dates.
* The UTC calendar is a day ahead every evening after 18:00 Denver time, which made "due yesterday" today. */
export function isoDay(days: number): string {
return new Intl.DateTimeFormat("en-CA", { timeZone: "America/Denver" }).format(
new Date(Date.now() + days * 86_400_000),
);
}
export interface Unit {
id: number;
number: string;
lot: string;
}
export interface Made {
id: number;
title: string;
unitNumber: string;
}
/** As Clerical: a project (made once per number) with the given fields. */
export async function makeProject(clara: Actor, number: string, fields: Json = {}): Promise<Json> {
const projects = (await getJson(clara.page.request, "/api/projects?status=all")) as unknown as Json[];
const found = projects.find((p) => p.number === number);
if (found) return found;
return api(clara, "post", "/api/projects", { number, name: `CI ${number}`, ...fields }, { status: 201 });
}
/** As Clerical: one unit with this lot (and due date) in the project. */
export async function makeUnit(clara: Actor, projectId: number, lot: string, due?: string): Promise<Unit> {
const units = (await api(
clara,
"post",
`/api/projects/${projectId}/units`,
{ lot, ...(due ? { due } : {}) },
{ status: 201 },
)) as unknown as Json[];
const u = units[0] as Json;
return { id: Number(u.id), number: String(u.number), lot: String(u.lot ?? lot) };
}
export interface DraftOpts {
dept?: string;
customer?: string;
wo?: string;
drawing?: string;
bom?: Json[];
/** "warehouse" sends it to the warehouse; a department code releases it there; absent leaves a bare draft. */
send?: string;
}
const BOM3: Json[] = [
{ item: 1, qty: 1, part_id: "PIPE-4", description: "4in pipe SCH40" },
{ item: 2, qty: 2, part_id: "FLG-4", description: "4in flange" },
{ item: 3, qty: 1, part_id: "STUD-58", description: "5/8 stud bolt" },
];
/**
* As clerk.clara: a hand draft on `unit` with a work order (target 2 h) and three BOM lines. With `send`, a one-page
* drawing is attached with her password and the heat map answered, then it is sent on. A bare draft is one request.
*/
export async function makeDraft(clara: Actor, unit: Unit, title: string, o: DraftOpts = {}): Promise<Made> {
const draft = await api(
clara,
"post",
"/api/packets",
{
unit_id: unit.id,
dept_code: o.dept ?? "03",
op: "WELD-PART",
title,
customer: o.customer ?? "CI Customer",
drawing: o.drawing ?? `D-${unit.number}`,
rev: "A",
wps: "WPS-11",
work_orders: [
{
wo_base: (o.wo ?? `W${unit.number}`).slice(0, 20),
lot: "001",
split: "0",
sub: "0",
qty: 1,
target_hrs: 2,
},
],
bom: o.bom ?? BOM3,
},
{ status: 201 },
);
const id = Number(draft.id);
if (o.send) {
const up = await clara.page.request.post("/api/assets", {
headers: writeHeaders(clara.page),
multipart: {
file: { name: `${unit.number}-${id}.pdf`, mimeType: "application/pdf", buffer: onePagePdf() },
purpose: "drawing",
},
});
expect(up.status(), await up.text()).toBe(201);
const asset = (await up.json()) as Json;
await api(clara, "post", `/api/packets/${id}/pages`, {
asset_id: asset.id,
type: "drawing",
sign: sign("clerk.clara"),
});
await api(clara, "patch", `/api/packets/${id}`, { set: { heat_map: true }, base: {} });
if (o.send === "warehouse") {
const sent = await stepApi(clara, id, "send_to_warehouse");
expect(sent.status).toBe("warehouse");
} else {
const sent = await stepApi(clara, id, "release_to_shop", { dept: o.send });
expect(sent.status).toBe("ready");
}
}
return { id, title, unitNumber: unit.number };
}
/**
* As wh.wade: every BOM line pulled with `heat` (line n gets `${heat}${n}`), except the items in `short`, which are
* recorded short (On order); then released. The packet is Ready to assign in 03.
*/
export async function pullWithHeat(wade: Actor, id: number, heat: string, short: number[] = []): Promise<Json> {
let p = await getJson(wade.page.request, `/api/packets/${id}`);
for (const line of p.bom as Json[]) {
const item = Number(line.item);
if (short.includes(item)) {
p = await api(wade, "post", `/api/packets/${id}/bom/${item}/short`, {
qty: Number(line.qty ?? 1),
reason: "On order",
expect_open: false,
});
continue;
}
let cur = (p.bom as Json[]).find((b) => b.item === item) as Json;
p = await api(
wade,
"patch",
`/api/packets/${id}/bom/${item}`,
{ pulled: true },
{ headers: { "If-Match": String(cur.row_version) } },
);
cur = (p.bom as Json[]).find((b) => b.item === item) as Json;
p = await api(
wade,
"patch",
`/api/packets/${id}/bom/${item}`,
{ heat: `${heat}${item}` },
{ headers: { "If-Match": String(cur.row_version) } },
);
}
const out = await stepApi(wade, id, "wh_release");
expect(out.status).toBe("ready");
return out;
}
/** The user id of a 03 person by display name (the assign sheet's people list). */
export async function personId(a: Actor, sam: string): Promise<string> {
const people = (await getJson(a.page.request, "/api/departments/03/people")) as unknown as Json[];
const me = people.find((x) => x.display_name === dcUser(sam).display_name);
if (!me) throw new Error(`${sam} is not in 03 people`);
return String(me.id);
}
// ---------------------------------------------------------------- the find bar
export function findBar(page: Page): Locator {
return page.getByTestId(L.find.bar);
}
export function searchBox(page: Page): Locator {
return findBar(page).getByRole("searchbox", { name: L.find.search });
}
/** Pick a project in the find bar: the Project select, by its number. */
export async function pickProject(page: Page, number: string): Promise<void> {
const sel = findBar(page).getByRole("combobox", { name: L.find.project, exact: true });
const opt = sel.locator("option").filter({ hasText: new RegExp(`^${number}( · |$)`) }).first();
await expect(opt).toBeAttached();
await sel.selectOption((await opt.getAttribute("value")) ?? "");
await expect(sel).not.toHaveValue("");
}
/** Back to every project. */
export async function unpickProject(page: Page): Promise<void> {
await findBar(page).getByRole("combobox", { name: L.find.project, exact: true }).selectOption({ label: L.find.allProjects });
}
/** The n of "Showing n packets". */
export async function showing(page: Page): Promise<number> {
const t = (await page.getByTestId(L.find.count).innerText()).replace(/\s+/g, " ");
const m = /Showing (\d+)/.exec(t);
if (!m) throw new Error(`no count in "${t}"`);
return Number(m[1]);
}
/** Wait until the bar shows `n` packets. */
export async function expectShowing(page: Page, n: number): Promise<void> {
await expect.poll(() => showing(page), { timeout: 10_000 }).toBe(n);
}
/** A select of the find bar's second row by its label. */
export function fltSelect(page: Page, label: string): Locator {
return findBar(page).getByLabel(label, { exact: true });
}
export function filtersButton(page: Page): Locator {
return findBar(page).getByRole("button", { name: new RegExp(`^${L.find.filters}`) });
}
export function chip(page: Page, label: string): Locator {
return findBar(page).getByRole("button", { name: label, exact: true });
}
export function viewButton(page: Page, which: "Board" | "List"): Locator {
return findBar(page).getByRole("button", { name: which, exact: true });
}
export function jobList(page: Page): Locator {
return page.getByRole("table", { name: L.find.listHead, exact: true });
}
/** The packet ids of the list view's rows, in order (each row carries data-card-id). */
export async function listIds(page: Page): Promise<number[]> {
const rows = jobList(page).locator("tbody tr[data-card-id]");
return (await rows.evaluateAll((els) => els.map((e) => Number(e.getAttribute("data-card-id"))))) as number[];
}
/** A card of a packet anywhere on the board: by data-card-id, else by its title or unit number. */
export function cardOf(page: Page, p: Made): Locator {
return page
.locator(`[data-card-id="${p.id}"]`)
.or(page.locator(".card").filter({ hasText: p.title }))
.or(page.locator(".card").filter({ hasText: p.unitNumber }))
.first();
}
export async function lsGet(page: Page, key: string): Promise<string | null> {
return page.evaluate((k) => window.localStorage.getItem(k), key);
}
// ---------------------------------------------------------------- PM
export function pmTab(page: Page, name: string): Locator {
return page.getByRole("tablist").getByRole("tab", { name, exact: true });
}
export function pmTable(page: Page, title: string): Locator {
return page.getByRole("table", { name: title, exact: true });
}
/** Body rows of every PM table on the page that carry data (not the "Nothing in scope." row). */
export function pmDataRows(page: Page): Locator {
return page.locator("table.pmtable tbody tr").filter({ hasNotText: L.pm.nothing });
}
/** Read a finished download as text, without the BOM. */
export async function downloadText(dl: Download): Promise<string> {
const path = await dl.path();
if (!path) throw new Error("download has no path");
const text = readFileSync(path, "utf-8");
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
}
/** Click and catch what it opens in a new tab (window.open is stubbed), as a URL. */
export async function catchOpen(page: Page, target: Locator): Promise<string> {
await expect(target).toBeVisible();
const href = await target.getAttribute("href");
if (href) return new URL(href, page.url()).toString();
await page.evaluate(() => {
const w = window as unknown as { __opened: string[]; open: (u?: string | URL) => Window | null };
w.__opened = [];
w.open = (u?: string | URL) => {
w.__opened.push(String(u ?? ""));
return null;
};
});
await target.click();
await expect
.poll(async () => (await page.evaluate(() => (window as unknown as { __opened: string[] }).__opened)).length)
.toBeGreaterThan(0);
const opened = await page.evaluate(() => (window as unknown as { __opened: string[] }).__opened);
return new URL(opened[0] ?? "", page.url()).toString();
}
/** How many Web Animations run on board cards right now (the FLIP glide, the fade in, the glow). */
export async function cardAnimations(page: Page): Promise<number> {
return page.evaluate(
() =>
document.getAnimations().filter((a) => {
const t = (a.effect as KeyframeEffect | null)?.target as Element | null | undefined;
return !!t && !!t.closest && !!t.closest("[data-card-id]");
}).length,
);
}