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]>
This commit is contained in:
@@ -141,12 +141,13 @@ test.describe.serial("VISUAL feed", () => {
|
||||
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: pull each one, as the warehouse would
|
||||
// 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 },
|
||||
data: { pulled: true, heat: `HT-${String(line.item)}` },
|
||||
});
|
||||
expect(pr.status(), await pr.text()).toBe(200);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { signInPassword } from "./helpers";
|
||||
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.
|
||||
// clerk.clara makes two packets by API (a hand draft on a fresh unit, BOM lines 1 to 3, a tiny PDF drawing,
|
||||
// heat map answered, sent to the warehouse). "warehouse board": wh.wade moves the first one across the board
|
||||
// with the card buttons, the hold sheet and drag, and a drop on Released to shop is refused with a toast;
|
||||
// sup.wes sees the pill and the line but no warehouse buttons. "pull flow": wh.wade pulls the second one on
|
||||
// its Materials tab (Pulled tick, heat with Enter, a short, an MTR), releases it, then receives the short
|
||||
// from the Open shorts table on /warehouse.
|
||||
// Every label used here is in L (whhelpers.ts), copied from the phase 4 design contract.
|
||||
|
||||
const RUN = Date.now().toString(36).toUpperCase().slice(-5);
|
||||
const PROJECT = `W4${RUN}`;
|
||||
const BOARD_TITLE = `Board spool ${RUN}`;
|
||||
const PULL_TITLE = `Pull spool ${RUN}`;
|
||||
const HOLD_REASON = "Waiting on purchasing / PO";
|
||||
const HOLD_NOTE = "PO 4471 due Fri";
|
||||
|
||||
let boardPkt: MadePacket;
|
||||
let pullPkt: MadePacket;
|
||||
|
||||
function row(page: Page, description: string) {
|
||||
return page.getByRole("row").filter({ hasText: description }).first();
|
||||
}
|
||||
|
||||
test.describe.serial("warehouse", () => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
test("clerk.clara makes two warehouse packets by API", async ({ page }) => {
|
||||
await signInPassword(page, "clerk.clara");
|
||||
boardPkt = await makeWarehousePacket(page, PROJECT, BOARD_TITLE);
|
||||
pullPkt = await makeWarehousePacket(page, PROJECT, PULL_TITLE);
|
||||
expect(boardPkt.id).toBeGreaterThan(0);
|
||||
expect(pullPkt.id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("warehouse board", async ({ page }) => {
|
||||
await signInPassword(page, "wh.wade");
|
||||
await page.goto("/warehouse");
|
||||
await expect(page.getByRole("heading", { name: L.pageHead, exact: true })).toBeVisible();
|
||||
await expect(page.getByText(L.eyebrow, { exact: true }).first()).toBeVisible();
|
||||
for (const title of [L.colIncoming, L.colPulling, L.colHold, L.colPulled, L.colReleased]) {
|
||||
await expect(column(page, title)).toBeVisible();
|
||||
}
|
||||
|
||||
// Waiting to pull, then Start pulling
|
||||
let c = card(column(page, L.colIncoming), BOARD_TITLE);
|
||||
await expect(c).toBeVisible();
|
||||
await expect(c.getByText(L.pillWaiting, { exact: true })).toBeVisible();
|
||||
await expect(c.getByText(/^Waiting .*not started yet$/)).toBeVisible();
|
||||
await c.getByRole("button", { name: L.startPulling, exact: true }).click();
|
||||
c = card(column(page, L.colPulling), BOARD_TITLE);
|
||||
await expect(c).toBeVisible();
|
||||
await expect(c.getByText(L.pillPulling, { exact: true })).toBeVisible();
|
||||
await expect(c.getByText(/^Being pulled by Wade Warehouse/)).toBeVisible();
|
||||
|
||||
// Hold with a reason and a note
|
||||
await c.getByRole("button", { name: L.hold, exact: true }).click();
|
||||
const sheet = page.getByRole("dialog", { name: L.holdSheet });
|
||||
await expect(sheet).toBeVisible();
|
||||
await sheet.getByRole("radio", { name: HOLD_REASON }).check();
|
||||
await sheet.getByLabel(L.holdNote).fill(HOLD_NOTE);
|
||||
await sheet.getByRole("button", { name: L.putOnHold, exact: true }).click();
|
||||
await expect(sheet).toBeHidden();
|
||||
c = card(column(page, L.colHold), BOARD_TITLE);
|
||||
await expect(c).toBeVisible();
|
||||
await expect(c.getByText(L.pillHold, { exact: true })).toBeVisible();
|
||||
await expect(c.getByText(`On hold: ${HOLD_REASON} · ${HOLD_NOTE}`, { exact: false })).toBeVisible();
|
||||
expect(await whStage(page, boardPkt.id)).toBe("hold");
|
||||
|
||||
// Resume pulling by button
|
||||
await c.getByRole("button", { name: L.resumePulling, exact: true }).click();
|
||||
c = card(column(page, L.colPulling), BOARD_TITLE);
|
||||
await expect(c).toBeVisible();
|
||||
expect(await whStage(page, boardPkt.id)).toBe("pulling");
|
||||
|
||||
// Drag from Being pulled to Pulled, ready for production. Lines are still unticked: confirm it.
|
||||
await drag(page, c, column(page, L.colPulled));
|
||||
const confirm = page.getByRole("dialog", { name: L.notAllPulled });
|
||||
await expect(confirm).toBeVisible();
|
||||
await confirm.getByRole("button", { name: L.markPulled, exact: true }).click();
|
||||
await expect(confirm).toBeHidden();
|
||||
c = card(column(page, L.colPulled), BOARD_TITLE);
|
||||
await expect(c).toBeVisible();
|
||||
await expect(c.getByText(L.pillPulled, { exact: true })).toBeVisible();
|
||||
await expect.poll(() => whStage(page, boardPkt.id)).toBe("pulled");
|
||||
await expect(page).toHaveURL(/\/warehouse/); // a drag is not a click: the packet did not open
|
||||
|
||||
// A drop on Released to shop is refused with the toast; the card stays where it was
|
||||
await drag(page, c, column(page, L.colReleased));
|
||||
await expect(page.getByText(L.dropReleasedToast)).toBeVisible();
|
||||
await expect(card(column(page, L.colPulled), BOARD_TITLE)).toBeVisible();
|
||||
await expect(card(column(page, L.colReleased), BOARD_TITLE)).toHaveCount(0);
|
||||
const pkt = await getJson(page.request, `/api/packets/${boardPkt.id}`);
|
||||
expect(pkt.status).toBe("warehouse");
|
||||
expect(pkt.wh.stage).toBe("pulled");
|
||||
});
|
||||
|
||||
test("sup.wes sees the warehouse pill and line but no warehouse buttons", async ({ page }) => {
|
||||
await signInPassword(page, "sup.wes");
|
||||
await page.goto(`/packets/${boardPkt.id}`);
|
||||
await expect(page.getByText(L.pillPulled, { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText(/^Pulled by Wade Warehouse .*ready for production$/).first()).toBeVisible();
|
||||
for (const name of [L.startPulling, L.markPulled, L.resumePulling, L.backToPulling, L.putOnHold, L.sendBack]) {
|
||||
await expect(page.getByRole("button", { name, exact: true })).toHaveCount(0);
|
||||
}
|
||||
await expect(page.getByRole("button", { name: new RegExp(`^${L.releaseToSupervisor}`) })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("pull flow", async ({ page }) => {
|
||||
await signInPassword(page, "wh.wade");
|
||||
await page.goto(`/packets/${pullPkt.id}`);
|
||||
await page
|
||||
.getByRole("tab", { name: new RegExp(`^${L.materialsTab}`) })
|
||||
.or(page.getByRole("button", { name: new RegExp(`^${L.materialsTab}`) }))
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const r1 = row(page, "4in pipe SCH40");
|
||||
const r2 = row(page, "4in flange");
|
||||
await expect(r1).toBeVisible();
|
||||
|
||||
// Tick line 1 Pulled: the server starts pulling
|
||||
await page.getByLabel(L.line(1, "pulled"), { exact: true }).check();
|
||||
await expect(page.getByText(L.pillPulling, { exact: true }).first()).toBeVisible();
|
||||
await expect.poll(() => whStage(page, pullPkt.id)).toBe("pulling");
|
||||
|
||||
// A heat and Enter: saved, and the focus moves to line 2's heat input (a scanner sends Enter)
|
||||
const h1 = page.getByLabel(L.line(1, "heat"), { exact: true });
|
||||
await expect(h1).toHaveAttribute("placeholder", L.heatPlaceholder);
|
||||
await h1.fill("HT-E2E-1");
|
||||
await h1.press("Enter");
|
||||
await expect(page.getByLabel(L.line(2, "heat"), { exact: true })).toBeFocused();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const p = await getJson(page.request, `/api/packets/${pullPkt.id}`);
|
||||
return (p.bom as Array<Record<string, any>>).find((b) => b.item === 1)?.heat ?? null;
|
||||
})
|
||||
.toBe("HT-E2E-1");
|
||||
|
||||
// Record a short on line 2: 1 of 2, Not in stock, with the heat of the piece that was pulled
|
||||
await r2
|
||||
.getByRole("button", { name: L.recordShort, exact: true })
|
||||
.or(r2.getByRole("button", { name: L.shortButton, exact: true }))
|
||||
.first()
|
||||
.click();
|
||||
const ss = page.getByRole("dialog", { name: L.recordShortSheet });
|
||||
await expect(ss).toBeVisible();
|
||||
await ss.getByLabel(L.qtyShort).fill("1");
|
||||
await ss.getByLabel(L.reason, { exact: true }).selectOption("Not in stock");
|
||||
await ss.getByLabel(L.heatForPulled).fill("HT-E2E-2");
|
||||
await ss.getByRole("button", { name: L.saveShort, exact: true }).click();
|
||||
await expect(ss).toBeHidden();
|
||||
await expect(r2.getByText(/short/i).first()).toBeVisible();
|
||||
|
||||
// A small PNG as the MTR on line 1
|
||||
await page
|
||||
.getByLabel(L.line(1, "MTR file"), { exact: true })
|
||||
.setInputFiles({ name: "mtr-photo.png", mimeType: "image/png", buffer: tinyPng() });
|
||||
await expect(r1.getByRole("button", { name: "MTR", exact: true })).toBeVisible();
|
||||
await expect(r1.getByText("Replace", { exact: true })).toBeVisible();
|
||||
|
||||
// Line 3 is not pulled: Release to supervisor is disabled with the server's reason under it
|
||||
const release = page.getByRole("button", { name: new RegExp(`^${L.releaseToSupervisor}`) });
|
||||
await expect(release).toBeDisabled();
|
||||
await expect(page.getByText(L.msgPull).first()).toBeVisible();
|
||||
|
||||
// Tick line 3 and give it a heat
|
||||
await page.getByLabel(L.line(3, "pulled"), { exact: true }).check();
|
||||
const h3 = page.getByLabel(L.line(3, "heat"), { exact: true });
|
||||
await expect(h3).toBeEnabled();
|
||||
await h3.fill("HT-E2E-3");
|
||||
await h3.press("Enter");
|
||||
await expect(release).toBeEnabled();
|
||||
await expect(release).toHaveText(new RegExp(`${L.releaseToSupervisor} \\(1 short\\)`));
|
||||
await release.click();
|
||||
await expect(page.getByText(L.readyToAssign).first()).toBeVisible();
|
||||
const pkt = await getJson(page.request, `/api/packets/${pullPkt.id}`);
|
||||
expect(pkt.status).toBe("ready");
|
||||
expect(pkt.open_shorts).toBe(1);
|
||||
|
||||
// Receive the short from the Open shorts table on the warehouse home
|
||||
await page.goto("/warehouse");
|
||||
const shortRow = page.getByRole("row").filter({ hasText: pullPkt.unitNumber || PULL_TITLE }).filter({ hasText: "Not in stock" });
|
||||
await expect(shortRow.first()).toBeVisible();
|
||||
await shortRow.first().getByRole("button", { name: L.receive, exact: true }).click();
|
||||
const rs = page.getByRole("dialog", { name: L.receiveSheet });
|
||||
await expect(rs).toBeVisible();
|
||||
await rs.getByLabel(L.qtyReceived).fill("1");
|
||||
await rs.getByLabel(L.heat, { exact: true }).fill("HT-E2E-R");
|
||||
await rs.getByRole("button", { name: L.receive, exact: true }).click();
|
||||
await expect(rs).toBeHidden();
|
||||
await expect(page.getByText(L.noShorts)).toBeVisible();
|
||||
const after = await getJson(page.request, `/api/packets/${pullPkt.id}`);
|
||||
expect(after.open_shorts).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { expect, type APIRequestContext, type Locator, type Page } from "@playwright/test";
|
||||
import { dcUser } from "./helpers";
|
||||
|
||||
// Helpers for the phase 4 warehouse scenarios: packets made through the API, and the board locators.
|
||||
// The labels are the exact texts of the phase 4 design contract (Frontend (C)). The frontend matches them.
|
||||
|
||||
export type Json = Record<string, any>;
|
||||
|
||||
export const L = {
|
||||
pageHead: "Warehouse",
|
||||
eyebrow: "Parts pull",
|
||||
colIncoming: "Waiting to pull",
|
||||
colPulling: "Being pulled",
|
||||
colHold: "On hold",
|
||||
colPulled: "Pulled, ready for production",
|
||||
colReleased: "Released to shop (3 days)",
|
||||
pillWaiting: "WH · waiting",
|
||||
pillPulling: "WH · being pulled",
|
||||
pillHold: "WH · on hold",
|
||||
pillPulled: "WH · pulled",
|
||||
startPulling: "Start pulling",
|
||||
hold: "Hold",
|
||||
markPulled: "Mark pulled",
|
||||
resumePulling: "Resume pulling",
|
||||
backToPulling: "Back to pulling",
|
||||
releaseCard: "Release…",
|
||||
putOnHold: "Put on hold",
|
||||
cancel: "Cancel",
|
||||
dropReleasedToast: "Open the packet and use Release to supervisor. It checks heats and shorts first.",
|
||||
sendBack: "Send back to Clerical",
|
||||
releaseToSupervisor: "Release to supervisor",
|
||||
materialsTab: "Materials",
|
||||
heatPlaceholder: "Scan / type heat",
|
||||
recordShort: "Record short",
|
||||
shortButton: "Short",
|
||||
line: (n: number, what: "pulled" | "issued" | "location" | "heat" | "N/A" | "MTR file") => `Line ${n} ${what}`,
|
||||
qtyShort: "Qty short",
|
||||
reason: "Reason",
|
||||
heatForPulled: "Heat no. for the pulled pieces",
|
||||
holdSheet: "Put on hold",
|
||||
holdNote: /^Note/,
|
||||
recordShortSheet: "Record short",
|
||||
receiveSheet: "Receive short part",
|
||||
notAllPulled: "Not everything is pulled yet",
|
||||
saveShort: "Save short",
|
||||
receive: "Receive",
|
||||
qtyReceived: "Qty received",
|
||||
heat: "Heat",
|
||||
note: "Note",
|
||||
openShorts: "Open shorts",
|
||||
noShorts: "No open shorts. When a part isn't on the shelf, mark it Short on the pull and it lands here.",
|
||||
readyToAssign: "Ready to assign",
|
||||
msgPull: "Pull every material line or mark it short first.",
|
||||
msgHeat: "Scan or type the heat number of every pulled line first. Use N/A for a part with no traceability.",
|
||||
} as const;
|
||||
|
||||
export function writeHeaders(page: Page): Record<string, string> {
|
||||
const base = new URL(page.url()).origin;
|
||||
return { Origin: base, "X-Requested-With": "PacketStation" };
|
||||
}
|
||||
|
||||
export 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 sendJson(page: Page, method: "post" | "patch", url: string, data: Json, status = 200): Promise<Json> {
|
||||
const r = await page.request[method](url, { headers: writeHeaders(page), data });
|
||||
expect(r.status(), `${method} ${url}: ${await r.text()}`).toBe(status);
|
||||
return (await r.json()) as Json;
|
||||
}
|
||||
|
||||
/** A minimal valid one-page PDF, built from raw bytes with a correct xref table. */
|
||||
export function onePagePdf(): Buffer {
|
||||
const content = "0 0 m 612 792 l S";
|
||||
const objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> >>",
|
||||
`<< /Length ${content.length} >>\nstream\n${content}\nendstream`,
|
||||
];
|
||||
let out = "%PDF-1.4\n";
|
||||
const offsets: number[] = [];
|
||||
objects.forEach((body, i) => {
|
||||
offsets.push(out.length);
|
||||
out += `${i + 1} 0 obj\n${body}\nendobj\n`;
|
||||
});
|
||||
const xref = out.length;
|
||||
out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||
out += offsets.map((o) => `${String(o).padStart(10, "0")} 00000 n \n`).join("");
|
||||
out += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
||||
return Buffer.from(out, "latin1");
|
||||
}
|
||||
|
||||
/** A 1x1 PNG. */
|
||||
export function tinyPng(): Buffer {
|
||||
return Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
}
|
||||
|
||||
export interface MadePacket {
|
||||
id: number;
|
||||
title: string;
|
||||
unitNumber: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* As the signed-in Clerical user: a project with one unit, a hand draft with BOM lines 1 to 3, a drawing
|
||||
* attached with a password signature, the heat map answered, sent to the warehouse.
|
||||
*/
|
||||
export async function makeWarehousePacket(page: Page, projectNumber: string, title: string): Promise<MadePacket> {
|
||||
const projects = (await getJson(page.request, "/api/projects?status=all")) as unknown as Json[];
|
||||
let project = projects.find((p) => p.number === projectNumber);
|
||||
if (!project) {
|
||||
project = await sendJson(page, "post", "/api/projects", { number: projectNumber, name: "CI warehouse" }, 201);
|
||||
}
|
||||
const units = (await sendJson(page, "post", `/api/projects/${project.id}/units`, {}, 201)) as unknown as Json[];
|
||||
const unit = units[0];
|
||||
const draft = await sendJson(
|
||||
page,
|
||||
"post",
|
||||
"/api/packets",
|
||||
{
|
||||
unit_id: unit.id,
|
||||
dept_code: "03",
|
||||
op: "WELD-PART",
|
||||
title,
|
||||
customer: "CI Customer",
|
||||
drawing: `D-${projectNumber}`,
|
||||
rev: "A",
|
||||
work_orders: [],
|
||||
bom: [
|
||||
{ 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" },
|
||||
],
|
||||
},
|
||||
201,
|
||||
);
|
||||
const up = await page.request.post("/api/assets", {
|
||||
headers: writeHeaders(page),
|
||||
multipart: {
|
||||
file: { name: `${projectNumber}.pdf`, mimeType: "application/pdf", buffer: onePagePdf() },
|
||||
purpose: "drawing",
|
||||
},
|
||||
});
|
||||
expect(up.status(), await up.text()).toBe(201);
|
||||
const asset = (await up.json()) as Json;
|
||||
await sendJson(page, "post", `/api/packets/${draft.id}/pages`, {
|
||||
asset_id: asset.id,
|
||||
type: "drawing",
|
||||
sign: { factor: "password", secret: dcUser("clerk.clara").password },
|
||||
});
|
||||
await sendJson(page, "patch", `/api/packets/${draft.id}`, { set: { heat_map: true }, base: {} });
|
||||
const pkt = await getJson(page.request, `/api/packets/${draft.id}`);
|
||||
const sent = await sendJson(page, "post", `/api/packets/${draft.id}/transitions`, {
|
||||
action: "send_to_warehouse",
|
||||
expect: { status: pkt.status, dept: pkt.dept.code },
|
||||
});
|
||||
expect(sent.status).toBe("warehouse");
|
||||
return { id: Number(draft.id), title, unitNumber: String(unit.number ?? pkt.unit?.number ?? "") };
|
||||
}
|
||||
|
||||
/** A board column by its title: a region named by the title, or a block that has it as a heading. */
|
||||
export function column(page: Page, title: string): Locator {
|
||||
return page
|
||||
.getByRole("region", { name: title, exact: true })
|
||||
.or(page.locator("section, [data-whcol]").filter({ has: page.getByRole("heading", { name: title, exact: true }) }))
|
||||
.first();
|
||||
}
|
||||
|
||||
/** A packet card, found by its unique title, anywhere under `scope`. */
|
||||
export function card(scope: Page | Locator, title: string): Locator {
|
||||
// Warehouse cards are role=group named "{job}, {stage name}"; the released column shows plain cards.
|
||||
return scope
|
||||
.getByRole("group")
|
||||
.filter({ hasText: title })
|
||||
.or(scope.locator(".card").filter({ hasText: title }))
|
||||
.first();
|
||||
}
|
||||
|
||||
/** Drag with the pointer from the middle of `from` to the middle of `to`, in steps (a real drag, not a click). */
|
||||
export async function drag(page: Page, from: Locator, to: Locator): Promise<void> {
|
||||
await from.scrollIntoViewIfNeeded();
|
||||
const a = await from.boundingBox();
|
||||
const b = await to.boundingBox();
|
||||
if (!a || !b) throw new Error("drag: element not visible");
|
||||
// Grab the card on its title line, away from its buttons.
|
||||
await page.mouse.move(a.x + a.width / 2, a.y + 12);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(a.x + a.width / 2 + 15, a.y + 20, { steps: 4 });
|
||||
await page.mouse.move(b.x + b.width / 2, b.y + Math.min(b.height / 2, 120), { steps: 16 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
export async function whStage(page: Page, id: number): Promise<string | null> {
|
||||
const p = await getJson(page.request, `/api/packets/${id}`);
|
||||
return p.wh ? String(p.wh.stage) : null;
|
||||
}
|
||||
Reference in New Issue
Block a user