Files
PacketStation/tests/e2e/tests/visual-feed.spec.ts
T
spencermandClaude Fable 5.1 39a4eb5213
ci / frontend (push) Successful in 30s
ci / server (push) Successful in 4m30s
ci / image (push) Successful in 35s
ci / e2e (push) Successful in 1m40s
Phase 4: Warehouse screen, pull table, shorts, MTRs and the upload pipeline
The Warehouse home with the numbers, the Open shorts table, the find bar and the five-column task board
(waiting, being pulled, on hold, pulled, released in the last 3 days) with buttons, hold reasons and drag.
Every role sees the warehouse stage on a packet as the WH pill and the line under it. The pull table on the
Materials tab: Pulled with auto-start, issued, location, heat with Enter to the next line, shorts with
receipts, N/A, MTR per line with a viewer. Send back to Clerical with a reason. Release to supervisor stays
blocked until every line is pulled or short and every pulled line has a heat.

Server: the heats_recorded precondition on WH_RELEASE, receive by custody, server-validated hold and short
reasons, the old packet history texts, GET /api/warehouse/board, cards with the warehouse stage and pull
counts. Uploads typed by content per kind (drawing, mtr, document, bom with XLSX and CSV), limits per kind,
optional ClamAV behind the clamav compose profile, a storage quota alert on the new notification_events
table (migration 0005) shown on the Admin Storage tab.

Tests: every warehouse action as every role, the release gate, the short-receive exception, the heat
pattern, upload sniffing and limits, the ClamAV path with a fake scanner, the quota alert; frontend board
and pull table tests; Playwright scenarios for the board and the pull flow. Verified on docker-1 in the CI
stack: 3557 server tests, 106 frontend tests, 17 Playwright scenarios. Docs updated; D5 closed.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-16 17:13:31 -06:00

213 lines
9.6 KiB
TypeScript

