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; legacyGateway?: boolean; } = {}, ) { const state: MockState = { listUrls: [] }; const vendorRecords = [...activeVendors, ...inactiveVendors].map((vendor) => ({ ...vendor })); if (options.legacyGateway) { vendorRecords[0].Phone = "555-1234 ext 9"; vendorRecords[0].GoogleMapsUrl = "http://maps.example.test/legacy"; } 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 = vendorRecords.filter((vendor) => vendor.IsActive === isActive); 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} ${vendor.City} ${vendor.State}` .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\/vendor-company-roster(?:\/\d+)?(?:\?.*)?$/, async (route) => { const request = route.request(); const url = new URL(request.url()); const pathCompanyId = url.pathname.match(/vendor-company-roster\/(\d+)$/)?.[1]; if (request.method() === "POST") { state.createdBody = request.postDataJSON(); await fulfillJson(route, { ...state.createdBody, companyId: 999, rowVersion: "rv-created", }); return; } if (request.method() === "PUT" && pathCompanyId) { state.updatedBody = request.postDataJSON(); const technicians = Array.isArray(state.updatedBody.technicians) ? (state.updatedBody.technicians as Array>) : []; for (const technician of technicians) { const vendor = vendorRecords.find((item) => String(item.Id) === String(technician.id)); if (vendor && typeof technician.isActive === "boolean") { vendor.IsActive = technician.isActive; } } await fulfillJson(route, { ...state.updatedBody, companyId: Number(pathCompanyId), rowVersion: "rv-updated", }); return; } const vendorId = url.searchParams.get("vendorId"); const requestedCompanyId = url.searchParams.get("companyId") ?? pathCompanyId; const anchor = vendorId ? vendorRecords.find((vendor) => String(vendor.Id) === vendorId) : vendorRecords.find((vendor) => String(vendor.CompanyId) === requestedCompanyId); if (!anchor) { await fulfillJson(route, { message: "Vendor roster not found" }, 404); return; } await fulfillJson(route, { companyId: anchor.CompanyId, rowVersion: "rv-1", name: anchor.CompanyName, companyPhone: anchor.CompanyPhone, email: anchor.Email, address: anchor.Address, city: anchor.City, state: anchor.State, zip: anchor.Zip, googleMapsUrl: anchor.GoogleMapsUrl, notes: anchor.Notes, technicians: vendorRecords .filter((vendor) => vendor.CompanyId === anchor.CompanyId) .map((vendor) => ({ id: vendor.Id, contactName: vendor.ContactName, phone: vendor.Phone, email: vendor.Email, preferredContact: vendor.PreferredContact ?? "Phone", tradeSpecialties: vendor.TradeSpecialties, isActive: vendor.IsActive, totalJobs: vendor.TotalJobs, })), }); }); 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+\/portal-token$/, (route) => fulfillJson(route, { data: { portalUrl: "https://vendor.example.test/token", issuedAt: "2026-07-31T12:00:00Z", expiresAt: "2026-08-07T12:00:00Z", }, }), ); 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(); const vendor = vendorRecords.find((item) => String(item.Id) === id); if (options.legacyGateway && vendor && typeof state.updatedBody?.isActive === "boolean") { vendor.IsActive = state.updatedBody.isActive; } await fulfillJson(route, { data: { ...vendor, ...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; const vendor = vendorRecords.find((item) => String(item.Id) === id); if (options.legacyGateway && vendor) vendor.IsActive = false; await fulfillJson(route, { status: "200" }); return; } const vendor = vendorRecords.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 vendor entries · 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 vendor entries · 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 vendor entry · filtered/)).toBeVisible(); expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe( true, ); await page.getByPlaceholder("Search name, company, trade...").fill("clayton"); await expect(page.getByText("Metro HVAC")).toBeVisible(); 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 vendor entry · 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("adds a technician to an existing company, then views and edits the aggregate", 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 company and add zero, one, or multiple technicians."), ).toBeVisible(); await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png"); await expect(page.getByRole("button", { name: "Add Vendor Company" }).last()).toBeDisabled(); 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", exact: true })).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 expect(page.getByLabel("Preferred Contact")).toHaveCount(0); await page.getByRole("button", { name: "Add technician" }).click(); await page.getByLabel("Technician Name").last().fill("New Technician"); const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last(); await tradeInput.fill("HVAC"); await tradeInput.press("Enter"); await tradeInput.fill("Plumbing"); await tradeInput.press("Enter"); await page.getByRole("button", { name: "Move Plumbing up" }).click(); await page.getByLabel("Notes").fill("Created in browser E2E"); await page.getByRole("button", { name: "Add Vendor Company" }).last().click(); await expect(page.getByRole("dialog", { name: "Add Vendor Company" })).toHaveCount(0); expect(state.updatedBody).toMatchObject({ name: "Gateway Plumbing", companyPhone: "(314) 555-0100", notes: "Created in browser E2E", rowVersion: "rv-1", }); expect(state.updatedBody?.technicians).toEqual( expect.arrayContaining([ expect.objectContaining({ contactName: "New Technician", tradeSpecialties: "Plumbing, HVAC", }), ]), ); const submittedTechnicians = Array.isArray(state.updatedBody?.technicians) ? (state.updatedBody.technicians as Array>) : []; const newTechnician = submittedTechnicians.find( (technician) => technician.contactName === "New Technician", ); expect(newTechnician?.preferredContact).toBeUndefined(); 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(page).toHaveURL(/\/vendors\/edit\/1$/); await page.getByLabel("Notes").fill("Updated in browser E2E"); await page.getByRole("button", { name: "Save" }).click(); await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E"); }); test("reactivates a legacy inactive vendor with an exact sparse update", async ({ page }) => { const state = await mockVendorApi(page, { legacyGateway: true }); await page.goto("/vendors"); await page.getByRole("button", { name: "Deactivate vendor" }).first().click(); await page.getByRole("button", { name: "Deactivate", exact: true }).click(); await expect.poll(() => state.deletedId).toBe("1"); await page.getByRole("tab", { name: "Inactive (2)" }).click(); await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); await detailDrawer.getByRole("button", { name: "Edit" }).click(); await expect(page).toHaveURL(/\/vendors\/edit\/1$/); await page.getByRole("switch", { name: "Inactive" }).first().click(); await page.getByRole("button", { name: "Save" }).click(); await expect.poll(() => state.updatedBody).toEqual({ isActive: true }); await page.getByRole("tab", { name: "Active (2)", exact: true }).click(); await expect(page.getByText("Gateway Plumbing").first()).toBeVisible(); }); 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 Company" }).last()).toBeDisabled(); await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC"); await page.getByLabel("Company Phone").fill("3145550199"); await page.getByRole("button", { name: "Add Vendor Company" }).last().click(); await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC"); expect(state.createdBody?.technicians).toEqual([]); }); test("closes vendor details with Escape and keeps only Edit in the footer", async ({ page }) => { await mockVendorApi(page); await page.goto("/vendors"); await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click(); const detailDrawer = page.locator(".MuiDrawer-paper").last(); await expect(detailDrawer).toBeVisible(); await expect(detailDrawer.getByRole("button", { name: "Edit" })).toBeVisible(); await expect(detailDrawer.getByRole("button", { name: "Maps" })).toHaveCount(0); await page.keyboard.press("Escape"); await expect(page.getByRole("button", { name: "Close drawer" })).toHaveCount(0); }); 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(); }); });