ci(work-orders): add Playwright visual regression to CI
Run board screenshots in the existing Docker visual job with vendors.
4
.github/workflows/ci.yaml
vendored
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
- run: npm ci
|
||||
- run: npm run verify
|
||||
|
||||
vendor-visual-regression:
|
||||
visual-regression:
|
||||
runs-on: ubuntu-latest
|
||||
container: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
steps:
|
||||
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vendor-visual-regression-diffs
|
||||
name: visual-regression-diffs
|
||||
path: |
|
||||
test-results/visual
|
||||
playwright-report-visual
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 186 KiB |
250
e2e/work-orders/work-orders.visual.spec.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const FROZEN_NOW = "2026-08-19T15:00:00.000Z";
|
||||
const WEEK_START = "2026-08-17";
|
||||
const WEEK_END = "2026-08-21";
|
||||
|
||||
const TEST_USER = {
|
||||
token: "wo-visual-token",
|
||||
expiration: "2030-01-01T00:00:00.000Z",
|
||||
email: "admin@seahavenind.com",
|
||||
userRoles: "Admin",
|
||||
phoneNumber: "5551234567",
|
||||
fullname: "Work Order Visual Admin",
|
||||
id: "1",
|
||||
};
|
||||
|
||||
const DISPATCHERS = [{ id: "1", name: "Pat Dispatcher" }];
|
||||
|
||||
const LOCATIONS = [
|
||||
{
|
||||
id: 10,
|
||||
name: "STL-01 · St. Louis, MO",
|
||||
siteCode: "STL-01",
|
||||
city: "St. Louis",
|
||||
state: "MO",
|
||||
code: "STL-01",
|
||||
},
|
||||
];
|
||||
|
||||
const VENDORS = [
|
||||
{
|
||||
id: 101,
|
||||
companyName: "Gateway Plumbing",
|
||||
contactName: "Adam Whyte",
|
||||
tradeSpecialties: "Plumbing",
|
||||
address: "1 Market St",
|
||||
},
|
||||
];
|
||||
|
||||
const scheduledRow = {
|
||||
id: 1,
|
||||
woNumber: "WO-501",
|
||||
workOrderType: 2,
|
||||
siteCode: "STL-01",
|
||||
locationName: "St. Louis HQ",
|
||||
locationId: 10,
|
||||
lifecycleStatusLabel: "Scheduled",
|
||||
scheduledDate: "2026-08-19",
|
||||
dispatcherId: "1",
|
||||
dispatcherName: "Pat Dispatcher",
|
||||
pm: "HVAC",
|
||||
vendorId: 101,
|
||||
vendorName: "Gateway Plumbing",
|
||||
techName: "Adam Whyte",
|
||||
title: "Quarterly HVAC PM",
|
||||
dueDate: "2026-08-21",
|
||||
rowVersion: "rv-visual",
|
||||
};
|
||||
|
||||
const unassignedRow = {
|
||||
...scheduledRow,
|
||||
id: 2,
|
||||
woNumber: "WO-502",
|
||||
scheduledDate: null,
|
||||
lifecycleStatusLabel: "Pending",
|
||||
title: "Boiler leak follow-up",
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
function isBoardList(pathname: string) {
|
||||
return pathname.endsWith("/workorders/board") || pathname.endsWith("/api/workorders/board");
|
||||
}
|
||||
|
||||
function isBoardSearch(pathname: string) {
|
||||
return pathname.includes("/workorders/board/search");
|
||||
}
|
||||
|
||||
function isBoardDetail(pathname: string) {
|
||||
return /\/workorders\/\d+\/detail$/.test(pathname);
|
||||
}
|
||||
|
||||
async function mockWorkOrderApi(page: Page, mode: "default" | "empty" | "error" = "default") {
|
||||
await page.clock.install({ time: new Date(FROZEN_NOW) });
|
||||
await page.clock.resume();
|
||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
||||
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (!pathname.startsWith("/api/")) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBoardSearch(pathname)) {
|
||||
const items = mode === "default" ? [unassignedRow] : [];
|
||||
await fulfillJson(route, { items, totalCount: items.length, page: 0, pageSize: 50 });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBoardList(pathname)) {
|
||||
if (mode === "error") {
|
||||
await fulfillJson(route, { message: "Work order board unavailable" }, 500);
|
||||
return;
|
||||
}
|
||||
const scheduled = mode === "empty" ? [] : [scheduledRow];
|
||||
const unscheduled = mode === "empty" ? [] : [unassignedRow];
|
||||
await fulfillJson(route, {
|
||||
weekStart: WEEK_START,
|
||||
weekEnd: WEEK_END,
|
||||
counts: { returned: scheduled.length + unscheduled.length, total: scheduled.length + 1 },
|
||||
unscheduled,
|
||||
scheduled,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBoardDetail(pathname)) {
|
||||
await fulfillJson(route, {
|
||||
data: {
|
||||
info: scheduledRow,
|
||||
comments: [],
|
||||
audit: [],
|
||||
media: [],
|
||||
completion: {},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.includes("/workorders/lookups/dispatchers")) {
|
||||
await fulfillJson(route, { data: DISPATCHERS });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.includes("/Contact/GetLocations") || pathname.includes("/locations/sites")) {
|
||||
await fulfillJson(route, { data: LOCATIONS });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.includes("/vendors/Dropdown") || pathname.endsWith("/vendors/dropdown")) {
|
||||
await fulfillJson(route, { data: VENDORS });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.includes("/DropdownOptions/ByCategory/Problem")) {
|
||||
await fulfillJson(route, {
|
||||
data: [
|
||||
{ id: 1, value: "HVAC", category: "Problem" },
|
||||
{ id: 2, value: "Plumbing", category: "Problem" },
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.includes("/api/User")) {
|
||||
await fulfillJson(route, { data: DISPATCHERS });
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, { data: [] });
|
||||
});
|
||||
}
|
||||
|
||||
async function openWorkOrderPage(page: Page, mode: "default" | "empty" | "error" = "default") {
|
||||
await mockWorkOrderApi(page, mode);
|
||||
await page.goto("/workorders");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
*, *::before, *::after { transition: none !important; animation: none !important; }
|
||||
.MuiDrawer-paper { transform: none !important; }
|
||||
.Toastify__toast-container { display: none !important; }
|
||||
.tsqd-parent-container { display: none !important; }
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectStableScreenshot(page: Page, name: string) {
|
||||
await page.waitForTimeout(250);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
await page.screenshot({ animations: "disabled", caret: "hide" });
|
||||
await expect(page).toHaveScreenshot(name);
|
||||
}
|
||||
|
||||
test.describe("Work Orders deterministic pixel regression", () => {
|
||||
test("list", async ({ page }) => {
|
||||
await openWorkOrderPage(page);
|
||||
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible();
|
||||
await expect(page.getByText("WO-501").first()).toBeVisible();
|
||||
await expectStableScreenshot(page, "wo-list.png");
|
||||
});
|
||||
|
||||
test("filters", async ({ page }) => {
|
||||
await openWorkOrderPage(page);
|
||||
await page.getByRole("button", { name: "Advanced Filters" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Advanced Filters" })).toBeVisible();
|
||||
await expectStableScreenshot(page, "wo-filters.png");
|
||||
});
|
||||
|
||||
test("new", async ({ page }) => {
|
||||
await openWorkOrderPage(page);
|
||||
await page.getByRole("button", { name: "New WO" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Type & schedule" })).toBeVisible();
|
||||
await expectStableScreenshot(page, "wo-new.png");
|
||||
});
|
||||
|
||||
test("detail", async ({ page }) => {
|
||||
await openWorkOrderPage(page);
|
||||
const row = page.locator("#wo-row-1");
|
||||
await row.hover();
|
||||
await row.getByRole("button", { name: "View details" }).click();
|
||||
await expect(page.getByRole("heading", { name: "#WO-501" })).toBeVisible();
|
||||
await expectStableScreenshot(page, "wo-detail.png");
|
||||
});
|
||||
|
||||
test("empty", async ({ page }) => {
|
||||
await openWorkOrderPage(page, "empty");
|
||||
await expect(page.getByRole("heading", { name: "Work Orders" })).toBeVisible();
|
||||
await page.getByLabel("Search work orders").fill("zz");
|
||||
await expect(page.getByText("No work orders match your search")).toBeVisible();
|
||||
await expectStableScreenshot(page, "wo-empty.png");
|
||||
});
|
||||
|
||||
test("error", async ({ page }) => {
|
||||
await openWorkOrderPage(page, "error");
|
||||
await expect(page.getByRole("main").getByRole("alert")).toContainText(/server error/i);
|
||||
await expectStableScreenshot(page, "wo-error.png");
|
||||
});
|
||||
|
||||
test("mobile", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openWorkOrderPage(page);
|
||||
await expect(page.getByText("WO-501").first()).toBeVisible();
|
||||
await expectStableScreenshot(page, "wo-mobile.png");
|
||||
|
||||
await page.getByRole("button", { name: "Toggle navigation" }).click();
|
||||
await expect(page.getByRole("navigation", { name: "Primary navigation" })).toBeVisible();
|
||||
await expectStableScreenshot(page, "wo-mobile-navigation.png");
|
||||
});
|
||||
});
|
||||