shoc-frontend-new/e2e/vendors/vendors.spec.ts

985 lines
40 KiB
TypeScript
Raw Normal View History

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",
AreaId: 1 as number | null,
AreaName: "East",
},
{
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,
AreaId: null as number | null,
AreaName: "",
},
];
const SERVICE_AREAS = [
{ id: 2, name: "Central" },
{ id: 1, name: "East" },
];
const inactiveVendors = [
{
...activeVendors[0],
Id: 3,
CompanyName: "Drain Cleaning Bros",
ContactName: "Drew Banks",
TotalJobs: 151,
IsActive: false,
},
];
const LONG_TEXT_VENDOR = {
CompanyName: "Gateway Plumbing and Water Systems of Greater Saint Louis",
ContactName: "Adam Christopher Whyte Senior Field Technician",
Email: "dispatch-and-after-hours-coordination@gatewayplumbing.example",
Phone: "+1 (314) 555-0198 extension 123456",
Address: "New York, NY, Estados Unidos, 123 Long Address Avenue",
City: "",
State: "",
};
interface MockState {
listUrls: string[];
createdBody?: Record<string, unknown>;
updatedBody?: Record<string, unknown>;
patchedBody?: Record<string, unknown>;
patchedCompanyId?: string;
deletedId?: string;
deleteConfirmedOpenWorkOrders?: boolean;
}
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;
groupedDirectory?: boolean;
legacyGateway?: boolean;
longText?: boolean;
} = {},
) {
const state: MockState = { listUrls: [] };
const vendorRecords = [...activeVendors, ...inactiveVendors].map((vendor) => ({ ...vendor }));
if (options.longText) Object.assign(vendorRecords[0], LONG_TEXT_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"],
areas: SERVICE_AREAS,
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/GetVendorDirectoryList*", 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 areas = [...url.searchParams.entries()]
.filter(([key]) => key.startsWith("areas["))
.map(([, value]) => value);
const filtered = source.filter(
(vendor) =>
(!search ||
`${vendor.CompanyName} ${vendor.ContactName} ${vendor.TradeSpecialties} ${vendor.City} ${vendor.State}`
.toLowerCase()
.includes(search)) &&
(!company || vendor.CompanyName === company) &&
(areas.length === 0 ||
areas.some((area) =>
area === "__unassigned__" ? vendor.AreaId == null : String(vendor.AreaId) === area,
)),
);
const isCount = url.searchParams.get("pageSize") === "1";
const responseRows = filtered.map((vendor) => {
if (!options.groupedDirectory) return vendor;
const technicians = [
{
Id: vendor.Id,
ContactName: vendor.ContactName,
Email: vendor.Email,
Phone: vendor.Phone,
TradeSpecialties: vendor.TradeSpecialties,
IsActive: vendor.IsActive,
TotalJobs: vendor.TotalJobs,
},
];
if (vendor.Id === 1) {
technicians.push({
Id: 91,
ContactName: "Robin Rivers",
Email: "robin@gateway.test",
Phone: "314-555-0191",
TradeSpecialties: "Drain Cleaning",
IsActive: true,
TotalJobs: 18,
});
}
return {
...vendor,
TotalJobs: technicians.reduce((sum, item) => sum + item.TotalJobs, 0),
Technicians: technicians,
};
});
await fulfillJson(route, {
Data: isCount ? responseRows.slice(0, 1) : responseRows,
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() === "PATCH" && pathCompanyId) {
state.patchedBody = request.postDataJSON();
state.patchedCompanyId = pathCompanyId;
const anchor = vendorRecords.find((vendor) => String(vendor.CompanyId) === pathCompanyId);
if (!anchor) {
await fulfillJson(route, { message: "Vendor roster not found" }, 404);
return;
}
const added = Array.isArray(state.patchedBody.addTechnicians)
? (state.patchedBody.addTechnicians as Array<Record<string, unknown>>)
: [];
await fulfillJson(route, {
companyId: anchor.CompanyId,
rowVersion: "rv-patched",
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,
areaId: anchor.AreaId,
areaName: anchor.AreaName,
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,
})),
...added.map((technician, index) => ({
id: 900 + index,
totalJobs: 0,
...technician,
})),
],
});
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,
areaId: anchor.AreaId,
areaName: anchor.AreaName,
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 requestUrl = new URL(route.request().url());
const id = requestUrl.pathname.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") {
state.deleteConfirmedOpenWorkOrders =
requestUrl.searchParams.get("confirmOpenWorkOrders") === "true";
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("renders company-owned columns and the grouped technician roster", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await mockVendorApi(page, { groupedDirectory: true });
await page.goto("/vendors");
const expectedHeaders = [
"Company",
"Company Phone",
"Company Email",
"Street Address",
"City",
"State",
"Service Area",
"Trade",
"Technician",
"Technician Phone",
"Total Jobs",
"Status",
];
await expect(page.getByRole("columnheader")).toHaveCount(expectedHeaders.length + 1);
for (const header of expectedHeaders) {
await expect(page.getByRole("columnheader", { name: header, exact: true })).toBeVisible();
}
const gatewayRow = page.getByRole("button", {
name: "Open vendor details for Gateway Plumbing",
});
await expect(gatewayRow).toContainText("Adam Whyte");
await expect(gatewayRow).toContainText("90");
const rosterBadge = gatewayRow.getByTestId("vendor-technician-badge");
await expect(rosterBadge).toHaveText("+1");
await rosterBadge.hover();
await expect(page.getByRole("tooltip")).toHaveText("Robin Rivers");
await rosterBadge.focus();
await expect(rosterBadge).toBeFocused();
await expect(page.getByRole("tooltip")).toHaveText("Robin Rivers");
});
test("covers status tabs, global counts, search, facets, table fields, and filtered footer", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 1000 });
const state = await mockVendorApi(page);
await page.goto("/vendors");
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");
await expect(page.getByText("2 active companies").first()).toBeVisible();
await expect(page.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
"aria-selected",
"true",
);
for (const heading of [
"Company",
"Company Phone",
"Company Email",
"Street Address",
"City",
"State",
"Service Area",
"Trade",
"Technician",
"Technician Phone",
"Total Jobs",
]) {
await expect(page.getByRole("columnheader", { name: heading, exact: true })).toBeVisible();
}
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
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(2200);
expect(
(await page.getByRole("columnheader", { name: "Company", exact: true }).boundingBox())
?.height,
).toBe(47);
expect((await page.locator("tbody tr").first().boundingBox())?.height).toBe(52);
await expect(page.getByText("Gateway Plumbing")).toBeVisible();
const tradeBadge = page.getByTestId("vendor-trade-badge").first();
await expect(tradeBadge).toHaveText("+1");
await tradeBadge.hover();
await expect(page.getByRole("tooltip")).toHaveText("Backflow Preventers");
await tradeBadge.focus();
await expect(tradeBadge).toBeFocused();
await tradeBadge.click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await tradeBadge.press("Enter");
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.getByText("2 active companies").last()).toBeVisible();
await expect(page.getByText("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 company · 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 filters" }).click();
await expect(page.getByText("Gateway Plumbing").first()).toBeVisible();
await expect(page.getByText("1 company · filtered by")).toBeVisible();
await expect(page.getByText(/1 active company · filtered/)).toBeVisible();
expect(
state.listUrls.some(
(url) => new URL(url).searchParams.get("companies[0]") === "Gateway Plumbing",
),
).toBe(true);
await page.getByRole("tab", { name: "Inactive (1)" }).click();
await expect(page.getByText("No inactive companies found")).toBeVisible();
await page.getByRole("button", { name: "Clear all" }).click();
await expect(page.getByText("Drain Cleaning Bros")).toBeVisible();
await expect(page.getByRole("columnheader", { name: "Status" })).toBeVisible();
await expect(page.getByRole("button", { name: "Deactivate vendor" })).toHaveCount(0);
});
test("truncates every non-abbreviated textual column", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await mockVendorApi(page, { longText: true });
await page.goto("/vendors");
const row = page.locator("tbody tr").first();
const fields = [
{ cellIndex: 0, testId: "vendor-text-company", value: LONG_TEXT_VENDOR.CompanyName },
{ cellIndex: 2, testId: "vendor-text-company-email", value: LONG_TEXT_VENDOR.Email },
{ cellIndex: 3, testId: "vendor-text-street-address", value: LONG_TEXT_VENDOR.Address },
{ cellIndex: 8, testId: "vendor-text-technician", value: LONG_TEXT_VENDOR.ContactName },
{ cellIndex: 9, testId: "vendor-text-technician-phone", value: LONG_TEXT_VENDOR.Phone },
];
for (const expected of fields) {
const cell = row.locator("td").nth(expected.cellIndex);
const field = cell.getByTestId(expected.testId);
await expect(field).toBeVisible();
const metrics = await field.evaluate((element) => {
const fieldRect = element.getBoundingClientRect();
const cellRect = element.closest("td")?.getBoundingClientRect();
return {
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
withinCell:
cellRect !== undefined &&
fieldRect.left >= cellRect.left - 1 &&
fieldRect.right <= cellRect.right + 1,
};
});
expect(metrics.scrollWidth).toBeGreaterThan(metrics.clientWidth);
expect(metrics.withinCell).toBe(true);
await field.hover();
await expect(page.getByRole("tooltip", { name: expected.value, exact: true })).toBeVisible();
await page.mouse.move(0, 0);
await expect(page.getByRole("tooltip", { name: expected.value, exact: true })).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", exact: true }).click();
await expect(page.getByText("Register a vendor company and its technicians.")).toBeVisible();
await captureVisual(page, "/tmp/seahaven-vendor-implemented-add.png");
await expect(
page.getByRole("button", { name: "Add Vendor", exact: true }).last(),
).toBeEnabled();
const companyLabel = page
.getByRole("dialog", { name: /Add Vendor/ })
.locator("label")
.filter({ hasText: "Company (required)" });
await expect(companyLabel).toBeVisible();
const companyLabelMetrics = await companyLabel.evaluate((element) => {
const field = element.closest(".MuiFormControl-root");
const labelRect = element.getBoundingClientRect();
const fieldRect = field?.getBoundingClientRect();
return {
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
withinFieldGeometry: fieldRect
? labelRect.left >= fieldRect.left &&
labelRect.right <= fieldRect.right &&
labelRect.top >= fieldRect.top &&
labelRect.bottom <= fieldRect.bottom
: false,
};
});
expect(companyLabelMetrics.clientWidth).toBeGreaterThanOrEqual(companyLabelMetrics.scrollWidth);
expect(companyLabelMetrics.withinFieldGeometry).toBe(true);
await page.getByRole("combobox", { name: "Company (required)" }).click();
await page.getByRole("option", { name: "Gateway Plumbing" }).click();
const floatedCompanyLabel = page
.getByRole("dialog", { name: /Add Vendor/ })
.locator("label")
.filter({ hasText: "Company (required)" });
const floatedCompanyField = floatedCompanyLabel.locator("..");
await expect(floatedCompanyLabel).toHaveText("Company (required)");
await expect(floatedCompanyField.locator("legend")).toHaveText("Company (required)");
const floatedMetrics = await floatedCompanyLabel.evaluate((element) => {
const labelRect = element.getBoundingClientRect();
const legendRect = element
.closest(".MuiFormControl-root")
?.querySelector("legend")
?.getBoundingClientRect();
return {
labelClientWidth: element.clientWidth,
labelScrollWidth: element.scrollWidth,
legendWidth: legendRect?.width ?? 0,
labelWidth: labelRect.width,
};
});
expect(floatedMetrics.labelClientWidth).toBeGreaterThanOrEqual(floatedMetrics.labelScrollWidth);
expect(floatedMetrics.legendWidth).toBeGreaterThanOrEqual(floatedMetrics.labelWidth);
// Scrolling focus down the form and back to Company must keep the floated label
// inside the dialog's visible scrollport.
const dialogContent = page
.getByRole("dialog", { name: /Add Vendor/ })
.locator(".MuiDialogContent-root");
await page.getByLabel("Technician name (optional)").click();
await expect
.poll(() => dialogContent.evaluate((element) => element.scrollTop))
.toBeGreaterThan(0);
await page.getByRole("combobox", { name: "Company (required)" }).click();
await expect
.poll(() => dialogContent.evaluate((element) => element.scrollTop))
.toBeLessThanOrEqual(1);
await expect(floatedCompanyLabel).toBeVisible();
const labelScrollportMetrics = await floatedCompanyLabel.evaluate((element) => {
const content = element.closest(".MuiDialogContent-root");
return {
labelTop: element.getBoundingClientRect().top,
labelBottom: element.getBoundingClientRect().bottom,
dialogTop: content?.getBoundingClientRect().top ?? Number.NaN,
dialogBottom: content?.getBoundingClientRect().bottom ?? Number.NaN,
overflowY: content ? getComputedStyle(content).overflowY : "",
};
});
expect(["auto", "scroll"]).toContain(labelScrollportMetrics.overflowY);
expect(labelScrollportMetrics.labelTop).toBeGreaterThanOrEqual(
labelScrollportMetrics.dialogTop - 1,
);
expect(labelScrollportMetrics.labelBottom).toBeLessThanOrEqual(
labelScrollportMetrics.dialogBottom + 1,
);
await expect(page.getByLabel("Company Phone (optional)")).toHaveValue("314-555-0100");
await expect(page.getByRole("textbox", { name: "Email (optional)", exact: true })).toHaveValue(
"dispatch@gateway.test",
);
feat(vendors): structure the address into Street/City/State with autocomplete (#176) * feat(vendors): structure the address into Street/City/State with autocomplete The Vendor form carried one free-text "Address (optional)" line, with City, State and Zip already present but hidden behind display:none, and a separate "Google Maps URL" input someone had to paste into by hand. Street Address, City and State are now three required fields. Typing three characters in Street Address offers up to four candidates; picking one fills all three at once, and typing without picking stays plain free text. The Google Maps URL input is gone — the location is derived from the address, the Street Address itself is the link in view mode, and a keyless map preview renders once all three parts are present. Zip stays in the payload but out of the form; the ticket scopes the visible set to three. The suggestion algorithm, candidate cities and copy are ported from the approved prototype rather than invented, so dev and design agree on what a dispatcher sees. Suggestions are deterministic for a given input on purpose: a reshuffling list moves a row out from under the pointer mid-click. Test fixtures that predate the requirement now carry an address, so each test still fails for the reason it is about. The two drawer tests asserting the stored-URL "Open in Google Maps" row are rewritten to the behaviour that replaced it. Delivers SH-271. * feat(work-orders): show the vendor location map in the Vendor dialog The last of SH-271's five acceptance bullets. The Work Order Vendor dialog gets one composed address line from the vendor dropdown payload, not the structured parts the form and detail drawer hold, so the preview takes the line directly — it is saved data either way, and the completeness rule exists to stop a map of half-typed input, not to reject a stored address. The dialog's hand-built maps URL now goes through the shared helper, so the link and the preview cannot drift apart. * test(vendors): update the browser specs and pixel baselines for the new address `npm run verify` does not run Playwright, so the first push went out with the browser suite still asserting the UI this ticket removes. Three assertions were stale: the combined "Address (optional)" input, the "Google Maps URL (optional)" input, and the detail drawer's separate "Open in Google Maps" row — now replaced by the Street Address itself being the link, checked against the derived href. A fourth test created a vendor without an address, which the new requirement blocks; it fills one, so the test still fails only for the reason it is about. Baselines regenerated in mcr.microsoft.com/playwright:v1.61.1-noble, the image CI uses — macOS font rendering produces different pixels. Exactly three of the sixteen were rewritten (vendor add, edit, detail); the rest, including every Work Orders shot, are byte-identical, which is the evidence that this change stays inside the surfaces it claims. * test(vendors): keep the map preview out of the pixel baselines The visual suite mocks `**/api/**` and nothing else, so the address map preview's iframe reached maps.google.com for real. Whether that frame paints, and what it paints, depends on the network and on what Google serves that minute — which is why `vendor-edit` failed in CI at 18178 differing pixels while passing in a container that could not reach Google. Regenerating the baseline would not have fixed it; it would have moved the flake. Aborting the request pins the frame to a blank box, so the shot measures our layout and nothing else. The committed baselines are unchanged by this — they were already correct — and a second container run with no --update passes 16/16, which is the evidence the shot is now stable rather than merely green once. * fix(vendors): complete structured address map flows * test(vendors): align add visual baseline with CI --------- Co-authored-by: Codex Review Integration <codex-review@local.invalid>
2026-09-15 16:13:15 -03:00
// SH-271: one combined "Address (optional)" line became three required
// fields, and the hand-pasted Google Maps URL input is gone — the location
// is derived from the address now.
await expect(page.getByRole("combobox", { name: "Street Address (required)" })).toHaveValue(
"1 Market St",
);
feat(vendors): structure the address into Street/City/State with autocomplete (#176) * feat(vendors): structure the address into Street/City/State with autocomplete The Vendor form carried one free-text "Address (optional)" line, with City, State and Zip already present but hidden behind display:none, and a separate "Google Maps URL" input someone had to paste into by hand. Street Address, City and State are now three required fields. Typing three characters in Street Address offers up to four candidates; picking one fills all three at once, and typing without picking stays plain free text. The Google Maps URL input is gone — the location is derived from the address, the Street Address itself is the link in view mode, and a keyless map preview renders once all three parts are present. Zip stays in the payload but out of the form; the ticket scopes the visible set to three. The suggestion algorithm, candidate cities and copy are ported from the approved prototype rather than invented, so dev and design agree on what a dispatcher sees. Suggestions are deterministic for a given input on purpose: a reshuffling list moves a row out from under the pointer mid-click. Test fixtures that predate the requirement now carry an address, so each test still fails for the reason it is about. The two drawer tests asserting the stored-URL "Open in Google Maps" row are rewritten to the behaviour that replaced it. Delivers SH-271. * feat(work-orders): show the vendor location map in the Vendor dialog The last of SH-271's five acceptance bullets. The Work Order Vendor dialog gets one composed address line from the vendor dropdown payload, not the structured parts the form and detail drawer hold, so the preview takes the line directly — it is saved data either way, and the completeness rule exists to stop a map of half-typed input, not to reject a stored address. The dialog's hand-built maps URL now goes through the shared helper, so the link and the preview cannot drift apart. * test(vendors): update the browser specs and pixel baselines for the new address `npm run verify` does not run Playwright, so the first push went out with the browser suite still asserting the UI this ticket removes. Three assertions were stale: the combined "Address (optional)" input, the "Google Maps URL (optional)" input, and the detail drawer's separate "Open in Google Maps" row — now replaced by the Street Address itself being the link, checked against the derived href. A fourth test created a vendor without an address, which the new requirement blocks; it fills one, so the test still fails only for the reason it is about. Baselines regenerated in mcr.microsoft.com/playwright:v1.61.1-noble, the image CI uses — macOS font rendering produces different pixels. Exactly three of the sixteen were rewritten (vendor add, edit, detail); the rest, including every Work Orders shot, are byte-identical, which is the evidence that this change stays inside the surfaces it claims. * test(vendors): keep the map preview out of the pixel baselines The visual suite mocks `**/api/**` and nothing else, so the address map preview's iframe reached maps.google.com for real. Whether that frame paints, and what it paints, depends on the network and on what Google serves that minute — which is why `vendor-edit` failed in CI at 18178 differing pixels while passing in a container that could not reach Google. Regenerating the baseline would not have fixed it; it would have moved the flake. Aborting the request pins the frame to a blank box, so the shot measures our layout and nothing else. The committed baselines are unchanged by this — they were already correct — and a second container run with no --update passes 16/16, which is the evidence the shot is now stable rather than merely green once. * fix(vendors): complete structured address map flows * test(vendors): align add visual baseline with CI --------- Co-authored-by: Codex Review Integration <codex-review@local.invalid>
2026-09-15 16:13:15 -03:00
await expect(page.getByLabel("City (required)")).toHaveValue("St. Louis");
await expect(page.getByLabel("State (required)")).toHaveValue("Missouri (MO)");
await expect(page.getByLabel("Service Area (optional)")).toHaveValue("East");
feat(vendors): structure the address into Street/City/State with autocomplete (#176) * feat(vendors): structure the address into Street/City/State with autocomplete The Vendor form carried one free-text "Address (optional)" line, with City, State and Zip already present but hidden behind display:none, and a separate "Google Maps URL" input someone had to paste into by hand. Street Address, City and State are now three required fields. Typing three characters in Street Address offers up to four candidates; picking one fills all three at once, and typing without picking stays plain free text. The Google Maps URL input is gone — the location is derived from the address, the Street Address itself is the link in view mode, and a keyless map preview renders once all three parts are present. Zip stays in the payload but out of the form; the ticket scopes the visible set to three. The suggestion algorithm, candidate cities and copy are ported from the approved prototype rather than invented, so dev and design agree on what a dispatcher sees. Suggestions are deterministic for a given input on purpose: a reshuffling list moves a row out from under the pointer mid-click. Test fixtures that predate the requirement now carry an address, so each test still fails for the reason it is about. The two drawer tests asserting the stored-URL "Open in Google Maps" row are rewritten to the behaviour that replaced it. Delivers SH-271. * feat(work-orders): show the vendor location map in the Vendor dialog The last of SH-271's five acceptance bullets. The Work Order Vendor dialog gets one composed address line from the vendor dropdown payload, not the structured parts the form and detail drawer hold, so the preview takes the line directly — it is saved data either way, and the completeness rule exists to stop a map of half-typed input, not to reject a stored address. The dialog's hand-built maps URL now goes through the shared helper, so the link and the preview cannot drift apart. * test(vendors): update the browser specs and pixel baselines for the new address `npm run verify` does not run Playwright, so the first push went out with the browser suite still asserting the UI this ticket removes. Three assertions were stale: the combined "Address (optional)" input, the "Google Maps URL (optional)" input, and the detail drawer's separate "Open in Google Maps" row — now replaced by the Street Address itself being the link, checked against the derived href. A fourth test created a vendor without an address, which the new requirement blocks; it fills one, so the test still fails only for the reason it is about. Baselines regenerated in mcr.microsoft.com/playwright:v1.61.1-noble, the image CI uses — macOS font rendering produces different pixels. Exactly three of the sixteen were rewritten (vendor add, edit, detail); the rest, including every Work Orders shot, are byte-identical, which is the evidence that this change stays inside the surfaces it claims. * test(vendors): keep the map preview out of the pixel baselines The visual suite mocks `**/api/**` and nothing else, so the address map preview's iframe reached maps.google.com for real. Whether that frame paints, and what it paints, depends on the network and on what Google serves that minute — which is why `vendor-edit` failed in CI at 18178 differing pixels while passing in a container that could not reach Google. Regenerating the baseline would not have fixed it; it would have moved the flake. Aborting the request pins the frame to a blank box, so the shot measures our layout and nothing else. The committed baselines are unchanged by this — they were already correct — and a second container run with no --update passes 16/16, which is the evidence the shot is now stable rather than merely green once. * fix(vendors): complete structured address map flows * test(vendors): align add visual baseline with CI --------- Co-authored-by: Codex Review Integration <codex-review@local.invalid>
2026-09-15 16:13:15 -03:00
await expect(page.getByLabel("Google Maps URL (optional)")).toHaveCount(0);
await expect(page.getByLabel("Address (optional)")).toHaveCount(0);
await expect(page.getByLabel("Preferred Contact")).toHaveCount(0);
await expect(page.getByLabel("Technician name (optional)")).toHaveCount(1);
await expect(page.getByLabel("Technician name (optional)")).toHaveValue("");
await expect(
page.getByRole("dialog", { name: /Add Vendor/ }).getByText("Adam Whyte"),
).toHaveCount(0);
await page.getByRole("button", { name: "Add technician" }).click();
await page.getByLabel("Technician name (optional)").last().fill("New Technician");
const tradeInput = page.getByRole("combobox", { name: "Add Trade" }).last();
await tradeInput.fill("Plumbing");
await tradeInput.press("Enter");
await tradeInput.fill("HVAC");
await tradeInput.press("Enter");
await expect(page.getByRole("button", { name: /Move (Plumbing|HVAC) (up|down)/ })).toHaveCount(
0,
);
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);
expect(state.patchedCompanyId).toBe("101");
expect(state.patchedBody).toMatchObject({ rowVersion: "rv-1" });
// Only the field the user actually typed into is transmitted. Address/phone/email were
// populated by selecting the company and never edited, so they must not be sent — otherwise a
// concurrent edit by another user to any of them would be silently reverted on resubmit.
expect(state.patchedBody?.companyFields).toEqual({ notes: "Created in browser E2E" });
expect(state.patchedBody?.companyFields).not.toHaveProperty("companyPhone");
expect(state.patchedBody?.companyFields).not.toHaveProperty("address");
expect(state.patchedBody?.companyFields).not.toHaveProperty("email");
expect(state.patchedBody?.companyFields).not.toHaveProperty("name");
expect(state.patchedBody?.addTechnicians).toEqual([
expect.objectContaining({
contactName: "New Technician",
tradeSpecialties: "Plumbing, HVAC",
}),
]);
const patchedTechnicians = Array.isArray(state.patchedBody?.addTechnicians)
? (state.patchedBody.addTechnicians as Array<Record<string, unknown>>)
: [];
const newTechnician = patchedTechnicians.find(
(technician) => technician.contactName === "New Technician",
);
expect(newTechnician?.preferredContact).toBeUndefined();
expect(patchedTechnicians.some((technician) => technician.contactName === "Adam Whyte")).toBe(
false,
);
expect(state.updatedBody).toBeUndefined();
await page.getByRole("button", { name: "View vendor Gateway Plumbing" }).click();
const detailDrawer = page.locator(".MuiDrawer-paper").last();
await expect(detailDrawer.getByRole("heading", { name: "Adam Whyte" })).toBeVisible();
await expect(detailDrawer.getByText("Adam Whyte")).toBeVisible();
await expect(detailDrawer.getByText("72")).toBeVisible();
feat(vendors): structure the address into Street/City/State with autocomplete (#176) * feat(vendors): structure the address into Street/City/State with autocomplete The Vendor form carried one free-text "Address (optional)" line, with City, State and Zip already present but hidden behind display:none, and a separate "Google Maps URL" input someone had to paste into by hand. Street Address, City and State are now three required fields. Typing three characters in Street Address offers up to four candidates; picking one fills all three at once, and typing without picking stays plain free text. The Google Maps URL input is gone — the location is derived from the address, the Street Address itself is the link in view mode, and a keyless map preview renders once all three parts are present. Zip stays in the payload but out of the form; the ticket scopes the visible set to three. The suggestion algorithm, candidate cities and copy are ported from the approved prototype rather than invented, so dev and design agree on what a dispatcher sees. Suggestions are deterministic for a given input on purpose: a reshuffling list moves a row out from under the pointer mid-click. Test fixtures that predate the requirement now carry an address, so each test still fails for the reason it is about. The two drawer tests asserting the stored-URL "Open in Google Maps" row are rewritten to the behaviour that replaced it. Delivers SH-271. * feat(work-orders): show the vendor location map in the Vendor dialog The last of SH-271's five acceptance bullets. The Work Order Vendor dialog gets one composed address line from the vendor dropdown payload, not the structured parts the form and detail drawer hold, so the preview takes the line directly — it is saved data either way, and the completeness rule exists to stop a map of half-typed input, not to reject a stored address. The dialog's hand-built maps URL now goes through the shared helper, so the link and the preview cannot drift apart. * test(vendors): update the browser specs and pixel baselines for the new address `npm run verify` does not run Playwright, so the first push went out with the browser suite still asserting the UI this ticket removes. Three assertions were stale: the combined "Address (optional)" input, the "Google Maps URL (optional)" input, and the detail drawer's separate "Open in Google Maps" row — now replaced by the Street Address itself being the link, checked against the derived href. A fourth test created a vendor without an address, which the new requirement blocks; it fills one, so the test still fails only for the reason it is about. Baselines regenerated in mcr.microsoft.com/playwright:v1.61.1-noble, the image CI uses — macOS font rendering produces different pixels. Exactly three of the sixteen were rewritten (vendor add, edit, detail); the rest, including every Work Orders shot, are byte-identical, which is the evidence that this change stays inside the surfaces it claims. * test(vendors): keep the map preview out of the pixel baselines The visual suite mocks `**/api/**` and nothing else, so the address map preview's iframe reached maps.google.com for real. Whether that frame paints, and what it paints, depends on the network and on what Google serves that minute — which is why `vendor-edit` failed in CI at 18178 differing pixels while passing in a container that could not reach Google. Regenerating the baseline would not have fixed it; it would have moved the flake. Aborting the request pins the frame to a blank box, so the shot measures our layout and nothing else. The committed baselines are unchanged by this — they were already correct — and a second container run with no --update passes 16/16, which is the evidence the shot is now stable rather than merely green once. * fix(vendors): complete structured address map flows * test(vendors): align add visual baseline with CI --------- Co-authored-by: Codex Review Integration <codex-review@local.invalid>
2026-09-15 16:13:15 -03:00
// SH-271: the separate "Open in Google Maps" row is gone — the Street
// Address itself is the link, built from the structured parts.
const streetLink = detailDrawer.getByRole("link", { name: "1 Market St" }).first();
await expect(streetLink).toHaveAttribute("target", "_blank");
await expect(streetLink).toHaveAttribute(
"href",
"https://www.google.com/maps/search/?api=1&query=1%20Market%20St%20St.%20Louis%20MO",
);
await expect(detailDrawer.getByRole("link", { name: "Open in Google Maps" })).toHaveCount(0);
await captureVisual(page, "/tmp/seahaven-vendor-implemented-detail.png");
await detailDrawer.getByRole("button", { name: "Edit" }).click();
await expect(page).toHaveURL(/\/vendors$/);
await detailDrawer.getByLabel("Notes (optional)").fill("Updated in browser E2E");
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
await expect.poll(() => state.updatedBody?.notes).toBe("Updated in browser E2E");
});
test("filters by Service Area and lets an admin assign one", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1000 });
const state = await mockVendorApi(page);
await page.goto("/vendors");
const gatewayRow = page.getByRole("button", {
name: "Open vendor details for Gateway Plumbing",
});
const metroRow = page.getByRole("button", { name: "Open vendor details for Metro HVAC" });
await expect(gatewayRow.getByTestId("vendor-text-service-area")).toHaveText("East");
await expect(metroRow.getByTestId("vendor-text-service-area")).toHaveText("Unassigned");
await page.getByRole("button", { name: "Open vendor filters" }).click();
await page.getByRole("button", { name: "Service Area" }).click();
await page.getByPlaceholder("All areas").click();
await expect(page.getByRole("option")).toHaveText(["Unassigned", "Central", "East"]);
await page.getByRole("option", { name: "East" }).click();
await page.getByRole("button", { name: "Apply filters" }).click();
await expect(
page.getByRole("button", { name: "Remove filter Service Area: East" }),
).toBeVisible();
await expect(gatewayRow).toBeVisible();
await expect(metroRow).toHaveCount(0);
expect(state.listUrls.some((url) => new URL(url).searchParams.get("areas[0]") === "1")).toBe(
true,
);
await page.getByRole("button", { name: "Open vendor filters" }).click();
await page.getByRole("button", { name: "Service Area" }).click();
await page.getByRole("button", { name: "Clear all" }).last().click();
await page.getByPlaceholder("All areas").click();
await page.getByRole("option", { name: "Unassigned" }).click();
await page.getByRole("button", { name: "Apply filters" }).click();
await expect(page.getByText("Service Area: Unassigned")).toBeVisible();
await expect(metroRow).toBeVisible();
await expect(gatewayRow).toHaveCount(0);
await page.getByRole("button", { name: "Edit vendor Metro HVAC" }).click();
const detailDrawer = page.locator(".MuiDrawer-paper").last();
const areaField = detailDrawer.getByRole("combobox", { name: "Service Area (optional)" });
await expect(areaField).toBeEnabled();
await areaField.click();
await page.getByRole("option", { name: "Central" }).click();
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
await expect.poll(() => state.updatedBody?.areaId).toBe(2);
expect(state.updatedBody?.state).toBe("MO");
});
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: "Edit vendor Gateway Plumbing" }).click();
await page.getByRole("switch", { name: "Active status" }).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: "Edit vendor Gateway Plumbing" }).click();
const detailDrawer = page.locator(".MuiDrawer-paper").last();
await detailDrawer.getByRole("switch", { name: "Active status" }).click();
await detailDrawer.getByRole("button", { name: "Save changes" }).click();
await expect.poll(() => state.updatedBody).toEqual({ isActive: true });
await detailDrawer.getByRole("button", { name: "Close drawer" }).click();
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", exact: true });
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", exact: true }).last(),
).toBeEnabled();
await page.getByRole("combobox", { name: "Company (required)" }).fill("Independent Vendor LLC");
await page.getByLabel("Company Phone (optional)").fill("3145550199");
feat(vendors): structure the address into Street/City/State with autocomplete (#176) * feat(vendors): structure the address into Street/City/State with autocomplete The Vendor form carried one free-text "Address (optional)" line, with City, State and Zip already present but hidden behind display:none, and a separate "Google Maps URL" input someone had to paste into by hand. Street Address, City and State are now three required fields. Typing three characters in Street Address offers up to four candidates; picking one fills all three at once, and typing without picking stays plain free text. The Google Maps URL input is gone — the location is derived from the address, the Street Address itself is the link in view mode, and a keyless map preview renders once all three parts are present. Zip stays in the payload but out of the form; the ticket scopes the visible set to three. The suggestion algorithm, candidate cities and copy are ported from the approved prototype rather than invented, so dev and design agree on what a dispatcher sees. Suggestions are deterministic for a given input on purpose: a reshuffling list moves a row out from under the pointer mid-click. Test fixtures that predate the requirement now carry an address, so each test still fails for the reason it is about. The two drawer tests asserting the stored-URL "Open in Google Maps" row are rewritten to the behaviour that replaced it. Delivers SH-271. * feat(work-orders): show the vendor location map in the Vendor dialog The last of SH-271's five acceptance bullets. The Work Order Vendor dialog gets one composed address line from the vendor dropdown payload, not the structured parts the form and detail drawer hold, so the preview takes the line directly — it is saved data either way, and the completeness rule exists to stop a map of half-typed input, not to reject a stored address. The dialog's hand-built maps URL now goes through the shared helper, so the link and the preview cannot drift apart. * test(vendors): update the browser specs and pixel baselines for the new address `npm run verify` does not run Playwright, so the first push went out with the browser suite still asserting the UI this ticket removes. Three assertions were stale: the combined "Address (optional)" input, the "Google Maps URL (optional)" input, and the detail drawer's separate "Open in Google Maps" row — now replaced by the Street Address itself being the link, checked against the derived href. A fourth test created a vendor without an address, which the new requirement blocks; it fills one, so the test still fails only for the reason it is about. Baselines regenerated in mcr.microsoft.com/playwright:v1.61.1-noble, the image CI uses — macOS font rendering produces different pixels. Exactly three of the sixteen were rewritten (vendor add, edit, detail); the rest, including every Work Orders shot, are byte-identical, which is the evidence that this change stays inside the surfaces it claims. * test(vendors): keep the map preview out of the pixel baselines The visual suite mocks `**/api/**` and nothing else, so the address map preview's iframe reached maps.google.com for real. Whether that frame paints, and what it paints, depends on the network and on what Google serves that minute — which is why `vendor-edit` failed in CI at 18178 differing pixels while passing in a container that could not reach Google. Regenerating the baseline would not have fixed it; it would have moved the flake. Aborting the request pins the frame to a blank box, so the shot measures our layout and nothing else. The committed baselines are unchanged by this — they were already correct — and a second container run with no --update passes 16/16, which is the evidence the shot is now stable rather than merely green once. * fix(vendors): complete structured address map flows * test(vendors): align add visual baseline with CI --------- Co-authored-by: Codex Review Integration <codex-review@local.invalid>
2026-09-15 16:13:15 -03:00
// SH-271 made the structured address required. This test is about keyboard
// dismissal and free-text company creation, so the address is filled to
// leave those as the only things it is exercising.
await page.getByRole("combobox", { name: "Street Address (required)" }).fill("1 Market St");
await page.getByLabel("City (required)").fill("St. Louis");
await page.getByLabel("State (required)").fill("Missouri");
await page.getByRole("option", { name: "Missouri (MO)" }).click();
await page.getByRole("button", { name: "Add Vendor", exact: true }).last().click();
await expect.poll(() => state.createdBody?.name).toBe("Independent Vendor LLC");
expect(state.createdBody?.technicians).toEqual([]);
expect(state.createdBody?.state).toBe("MO");
expect(state.createdBody?.areaId).toBeNull();
});
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("confirms deactivation past linked work orders and preserves the vendor on a raced 409", async ({
page,
}) => {
const confirmState = await mockVendorApi(page, { deactivationBlocked: true });
await page.goto("/vendors");
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
await page.getByRole("switch", { name: "Active status" }).click();
const dialog = page.getByRole("dialog", { name: "Deactivate this vendor?" });
await expect(dialog).toContainText("It still has 1 open work order");
await expect(
dialog.getByRole("link", { name: /WO-501 — Emergency boiler repair/ }),
).toHaveAttribute("href", "/workorders/501");
// SH-254: the open work orders inform the decision, they no longer block it.
const confirm = dialog.getByRole("button", { name: "Deactivate anyway" });
await expect(confirm).toBeEnabled();
await confirm.click();
await expect.poll(() => confirmState.deletedId).toBe("1");
expect(confirmState.deleteConfirmedOpenWorkOrders).toBe(true);
await page.unrouteAll({ behavior: "wait" });
const racedState = await mockVendorApi(page, { deleteConflict: true });
await page.reload();
await page.getByRole("button", { name: "Edit vendor Gateway Plumbing" }).click();
await page.getByRole("switch", { name: "Active status" }).click();
await page
.getByRole("dialog", { name: "Deactivate this vendor?" })
.getByRole("button", {
name: /^Deactivate$/,
})
.click();
await expect(page.getByRole("dialog", { name: "Deactivate this 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");
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();
await expect(page.getByRole("main").getByRole("alert")).toContainText(
/Vendor directory unavailable|failed|server error/i,
{ timeout: 15_000 },
);
await captureVisual(page, "/tmp/seahaven-vendor-implemented-mobile-list.png");
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();
});
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",
);
}
});
});