diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cde0f414..100fff7d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png new file mode 100644 index 00000000..f4a0af4c Binary files /dev/null and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-detail.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png new file mode 100644 index 00000000..304f8ce1 Binary files /dev/null and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-empty.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png new file mode 100644 index 00000000..c6cb53ae Binary files /dev/null and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-error.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png new file mode 100644 index 00000000..fcd42500 Binary files /dev/null and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-filters.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png new file mode 100644 index 00000000..0477e77f Binary files /dev/null and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-list.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png new file mode 100644 index 00000000..ddf4439b Binary files /dev/null and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile-navigation.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png new file mode 100644 index 00000000..23bcd83b Binary files /dev/null and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-mobile.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png new file mode 100644 index 00000000..928be509 Binary files /dev/null and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png differ diff --git a/e2e/work-orders/work-orders.visual.spec.ts b/e2e/work-orders/work-orders.visual.spec.ts new file mode 100644 index 00000000..d30fc454 --- /dev/null +++ b/e2e/work-orders/work-orders.visual.spec.ts @@ -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((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"); + }); +});