2026-07-23 15:23:03 -03:00
|
|
|
|
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,
|
2026-07-23 17:36:21 -03:00
|
|
|
|
CompanyId: 101,
|
2026-07-23 15:23:03 -03:00
|
|
|
|
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,
|
2026-07-23 17:36:21 -03:00
|
|
|
|
PreferredContact: "Email",
|
2026-07-23 15:23:03 -03:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
Id: 2,
|
2026-07-23 17:36:21 -03:00
|
|
|
|
CompanyId: 102,
|
2026-07-23 15:23:03 -03:00
|
|
|
|
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<string, unknown>;
|
|
|
|
|
|
updatedBody?: Record<string, unknown>;
|
|
|
|
|
|
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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 17:36:21 -03:00
|
|
|
|
async function mockVendorApi(
|
|
|
|
|
|
page: Page,
|
2026-07-28 17:15:42 -03:00
|
|
|
|
options: {
|
|
|
|
|
|
listError?: boolean;
|
|
|
|
|
|
deactivationBlocked?: boolean;
|
|
|
|
|
|
deleteConflict?: boolean;
|
|
|
|
|
|
legacyGateway?: boolean;
|
|
|
|
|
|
} = {},
|
2026-07-23 17:36:21 -03:00
|
|
|
|
) {
|
2026-07-23 15:23:03 -03:00
|
|
|
|
const state: MockState = { listUrls: [] };
|
2026-07-28 17:15:42 -03:00
|
|
|
|
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";
|
|
|
|
|
|
}
|
2026-07-23 15:23:03 -03:00
|
|
|
|
|
|
|
|
|
|
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) => ({
|
2026-07-23 17:36:21 -03:00
|
|
|
|
companyId: vendor.CompanyId,
|
2026-07-23 15:23:03 -03:00
|
|
|
|
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";
|
2026-07-28 17:15:42 -03:00
|
|
|
|
const source = vendorRecords.filter((vendor) => vendor.IsActive === isActive);
|
2026-07-23 15:23:03 -03:00
|
|
|
|
const search = (url.searchParams.get("search") ?? "").toLowerCase();
|
|
|
|
|
|
const company = url.searchParams.get("companies[0]");
|
|
|
|
|
|
const filtered = source.filter(
|
|
|
|
|
|
(vendor) =>
|
|
|
|
|
|
(!search ||
|
2026-08-04 09:53:48 -03:00
|
|
|
|
`${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties} ${vendor.City} ${vendor.State}`
|
2026-07-23 15:23:03 -03:00
|
|
|
|
.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),
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-08-04 09:53:48 -03:00
|
|
|
|
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<Record<string, unknown>>)
|
|
|
|
|
|
: [];
|
|
|
|
|
|
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,
|
|
|
|
|
|
})),
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-07-23 17:36:21 -03:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
: [],
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-08-04 09:53:48 -03:00
|
|
|
|
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",
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
2026-07-23 15:23:03 -03:00
|
|
|
|
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();
|
2026-07-28 17:15:42 -03:00
|
|
|
|
const vendor = vendorRecords.find((item) => String(item.Id) === id);
|
2026-07-28 17:22:42 -03:00
|
|
|
|
if (options.legacyGateway && vendor && typeof state.updatedBody?.isActive === "boolean") {
|
2026-07-28 17:15:42 -03:00
|
|
|
|
vendor.IsActive = state.updatedBody.isActive;
|
|
|
|
|
|
}
|
2026-07-23 15:23:03 -03:00
|
|
|
|
await fulfillJson(route, {
|
2026-07-28 17:15:42 -03:00
|
|
|
|
data: { ...vendor, ...state.updatedBody, Id: Number(id) },
|
2026-07-23 15:23:03 -03:00
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (route.request().method() === "DELETE") {
|
2026-07-23 17:36:21 -03:00
|
|
|
|
if (options.deleteConflict) {
|
|
|
|
|
|
await fulfillJson(
|
|
|
|
|
|
route,
|
|
|
|
|
|
{ message: "Vendor has open work orders and cannot be deactivated." },
|
|
|
|
|
|
409,
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-23 15:23:03 -03:00
|
|
|
|
state.deletedId = id;
|
2026-07-28 17:15:42 -03:00
|
|
|
|
const vendor = vendorRecords.find((item) => String(item.Id) === id);
|
2026-07-28 17:22:42 -03:00
|
|
|
|
if (options.legacyGateway && vendor) vendor.IsActive = false;
|
2026-07-23 15:23:03 -03:00
|
|
|
|
await fulfillJson(route, { status: "200" });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-28 17:15:42 -03:00
|
|
|
|
const vendor = vendorRecords.find((item) => String(item.Id) === id);
|
2026-07-23 15:23:03 -03:00
|
|
|
|
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,
|
|
|
|
|
|
}) => {
|
2026-08-06 23:30:23 -03:00
|
|
|
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
2026-07-23 15:23:03 -03:00
|
|
|
|
const state = await mockVendorApi(page);
|
|
|
|
|
|
await page.goto("/vendors");
|
|
|
|
|
|
|
2026-08-06 23:30:23 -03:00
|
|
|
|
const heading = page.getByRole("heading", { name: "Vendors / Technicians" });
|
|
|
|
|
|
await expect(heading).toBeVisible();
|
|
|
|
|
|
await expect(heading).toHaveCSS("font-family", /Montserrat/);
|
|
|
|
|
|
await expect(heading).toHaveCSS("font-size", "30px");
|
|
|
|
|
|
await expect(heading).toHaveCSS("font-weight", "800");
|
|
|
|
|
|
await expect(heading).toHaveCSS("line-height", "33px");
|
|
|
|
|
|
await expect(heading).toHaveCSS("letter-spacing", "-0.6px");
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await expect(page.getByText("3 technicians · 2 active")).toBeVisible();
|
2026-07-23 15:23:03 -03:00
|
|
|
|
await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
|
|
|
|
|
|
"aria-selected",
|
|
|
|
|
|
"true",
|
|
|
|
|
|
);
|
|
|
|
|
|
for (const heading of [
|
|
|
|
|
|
"Company",
|
|
|
|
|
|
"Technician",
|
|
|
|
|
|
"Location",
|
|
|
|
|
|
"Trade",
|
|
|
|
|
|
"Phone",
|
|
|
|
|
|
"Email",
|
|
|
|
|
|
"Total Jobs",
|
|
|
|
|
|
]) {
|
|
|
|
|
|
await expect(page.getByRole("columnheader", { name: heading })).toBeVisible();
|
|
|
|
|
|
}
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
|
2026-08-06 23:30:23 -03:00
|
|
|
|
const activeTabBox = await page.getByRole("tab", { name: "Active (2)" }).boundingBox();
|
|
|
|
|
|
expect(activeTabBox).toMatchObject({ x: 256, y: 159.5, height: 36 });
|
|
|
|
|
|
const tableContainerBox = await page.locator("table").locator("..").boundingBox();
|
|
|
|
|
|
expect(tableContainerBox).toMatchObject({ x: 260, y: 212.5, width: 1164 });
|
|
|
|
|
|
expect(tableContainerBox?.height).toBeCloseTo(738, 0);
|
|
|
|
|
|
expect((await page.locator("table").boundingBox())?.width).toBe(1550);
|
|
|
|
|
|
expect((await page.getByRole("columnheader", { name: "Company" }).boundingBox())?.height).toBe(
|
|
|
|
|
|
47,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52);
|
2026-07-23 15:23:03 -03:00
|
|
|
|
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
|
|
|
|
|
|
await expect(page.getByText("+1")).toBeVisible();
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await expect(page.getByText("2 active technicians")).toBeVisible();
|
|
|
|
|
|
await expect(page.getByText("2 active · 1 inactive")).toBeVisible();
|
2026-07-23 15:23:03 -03:00
|
|
|
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-list.png");
|
|
|
|
|
|
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await page.getByPlaceholder("Search name, company, trade…").fill("metro");
|
2026-07-23 15:23:03 -03:00
|
|
|
|
await expect(page.getByText("Metro HVAC")).toBeVisible();
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
|
2026-07-23 15:23:03 -03:00
|
|
|
|
expect(state.listUrls.some((url) => new URL(url).searchParams.get("search") === "metro")).toBe(
|
|
|
|
|
|
true,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await page.getByPlaceholder("Search name, company, trade…").fill("clayton");
|
2026-08-04 09:53:48 -03:00
|
|
|
|
await expect(page.getByText("Metro HVAC")).toBeVisible();
|
|
|
|
|
|
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await page.getByPlaceholder("Search name, company, trade…").clear();
|
2026-07-23 15:23:03 -03:00
|
|
|
|
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();
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await page.getByRole("button", { name: "Apply filters" }).click();
|
2026-07-23 15:23:03 -03:00
|
|
|
|
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await expect(page.getByText("1 technician · filtered by")).toBeVisible();
|
|
|
|
|
|
await expect(page.getByText(/1 active technician · filtered/)).toBeVisible();
|
2026-07-23 15:23:03 -03:00
|
|
|
|
expect(
|
|
|
|
|
|
state.listUrls.some(
|
|
|
|
|
|
(url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing",
|
|
|
|
|
|
),
|
|
|
|
|
|
).toBe(true);
|
|
|
|
|
|
|
|
|
|
|
|
await page.getByRole("tab", { name: "Inactive (1)" }).click();
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await expect(page.getByText("No inactive technicians found")).toBeVisible();
|
2026-07-23 15:23:03 -03:00
|
|
|
|
await page.getByRole("button", { name: "Clear all" }).click();
|
|
|
|
|
|
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
|
2026-07-23 15:23:03 -03:00
|
|
|
|
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-04 09:53:48 -03:00
|
|
|
|
test("adds a technician to an existing company, then views and edits the aggregate", async ({
|
2026-07-23 15:23:03 -03:00
|
|
|
|
page,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
const state = await mockVendorApi(page);
|
|
|
|
|
|
await page.goto("/vendors");
|
|
|
|
|
|
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await page.getByRole("button", { name: "Add Vendor", exact: true }).click();
|
|
|
|
|
|
await expect(page.getByText("Register a vendor company and its technicians.")).toBeVisible();
|
2026-07-23 15:23:03 -03:00
|
|
|
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await expect(
|
|
|
|
|
|
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
|
|
|
|
|
|
).toBeEnabled();
|
2026-07-23 15:23:03 -03:00
|
|
|
|
|
|
|
|
|
|
await page.getByRole("combobox", { name: "Company" }).click();
|
|
|
|
|
|
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100");
|
|
|
|
|
|
await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
|
2026-08-04 09:53:48 -03:00
|
|
|
|
"dispatch@gateway.test",
|
|
|
|
|
|
);
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await expect(page.getByLabel("Address (optional)")).toHaveValue("1 Market St");
|
|
|
|
|
|
await expect(page.getByLabel("Google Maps URL (optional)")).toHaveValue(
|
|
|
|
|
|
"https://maps.google.com/gateway",
|
|
|
|
|
|
);
|
2026-08-04 09:53:48 -03:00
|
|
|
|
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
|
|
|
|
|
|
await page.getByRole("button", { name: "Add technician" }).click();
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await page.getByLabel("Technician name (optional)").last().fill("New Technician");
|
2026-08-04 09:53:48 -03:00
|
|
|
|
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();
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await page.getByLabel("Notes (optional)").fill("Created in browser E2E");
|
|
|
|
|
|
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
|
|
|
|
|
|
await expect(page.getByRole("dialog", { name: /Add Vendor/ })).toHaveCount(0);
|
2026-08-04 09:53:48 -03:00
|
|
|
|
expect(state.updatedBody).toMatchObject({
|
|
|
|
|
|
name: "Gateway Plumbing",
|
2026-07-23 17:36:21 -03:00
|
|
|
|
companyPhone: "(314) 555-0100",
|
2026-07-23 15:23:03 -03:00
|
|
|
|
notes: "Created in browser E2E",
|
2026-08-04 09:53:48 -03:00
|
|
|
|
rowVersion: "rv-1",
|
2026-07-23 15:23:03 -03:00
|
|
|
|
});
|
2026-08-04 09:53:48 -03:00
|
|
|
|
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<Record<string, unknown>>)
|
|
|
|
|
|
: [];
|
|
|
|
|
|
const newTechnician = submittedTechnicians.find(
|
|
|
|
|
|
(technician) => technician.contactName === "New Technician",
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(newTechnician?.preferredContact).toBeUndefined();
|
2026-07-23 15:23:03 -03:00
|
|
|
|
|
|
|
|
|
|
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
|
|
|
|
|
|
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible();
|
2026-07-23 15:23:03 -03:00
|
|
|
|
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();
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await expect(page).toHaveURL(/\/vendors$/);
|
|
|
|
|
|
await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E");
|
|
|
|
|
|
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
|
2026-07-23 15:23:03 -03:00
|
|
|
|
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-28 17:15:42 -03:00
|
|
|
|
test("reactivates a legacy inactive vendor with an exact sparse update", async ({ page }) => {
|
|
|
|
|
|
const state = await mockVendorApi(page, { legacyGateway: true });
|
|
|
|
|
|
await page.goto("/vendors");
|
|
|
|
|
|
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
|
|
|
|
|
await page.getByRole("switch", { name: "Active status" }).click();
|
2026-07-28 17:15:42 -03:00
|
|
|
|
await page.getByRole("button", { name: "Deactivate", exact: true }).click();
|
|
|
|
|
|
await expect.poll(() => state.deletedId).toBe("1");
|
|
|
|
|
|
|
|
|
|
|
|
await page.getByRole("tab", { name: "Inactive (2)" }).click();
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
2026-07-28 17:15:42 -03:00
|
|
|
|
const detailDrawer = page.locator(".MuiDrawer-paper").last();
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await detailDrawer.getByRole("switch", { name: "Active status" }).click();
|
|
|
|
|
|
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
|
2026-07-28 17:15:42 -03:00
|
|
|
|
await expect.poll(() => state.updatedBody).toEqual({ isActive: true });
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await detailDrawer.getByRole("button", { name: "Close drawer" }).click();
|
2026-08-04 09:53:48 -03:00
|
|
|
|
await page.getByRole("tab", { name: "Active (2)", exact: true }).click();
|
2026-07-28 17:15:42 -03:00
|
|
|
|
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-23 15:23:03 -03:00
|
|
|
|
test("supports keyboard dismissal and free-text company creation", async ({ page }) => {
|
|
|
|
|
|
const state = await mockVendorApi(page);
|
|
|
|
|
|
await page.goto("/vendors");
|
|
|
|
|
|
|
2026-08-06 22:23:30 -03:00
|
|
|
|
const addButton = page.getByRole("button", { name: "Add Vendor", exact: true });
|
2026-07-23 15:23:03 -03:00
|
|
|
|
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();
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await expect(
|
|
|
|
|
|
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
|
|
|
|
|
|
).toBeEnabled();
|
2026-07-23 15:23:03 -03:00
|
|
|
|
await page.getByRole("combobox", { name: "Company" }).fill("Independent Vendor LLC");
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await page.getByLabel("Company Phone (optional)").fill("3145550199");
|
|
|
|
|
|
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
|
2026-08-04 09:53:48 -03:00
|
|
|
|
|
|
|
|
|
|
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");
|
2026-07-23 15:23:03 -03:00
|
|
|
|
|
2026-08-04 09:53:48 -03:00
|
|
|
|
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);
|
2026-07-23 17:36:21 -03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
|
|
|
|
|
await page.getByRole("switch", { name: "Active status" }).click();
|
2026-07-23 17:36:21 -03:00
|
|
|
|
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();
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
|
|
|
|
|
|
await page.getByRole("switch", { name: "Active status" }).click();
|
2026-07-23 17:36:21 -03:00
|
|
|
|
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();
|
2026-07-23 15:23:03 -03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
|
2026-08-10 11:51:00 -03:00
|
|
|
|
const mainBox = await page.getByRole("main").boundingBox();
|
|
|
|
|
|
expect(mainBox).toMatchObject({ x: 0, width: 390 });
|
|
|
|
|
|
const navigation = page.getByRole("navigation", { name: "Primary navigation" });
|
|
|
|
|
|
await expect(navigation).not.toBeVisible();
|
|
|
|
|
|
|
|
|
|
|
|
const openNavigation = page.getByRole("button", { name: "Toggle navigation" });
|
|
|
|
|
|
await openNavigation.focus();
|
|
|
|
|
|
await page.keyboard.press("Enter");
|
|
|
|
|
|
await expect(navigation).toBeVisible();
|
|
|
|
|
|
await expect(page.getByRole("link", { name: "Sites" })).toBeVisible();
|
|
|
|
|
|
await page.keyboard.press("Escape");
|
|
|
|
|
|
await expect(navigation).not.toBeVisible();
|
|
|
|
|
|
await expect(openNavigation).toBeFocused();
|
|
|
|
|
|
|
2026-07-23 15:23:03 -03:00
|
|
|
|
await expect(page.getByRole("main").getByRole("alert")).toContainText(
|
|
|
|
|
|
/Vendor directory unavailable|failed|server error/i,
|
|
|
|
|
|
{ timeout: 15_000 },
|
|
|
|
|
|
);
|
2026-08-06 22:23:30 -03:00
|
|
|
|
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile-list.png");
|
2026-07-23 15:23:03 -03:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
2026-08-10 11:51:00 -03:00
|
|
|
|
|
|
|
|
|
|
test("keeps protected shell destinations available and marks their selected state", async ({
|
|
|
|
|
|
page,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
await mockVendorApi(page);
|
|
|
|
|
|
|
|
|
|
|
|
for (const destination of [
|
|
|
|
|
|
{ path: "/workorders", label: "Work Orders" },
|
|
|
|
|
|
{ path: "/accounts", label: "Accounts" },
|
|
|
|
|
|
{ path: "/locations", label: "Sites" },
|
|
|
|
|
|
{ path: "/notifications", label: "Notifications" },
|
|
|
|
|
|
]) {
|
|
|
|
|
|
await page.goto(destination.path);
|
|
|
|
|
|
await expect(page.getByRole("link", { name: destination.label })).toHaveAttribute(
|
|
|
|
|
|
"aria-current",
|
|
|
|
|
|
"page",
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-07-23 15:23:03 -03:00
|
|
|
|
});
|