import { writeFileSync } from "node:fs";
import { join } from "node:path";
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
import { dcUser, signInPassword } from "./helpers";
// 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
// scenario can switch the state. clerk.clara presses Run now, the feed makes project L900 and its
// drafts, the chained drawing run attaches the WELD-PART drawing, the packet goes to the warehouse and
// wh.wade releases it to the shop. State 2 moves that drawing to rev 2: the next run opens a change
// order, the drawing run stages the rev 2 PDF, and clara approves it with her password.
const FIXTURE_DIR = process.env.FIXTURE_DIR ?? "/fixtures";
const KEY_WELD_PART = "L900001|WELD-PART|L900-0101";
const POLL_MS = 60_000;
type Json = Record<string, any>;
function writeHeaders(page: Page): Record<string, string> {
const base = new URL(page.url()).origin;
return { Origin: base, "X-Requested-With": "PacketStation" };
}
async function getJson(req: APIRequestContext, url: string): Promise<Json> {
const r = await req.get(url);
expect(r.status(), `${url}: ${await r.text()}`).toBe(200);
return (await r.json()) as Json;
}
async function lastRunId(page: Page): Promise<number> {
const o = await getJson(page.request, "/api/feed/overview");
return o.last_run ? Number(o.last_run.id) : 0;
}
/** Wait until a visual-feed run newer than `after` has finished ok (or degraded). Returns it. */
async function waitForRun(page: Page, after: number): Promise<Json> {
let run: Json | null = null;
await expect
.poll(
async () => {
const o = await getJson(page.request, "/api/feed/overview");
run = (o.runs as Json[]).find((r) => Number(r.id) > after && r.status !== "running") ?? null;
return run ? run.status : "waiting";
},
{ timeout: POLL_MS, intervals: [1_000, 2_000] },
)
.toMatch(/^(ok|degraded)$/);
return run as unknown as Json;
}
async function pressRunNow(page: Page): Promise<void> {
const button = page.getByRole("button", { name: /run now/i }).first();
await expect(button).toBeEnabled({ timeout: POLL_MS });
await button.click();
}
/** The live packet for a candidate key "<unit>|<op>|<drawing>", found by its card fields. */
async function packetByKey(page: Page, key: string): Promise<Json> {
const [unit, op, drawing] = key.split("|");
const list = await getJson(page.request, "/api/packets?limit=2000");
const card = (list.packets as Json[]).find(
(p) => p.unit?.number === unit && p.op === op && (p.drawing ?? "") === drawing && p.source_kind === "visual",
);
if (!card) throw new Error(`no packet for ${key}`);
return getJson(page.request, `/api/packets/${card.id}`);
}
async function transition(page: Page, pkt: Json, action: string, extra: Json = {}) {
return page.request.post(`/api/packets/${pkt.id}/transitions`, {
headers: writeHeaders(page),
data: { action, expect: { status: pkt.status, dept: pkt.dept?.code ?? pkt.dept_code }, ...extra },
});
}
let packetId = 0;
test.describe.serial("VISUAL feed", () => {
test.setTimeout(300_000);
test("Run now makes project L900, its drafts and attaches the WELD-PART drawing", async ({ page }) => {
writeFileSync(join(FIXTURE_DIR, "state"), "1");
await signInPassword(page, "clerk.clara");
await expect(page).toHaveURL(/\/clerical/);
await expect(page.getByText(/from visual/i).first()).toBeVisible();
const before = await lastRunId(page);
await pressRunNow(page);
const run = await waitForRun(page, before);
expect(run.counts.new_drafts).toBeGreaterThan(0);
const projects = await getJson(page.request, "/api/projects");
const list = projects as unknown as Json[];
const l900 = list.find((p) => p.number === "L900");
expect(l900, "project L900").toBeTruthy();
const proj = await getJson(page.request, `/api/projects/${l900!.id}`);
const unitNumbers = ((proj.units ?? []) as Json[]).map((u) => u.number);
expect(unitNumbers).toEqual(expect.arrayContaining(["L900001", "L900002"]));
let pkt = await packetByKey(page, KEY_WELD_PART);
packetId = Number(pkt.id);
expect(pkt.status).toBe("draft");
// Either still waiting for its drawing, or the chained drawing job already attached it
const flags = pkt.flags as string[];
expect(flags.includes("drawing_needed") || pkt.drawing_state === "attached").toBe(true);
// The drawing job runs right after the feed (chained)
await expect
.poll(async () => (await getJson(page.request, `/api/packets/${packetId}`)).drawing_state, {
timeout: POLL_MS,
intervals: [1_000, 2_000],
})
.toBe("attached");
await page.goto(`/packets/${packetId}`);
// The Drawing tab shows the pages the feed attached from M-Files (the fixture vault)
const full = await getJson(page.request, `/api/packets/${packetId}`);
expect(((full.pages ?? []) as Json[]).length).toBeGreaterThan(0);
await expect(page.getByRole("tab", { name: /^Drawing/ }).or(page.getByRole("button", { name: /^Drawing/ })).first()).toBeVisible();
// Send it on, unless the feed auto-released it already
pkt = await getJson(page.request, `/api/packets/${packetId}`);
if (pkt.status === "draft") {
if (pkt.heat_map === null || pkt.heat_map === undefined) {
const r = await page.request.patch(`/api/packets/${packetId}`, {
headers: writeHeaders(page),
data: { set: { heat_map: true }, base: {} },
});
expect(r.status(), await r.text()).toBe(200);
pkt = await getJson(page.request, `/api/packets/${packetId}`);
}
const r = await transition(page, pkt, "send_to_warehouse");
if (r.status() !== 200) {
// the worker may have auto-released it between the two reads
pkt = await getJson(page.request, `/api/packets/${packetId}`);
expect(pkt.status, await r.text()).toBe("warehouse");
}
}
pkt = await getJson(page.request, `/api/packets/${packetId}`);
expect(pkt.status).toBe("warehouse");
});
test("wh.wade releases it to the shop", async ({ page }) => {
await signInPassword(page, "wh.wade");
let pkt = await getJson(page.request, `/api/packets/${packetId}`);
// Release needs every material line pulled or short, with a heat on every pulled line: pull each one
// with a heat, as the warehouse would
for (const line of (pkt.bom ?? []) as Json[]) {
if (line.pulled || line.na) continue;
const pr = await page.request.patch(`/api/packets/${packetId}/bom/${line.item}`, {
headers: { ...writeHeaders(page), "If-Match": String(line.row_version) },
data: { pulled: true, heat: `HT-${String(line.item)}` },
});
expect(pr.status(), await pr.text()).toBe(200);
}
pkt = await getJson(page.request, `/api/packets/${packetId}`);
const r = await transition(page, pkt, "wh_release");
expect(r.status(), await r.text()).toBe(200);
expect((await r.json()).status).toBe("ready");
});
test("VISUAL moves the drawing to rev 2: a change order, staged, approved", async ({ page }) => {
await signInPassword(page, "clerk.clara");
await expect(page).toHaveURL(/\/clerical/);
writeFileSync(join(FIXTURE_DIR, "state"), "2");
const before = await lastRunId(page);
await pressRunNow(page);
const run = await waitForRun(page, before);
expect(run.counts.cos_opened).toBeGreaterThan(0);
const cos = await getJson(page.request, `/api/packets/${packetId}/change-orders`);
const list = cos as unknown as Json[];
const co = list.find((c) => c.review === "pending");
expect(co, "a pending change order").toBeTruthy();
// The Changes tab on the packet and the Clerical home both show it
await page.goto(`/packets/${packetId}`);
await page.getByRole("tab", { name: /changes/i }).or(page.getByRole("button", { name: /^changes/i })).first().click();
await expect(page.getByText(/waiting on clerical/i).first()).toBeVisible();
await page.goto("/clerical");
await expect(page.getByText(/changes to approve/i).first()).toBeVisible();
await expect(page.getByText("L900001").first()).toBeVisible();
// The chained drawing run stages the rev 2 PDF
await expect
.poll(
async () => {
const all = await getJson(page.request, `/api/packets/${packetId}/change-orders`);
const c = (all as unknown as Json[]).find((x) => x.co_no === co!.co_no);
return c?.staged ? "staged" : "waiting";
},
{ timeout: POLL_MS, intervals: [1_000, 2_000] },
)
.toBe("staged");
const clara = dcUser("clerk.clara");
const r = await page.request.post(`/api/packets/${packetId}/change-orders/${co!.co_no}/approve`, {
headers: writeHeaders(page),
data: { sign: { factor: "password", secret: clara.password } },
});
expect(r.status(), await r.text()).toBe(200);
const pkt = await getJson(page.request, `/api/packets/${packetId}`);
expect(pkt.rev).toBe("2");
// The old rev 1 pages are kept and voided; the rev 2 pages carry the change order
const pages = (pkt.pages ?? []) as Json[];
expect(pages.some((pg) => pg.void)).toBe(true);
expect(pages.some((pg) => !pg.void && pg.rev === "2")).toBe(true);
await page.goto(`/packets/${packetId}`);
await expect(page.getByText(`${pkt.drawing} R2`, { exact: true }).first()).toBeVisible();
// The Drawing tab lists the voided rev 1 page beside the new rev 2 page
await expect(page.getByRole("button", { name: /\bvoid\b/i }).first()).toBeVisible();
});
});