ci(work-orders): add Playwright visual regression to CI

Run board screenshots in the existing Docker visual job with vendors.
This commit is contained in:
Arthur Bassi 2026-08-20 23:24:14 -03:00
parent c9f4adba08
commit 9d39d5bb14
10 changed files with 252 additions and 2 deletions

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

View 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");
});
});