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]>
419 lines
15 KiB
TypeScript
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,
|
|
);
|
|
}
|