import { expect, test, type Page, type Route } from "@playwright/test"; const TEST_USER = { token: "vendor-e2e-token", expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), email: "admin@seahavenind.com", userRoles: "Admin", phoneNumber: "5551234567", fullname: "Vendor E2E Admin", id: "1", }; const activeVendors = [ { Id: 1, CompanyId: 101, CompanyName: "Gateway Plumbing", ContactName: "Adam Whyte", Email: "dispatch@gateway.test", Phone: "314-555-0198", CompanyPhone: "314-555-0100", Address: "1 Market St", City: "St. Louis", State: "MO", Zip: "63101", TradeSpecialties: "Plumbing, Backflow Preventers", GoogleMapsUrl: "https://maps.google.com/gateway", Notes: "Preferred after-hours vendor", TotalJobs: 72, IsActive: true, PreferredContact: "Email", }, { Id: 2, CompanyId: 102, CompanyName: "Metro HVAC", ContactName: "Maya Chen", Email: "maya@metro.test", Phone: "314-555-0112", CompanyPhone: "314-555-0110", Address: "2 Pine St", City: "Clayton", State: "MO", Zip: "63105", TradeSpecialties: "HVAC", GoogleMapsUrl: "", Notes: "", TotalJobs: 18, IsActive: true, }, ]; const inactiveVendors = [ { ...activeVendors[0], Id: 3, CompanyName: "Drain Cleaning Bros", ContactName: "Drew Banks", TotalJobs: 151, IsActive: false, }, ]; interface MockState { listUrls: string[]; createdBody?: Record; updatedBody?: Record; deletedId?: string; } async function fulfillJson(route: Route, body: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); } async function captureVisual(page: Page, path: string) { if (!process.env.VENDOR_VISUAL_CAPTURE) return; await page.waitForTimeout(400); await page.screenshot({ path, fullPage: true }); } async function mockVendorApi( page: Page, options: { listError?: boolean; deactivationBlocked?: boolean; deleteConflict?: boolean } = {}, ) { const state: MockState = { listUrls: [] }; await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); await page.route("**/api/DropdownOptions/ByCategory/Trade*", (route) => fulfillJson(route, { data: [ { id: 1, value: "Plumbing" }, { id: 2, value: "HVAC" }, ], }), ); await page.route("**/api/Vendor/facets*", (route) => fulfillJson(route, { data: { companies: activeVendors.map((vendor) => ({ companyId: vendor.CompanyId, name: vendor.CompanyName, companyPhone: vendor.CompanyPhone, email: vendor.Email, address: vendor.Address, city: vendor.City, state: vendor.State, zip: vendor.Zip, googleMapsUrl: vendor.GoogleMapsUrl, })), trades: ["Plumbing", "Backflow Preventers", "HVAC"], locations: [ { city: "St. Louis", state: "MO", label: "St. Louis, MO" }, { city: "Clayton", state: "MO", label: "Clayton, MO" }, ], jobBuckets: [ { id: "under-50", label: "Under 50" }, { id: "50-99", label: "50–99" }, { id: "100-149", label: "100–149" }, { id: "150-plus", label: "150+" }, ], }, }), ); await page.route("**/api/Vendor/GetVendorList*", async (route) => { const url = new URL(route.request().url()); state.listUrls.push(url.toString()); if (options.listError && url.searchParams.get("pageSize") !== "1") { await fulfillJson(route, { message: "Vendor directory unavailable" }, 500); return; } const isActive = url.searchParams.get("isActive") !== "false"; const source = isActive ? activeVendors : inactiveVendors; const search = (url.searchParams.get("search") ?? "").toLowerCase(); const company = url.searchParams.get("companies[0]"); const filtered = source.filter( (vendor) => (!search || `${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties}` .toLowerCase() .includes(search)) && (!company || vendor.CompanyName === company), ); const isCount = url.searchParams.get("pageSize") === "1"; await fulfillJson(route, { Data: isCount ? filtered.slice(0, 1) : filtered, TotalCount: filtered.length, PageNumber: 1, PageSize: Number(url.searchParams.get("pageSize") ?? 12), }); }); await page.route(/\/api\/vendors\/\d+\/deactivation-impact$/, async (route) => { const vendorId = Number(route.request().url().split("/").at(-2)); await fulfillJson(route, { data: { vendorId, canDeactivate: !options.deactivationBlocked, openWorkOrders: options.deactivationBlocked ? [ { workOrderId: 501, workOrderNumber: "WO-501", workOrderTitle: "Emergency boiler repair", status: "Dispatched", scheduledDate: "2026-07-24T14:00:00Z", dispatchId: 801, }, ] : [], }, }); }); await page.route(/\/api\/vendors\/\d+$/, async (route) => { const id = route.request().url().split("/").pop() ?? ""; if (route.request().method() === "PUT") { state.updatedBody = route.request().postDataJSON(); await fulfillJson(route, { data: { ...activeVendors[0], ...state.updatedBody, Id: Number(id) }, }); return; } if (route.request().method() === "DELETE") { if (options.deleteConflict) { await fulfillJson( route, { message: "Vendor has open work orders and cannot be deactivated." }, 409, ); return; } state.deletedId = id; await fulfillJson(route, { status: "200" }); return; } const vendor = [...activeVendors, ...inactiveVendors].find((item) => String(item.Id) === id); await fulfillJson(route, { data: vendor }); }); await page.route(/\/api\/vendors\/?$/, async (route) => { if (route.request().method() !== "POST") { await route.fallback(); return; } state.createdBody = route.request().postDataJSON(); await fulfillJson(route, { data: { ...state.createdBody, id: 99, totalJobs: 0 } }); }); return state; } test.describe("Vendor directory prototype parity", () => { test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({ page, }) => { const state = await mockVendorApi(page); await page.goto("/vendors"); await expect(page.getByRole("heading", { name: "Vendors / Technicians" })).toBeVisible(); await expect(page.getByText("3 technicians · 2 active")).toBeVisible(); await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute( "aria-selected", "true", ); for (const heading of [ "Company", "Technician", "Location", "Trade", "Phone", "Email", "Total Jobs", "Status", ]) { await expect(page.getByRole("columnheader", { name: heading })).toBeVisible(); } await expect(page.getByText("Gateway Plumbing")).toBeVisible(); await expect(page.getByText("+1")).toBeVisible(); await expect(page.getByText("2 active technicians · 2 active · 1 inactive")).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png"); await page.getByPlaceholder("Search name, company, trade...").fill("metro"); await expect(page.getByText("Metro HVAC")).toBeVisible(); await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe( true, ); await page.getByPlaceholder("Search name, company, trade...").clear(); await page.getByRole("button", { name: "Open vendor filters" }).click(); await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-filters.png"); await page.getByRole("button", { name: /Company/ }).click(); await page.getByPlaceholder("All companies").click(); await page.getByRole("option", { name: /Gateway Plumbing/ }).click(); await page.getByRole("button", { name: "Apply" }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); await expect(page.getByText(/1 active technician · filtered/)).toBeVisible(); expect( state.listUrls.some( (url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing", ), ).toBe(true); await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText( "history will be preserved", ); await page.getByRole("button", { name: "Deactivate", exact: true }).click(); await expect.poll(() => state.deletedId).toBe("1"); await page.getByRole("tab", { name: "Inactive (1)" }).click(); await expect(page.getByText("No vendors match the current filters.")).toBeVisible(); await page.getByRole("button", { name: "Clear all" }).click(); await expect(page.getByText("Drain Cleaning Bros")).toBeVisible(); await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0); }); test("creates with company autofill, then views and edits every detail field", async ({ page, }) => { const state = await mockVendorApi(page); await page.goto("/vendors"); await page.getByRole("button", { name: "+ Add Vendor" }).click(); await expect(page.getByText("Register a technician and their vendor company.")).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png"); await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled(); await page.getByLabel("Technician Name").fill("New Technician"); await page.getByRole("combobox", { name: "Company" }).click(); await page.getByRole("option", { name: "Gateway Plumbing" }).click(); await expect(page.getByLabel("Company Phone")).toHaveValue("314-555-0100"); await expect(page.getByRole("textbox", { name: "Email" })).toHaveValue("dispatch@gateway.test"); await expect(page.getByLabel("Address")).toHaveValue("1 Market St"); await expect(page.getByLabel("Google Maps URL")).toHaveValue("https://maps.google.com/gateway"); await page.getByRole("combobox", { name: "Add Trade" }).fill("Plumbing"); await page.getByRole("button", { name: "Add trade" }).click(); await page.getByRole("combobox", { name: "Add Trade" }).fill("HVAC"); await page.getByRole("button", { name: "Add trade" }).click(); await page.getByRole("button", { name: "Move HVAC up" }).click(); await page.getByLabel("Notes").fill("Created in browser E2E"); await page.getByRole("button", { name: "Text" }).click(); await expect(page.getByRole("button", { name: "Text" })).toHaveAttribute( "aria-pressed", "true", ); await page.getByRole("button", { name: "Add Vendor" }).last().click(); await expect(page.getByRole("dialog", { name: "Add Vendor" })).toHaveCount(0); expect(state.createdBody).toMatchObject({ companyName: "Gateway Plumbing", companyId: 101, contactName: "New Technician", companyPhone: "(314) 555-0100", preferredContact: "Text", tradeSpecialties: "HVAC, Plumbing", notes: "Created in browser E2E", }); await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); await expect(detailDrawer.getByRole("heading", { name: "Gateway Plumbing" })).toBeVisible(); await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible(); await expect(detailDrawer.getByText("72")).toBeVisible(); await expect( detailDrawer.getByRole("link", { name: "Open in Google Maps" }).first(), ).toHaveAttribute("target", "_blank"); await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png"); await detailDrawer.getByRole("button", { name: "Edit" }).click(); await expect(detailDrawer.getByLabel("Total Jobs")).toHaveValue("72"); await expect(detailDrawer.getByLabel("Total Jobs")).toHaveAttribute("readonly"); await detailDrawer.getByLabel("Notes").fill("Updated in browser E2E"); await detailDrawer.getByRole("switch", { name: "Active" }).click(); await detailDrawer.getByRole("button", { name: "Save" }).click(); await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E"); expect(state.updatedBody?.isActive).toBe(false); }); test("supports keyboard dismissal and free-text company creation", async ({ page }) => { const state = await mockVendorApi(page); await page.goto("/vendors"); const addButton = page.getByRole("button", { name: "+ Add Vendor" }); await addButton.focus(); await page.keyboard.press("Enter"); await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0); await expect(addButton).toBeFocused(); await addButton.click(); await expect(page.getByRole("button", { name: "Add Vendor" }).last()).toBeDisabled(); await page.getByLabel("Technician Name").fill("Independent Tech"); await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC"); await page.getByRole("button", { name: "Add Vendor" }).last().click(); await expect.poll(() => state.createdBody?.companyName).toBe("Independent Vendor LLC"); expect(state.createdBody?.companyId).toBeNull(); }); test("blocks deactivation for linked work orders and preserves the vendor on a raced 409", async ({ page, }) => { const blockedState = await mockVendorApi(page, { deactivationBlocked: true }); await page.goto("/vendors"); await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); const blockedDialog = page.getByRole("dialog", { name: "Deactivate Vendor" }); await expect(blockedDialog).toContainText("WO-501 — Emergency boiler repair"); await expect(blockedDialog.getByRole("button", { name: /^Deactivate$/ })).toBeDisabled(); expect(blockedState.deletedId).toBeUndefined(); await blockedDialog.getByRole("button", { name: "Cancel" }).click(); await page.unrouteAll({ behavior: "wait" }); const racedState = await mockVendorApi(page, { deleteConflict: true }); await page.reload(); await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); await page .getByRole("dialog", { name: "Deactivate Vendor" }) .getByRole("button", { name: /^Deactivate$/, }) .click(); await expect(page.getByRole("dialog", { name: "Deactivate Vendor" })).toContainText( /open work orders|conflict/i, ); expect(racedState.deletedId).toBeUndefined(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); }); test("shows API failure and keeps drawers usable at a mobile viewport", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockVendorApi(page, { listError: true }); await page.goto("/vendors"); await expect(page.getByRole("main").getByRole("alert")).toContainText( /Vendor directory unavailable|failed|server error/i, { timeout: 15_000 }, ); await page.getByRole("button", { name: "Open vendor filters" }).click(); const filterDrawer = page.locator(".MuiDrawer-paper").filter({ hasText: "Total Jobs" }); await expect(filterDrawer).toBeVisible(); const box = await filterDrawer.boundingBox(); expect(box?.width).toBeLessThanOrEqual(391); await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile.png"); await page.getByRole("button", { name: "Close filters" }).click(); }); });