Clerical close and file: the VISUAL observed state beside the close confirm, a 409 with a second confirm when the feed still shows work orders open, filing with a signature and the HMAC seal, the data book built on the server and stored on the seal in the filing transaction, admin reopen keeping the broken seal with its data book. Server PDFs with reportlab, pypdf and pypdfium2: the manufacturing data book page for page after the old build (cover, index, drawings with VOID stamps, traceability and heat map rasters, weld maps and welding records, Q.C. inspections, Q.C. and test documents, MTRs, record history), the job traveler with work order QR codes, the ITP PDF filed automatically at sign-off, every print kind with the PRINTED COPY stamp, the controlled-copy stamp signed with the seal key, PDF metadata, the traceability CSV with the formula guard, and the append-only print_log with an audit row for every print and export (migration 0008). Frontend: the close and file panel, the print menu and its entry points, the printed copies list on the History tab, the ITP PDF buttons. Tests: server (close, file, reopen, seal, the builders, prints, CSV, stamps), frontend, and the Playwright close and print scenarios. Docs updated; SECURITY closes M2, S6, S7 and S8. Co-Authored-By: Claude Fable 5.1 <[email protected]>
256 lines
11 KiB
TypeScript
256 lines
11 KiB
TypeScript
import { expect, type APIResponse, type BrowserContext, type Locator, type Page } from "@playwright/test";
|
|
import { api, packet, sign, stepApi, type Actor } from "./shophelpers";
|
|
import { atQcApi, type Crew, type QcPacket } from "./qchelpers";
|
|
import type { Json } from "./whhelpers";
|
|
|
|
// Helpers for the phase 7 close and file scenarios: a project with one unit, packets taken to complete routed to
|
|
// the data book through the API, the ITP signed off through the API, and the UI labels the close spec depends on
|
|
// (L). The labels are the "Labels" section of the phase 7 design contract and the components as agent B built
|
|
// them. The main session adjusts them here in one place when the components differ.
|
|
|
|
export const L = {
|
|
// the Clerical home (screens/clerical/ClericalHome.tsx, boards.ts)
|
|
clericalHead: "Clerical",
|
|
findJob: "Find a job",
|
|
colToClose: "Ready to close",
|
|
colToBook: "Ready for data book",
|
|
colFiled: "Filed",
|
|
kpiToClose: (n: number) => `Ready to close: ${n}`, // the kpi tile's aria-label
|
|
filedPill: "Filed in data book", // the status label of filed; the card's aria-label ends with it
|
|
// the close and file panel
|
|
panel: "Close and file", // section aria-label (role region)
|
|
readyToClose: "Back from the shop, ready to close.",
|
|
readyForBook: "Ready for the data book.",
|
|
filed: "Filed in the data book",
|
|
sealIntact: "Seal intact",
|
|
sealBroken: "Seal does not match the record",
|
|
sealUnknown: "Seal unknown",
|
|
reopenedLine: /^Reopened by .+: .+\. The earlier seal is kept as broken\./,
|
|
blocked: "Close the job in VISUAL first.",
|
|
visualLine: "visual-line", // testid of the VISUAL observed line
|
|
sealState: "seal-state", // testid of the seal line
|
|
reopened: "reopened", // testid of the reopen line
|
|
// panel buttons
|
|
jobClosed: "Job closed in VISUAL",
|
|
moveBack: "Not closed in VISUAL, move back",
|
|
fileIt: "File in the data book",
|
|
fileAnyway: "File anyway",
|
|
dataBookPdf: "Data book (PDF)",
|
|
download: "Download",
|
|
csv: "Traceability (CSV)",
|
|
printOpen: "Print…", // U+2026, as the contract writes it
|
|
reopenIt: "Reopen (IT)",
|
|
// sheets
|
|
printSheet: "Print",
|
|
openWosSheet: "File with open work orders?",
|
|
reopenSheet: "Reopen a filed packet",
|
|
// print rows (each a button whose name starts with the row title)
|
|
printRows: ["Job traveler", "Drawing with heat map", "Drawing with weld map", "Drawing (no markup)", "Data book"],
|
|
voidRow: "Superseded drawings (VOID)",
|
|
printFooter: /Every printed page is stamped with who printed it and when/,
|
|
// other print entry points
|
|
printTraveler: "Print traveler",
|
|
travelerPdf: "Traveler (PDF)",
|
|
printSheetBtn: "Print this sheet",
|
|
// the packet tabs
|
|
tabWorkOrders: /^Work orders/,
|
|
tabHistory: /^History/,
|
|
// the History tab
|
|
printsSection: "Printed copies and exports", // region
|
|
nothingPrinted: "Nothing printed yet.",
|
|
// the ITP page
|
|
itpPdf: "ITP (PDF)",
|
|
signedPdf: "Signed PDF",
|
|
signedOffDoc: (no: string) => `${no} — signed off`, // the automatic Q.C. folder document, em dash is the server's text
|
|
// shared
|
|
cancel: "Cancel",
|
|
statusPill: "status-pill", // testid (phase 5)
|
|
} as const;
|
|
|
|
/** A regex that matches a text literally from its start. */
|
|
export function starts(s: string): RegExp {
|
|
return new RegExp(`^${s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
|
|
}
|
|
|
|
export interface CloseProject {
|
|
id: number;
|
|
number: string;
|
|
unit: Json;
|
|
}
|
|
|
|
/** As clerk.clara: a project with a customer and a PO, and one unit. */
|
|
export async function makeCloseProject(clara: Actor, number: string, name = "Close and file skid"): Promise<CloseProject> {
|
|
const project = await api(
|
|
clara,
|
|
"post",
|
|
"/api/projects",
|
|
{ number, name, customer: "ACME", po: "7731" },
|
|
{ status: 201 },
|
|
);
|
|
const units = (await api(clara, "post", `/api/projects/${project.id}/units`, {}, { status: 201 })) as unknown as Json[];
|
|
expect(units.length).toBe(1);
|
|
return { id: Number(project.id), number: String(project.number), unit: units[0] as Json };
|
|
}
|
|
|
|
/** The ten Q.C. check keys (tests/server/qchelpers.py QC_KEYS). */
|
|
export const QC_KEYS = [
|
|
"db_heats",
|
|
"db_mtr",
|
|
"db_weldmap",
|
|
"db_wps",
|
|
"db_ck",
|
|
"ph_dim",
|
|
"ph_heat",
|
|
"ph_stamp",
|
|
"ph_clean",
|
|
"ph_qty",
|
|
] as const;
|
|
|
|
/** As qc.quinn through the API: inspector and stamp, every check, every live weld accepted. */
|
|
export async function finishInspectionApi(quinn: Actor, id: number): Promise<Json> {
|
|
let p = await api(quinn, "patch", `/api/packets/${id}/qc`, { set: { inspector: "Quinn QC", stamp: "Q1" }, base: {} });
|
|
for (const k of QC_KEYS) p = await api(quinn, "put", `/api/packets/${id}/qc/checks/${k}`, { done: true });
|
|
const welds = (p.welds as Json[]).filter((w) => !w.removed_at && !w.removed).map((w) => Number(w.weld_no));
|
|
for (const no of welds) p = await api(quinn, "put", `/api/packets/${id}/qc/welds/${no}`, { result: "accept" });
|
|
expect(p.qc.missing, JSON.stringify(p.qc.missing)).toEqual([]);
|
|
return p;
|
|
}
|
|
|
|
/** A packet taken through the shop and Q.C. to status complete, routed to the data book, all through the API. */
|
|
export async function atCompleteApi(crew: Crew, quinn: Actor, unit: Json, title: string, stall: string): Promise<QcPacket> {
|
|
const p = await atQcApi(crew, unit, title, stall, "Bay 7");
|
|
await finishInspectionApi(quinn, p.id);
|
|
const out = await stepApi(quinn, p.id, "qc_accept", { routed_to: "databook", sign: sign("qc.quinn") });
|
|
expect(out.status).toBe("complete");
|
|
expect(out.routed_to).toBe("databook");
|
|
return p;
|
|
}
|
|
|
|
/** A board column of the Clerical home by its heading (the heading ends with the card count). */
|
|
export function column(page: Page, heading: string): Locator {
|
|
return page.getByRole("region", { name: new RegExp(`^${heading}\\s*\\d*$`) }).first();
|
|
}
|
|
|
|
/** A packet card by its link. */
|
|
export function cardOf(scope: Locator, id: number): Locator {
|
|
return scope.locator(`a[href="/packets/${id}"]`).first();
|
|
}
|
|
|
|
/** The Clerical home, narrowed to one project through the find box so the numbers count only this run. */
|
|
export async function openClericalHome(page: Page, project: string): Promise<void> {
|
|
await page.goto("/clerical");
|
|
await expect(page.getByRole("heading", { level: 1, name: L.clericalHead })).toBeVisible();
|
|
await page.getByRole("searchbox", { name: L.findJob }).fill(project);
|
|
}
|
|
|
|
/** The close and file panel. */
|
|
export function panel(page: Page): Locator {
|
|
return page.getByRole("region", { name: L.panel, exact: true });
|
|
}
|
|
|
|
/** Open a packet page and wait for its heading. */
|
|
export async function openPacket(page: Page, id: number): Promise<void> {
|
|
await page.goto(`/packets/${id}`);
|
|
await expect(page.getByRole("heading").first()).toBeVisible();
|
|
}
|
|
|
|
/** A packet's current state on the server, for expect.poll. */
|
|
export async function field(a: Actor, id: number, pick: (p: Json) => unknown): Promise<unknown> {
|
|
return pick(await packet(a, id));
|
|
}
|
|
|
|
/** What a click asked the browser to open: the URL, plus the server's answer fetched through the page's session. */
|
|
export interface Opened {
|
|
url: () => string;
|
|
response: APIResponse;
|
|
}
|
|
|
|
/**
|
|
* Click `target` and catch the URL it opens, then fetch it through the page's own session so the server sees the
|
|
* request and logs it. A link is read from its href (a download link would start a download, not a navigation).
|
|
* A button that calls window.open is caught through a stub, because a noopener tab in headless Chromium does not
|
|
* report its request on the context. The URL must contain `part`.
|
|
*/
|
|
export async function clickAndCatch(ctx: BrowserContext, page: Page, target: Locator, part: string): Promise<Opened> {
|
|
await expect(target).toBeVisible();
|
|
let url: string | null = null;
|
|
const href = await target.getAttribute("href");
|
|
if (href) {
|
|
url = new URL(href, page.url()).toString();
|
|
} else {
|
|
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, {
|
|
timeout: 15_000,
|
|
})
|
|
.toBeGreaterThan(0);
|
|
const opened = await page.evaluate(() => (window as unknown as { __opened: string[] }).__opened);
|
|
url = new URL(opened[0] ?? "", page.url()).toString();
|
|
}
|
|
expect(url, `opened ${url}`).toContain(part);
|
|
const response = await page.request.get(url);
|
|
for (const other of ctx.pages()) if (other !== page) await other.close();
|
|
const fixed = url;
|
|
return { url: () => fixed, response };
|
|
}
|
|
|
|
/** GET a URL through the actor's API context and check the status. */
|
|
export async function fetchOk(a: Actor, url: string): Promise<APIResponse> {
|
|
const r = await a.page.request.get(url);
|
|
expect(r.status(), `GET ${url}: ${r.status()}`).toBe(200);
|
|
return r;
|
|
}
|
|
|
|
/** GET a PDF through the actor's API context: 200, application/pdf, the body starts with %PDF. */
|
|
export async function expectPdf(a: Actor, url: string): Promise<Buffer> {
|
|
const r = await fetchOk(a, url);
|
|
expect(r.headers()["content-type"]).toContain("application/pdf");
|
|
const body = await r.body();
|
|
expect(body.subarray(0, 4).toString("latin1")).toBe("%PDF");
|
|
return body;
|
|
}
|
|
|
|
/** The packet's print log rows, newest first (GET /api/packets/{id}/prints). */
|
|
export async function prints(a: Actor, id: number): Promise<Json[]> {
|
|
const out = await api(a, "get", `/api/packets/${id}/prints`);
|
|
return (out.prints ?? []) as Json[];
|
|
}
|
|
|
|
/** How many print log rows of `kind` the packet has. */
|
|
export async function printCount(a: Actor, id: number, kind: string): Promise<number> {
|
|
return (await prints(a, id)).filter((r) => r.kind === kind).length;
|
|
}
|
|
|
|
/** The ITP of a unit, or null. */
|
|
export async function itpOf(a: Actor, unitId: number): Promise<Json | null> {
|
|
const out = await api(a, "get", `/api/units/${unitId}/itp`);
|
|
return out.itp === null ? null : out;
|
|
}
|
|
|
|
/**
|
|
* As qc.quinn through the API: start the unit's ITP, approve every open line, sign every block with the
|
|
* password, sign off. Returns the signed-off ITP.
|
|
*/
|
|
export async function signedOffItpApi(quinn: Actor, unitId: number): Promise<Json> {
|
|
let itp = (await itpOf(quinn, unitId)) ?? (await api(quinn, "post", `/api/units/${unitId}/itp`, {}));
|
|
for (const line of Object.values(itp.lines as Record<string, Json>)) {
|
|
if (line.result === null || line.result === undefined)
|
|
itp = await api(quinn, "post", `/api/itps/${itp.id}/lines/${line.key}/sign`, {});
|
|
}
|
|
for (const b of itp.blocks as Json[]) {
|
|
if (b.signed) continue;
|
|
itp = await api(quinn, "post", `/api/itps/${itp.id}/blocks/${b.key}/sign`, { sign: sign("qc.quinn") });
|
|
}
|
|
itp = await api(quinn, "post", `/api/itps/${itp.id}/signoff`, { sign: sign("qc.quinn") });
|
|
expect(itp.status).toBe("complete");
|
|
return itp;
|
|
}
|