← back to Govarbitrage

tests/e2e/agents-sort-density.spec.ts

139 lines

import { test, expect, type Page } from "@playwright/test";

// TK-00046 regression guard — the /agents results grid must keep its sort +
// density controls (Steve standing rule: every product/results grid gets sort +
// density, persisted to localStorage).
//
// The real search bills Google Places (~$0.04/query x 3 lenses x kinds), so the
// API is intercepted with a fixture: this spec costs $0 and never calls Places.
//
// This is a NEGATIVE-CAPABLE test: it asserts observable runtime behaviour
// (order actually changes, the grid's minmax actually changes, prefs actually
// survive a reload) rather than the mere presence of markup — so stripping the
// controls, or leaving them rendered but unwired, both turn it RED.

// The app fronts everything with a Basic-Auth wall (src/middleware.ts), and
// clearing that wall IS the login — presentedAdminBasicAuth() mints an ADMIN
// session, so no email form is involved. Sending the creds on each request
// exercises the REAL gated path instead of disabling the wall for the test.
// Creds come from the same BASIC_AUTH env the middleware reads, so nothing is
// hardcoded here; skip if the wall is explicitly disabled.
const [BA_USER, BA_PASS] = (process.env.BASIC_AUTH ?? "admin:DW2024!").split(":");
if (BA_USER) test.use({ httpCredentials: { username: BA_USER, password: BA_PASS ?? "" } });

// E2E_CHANNEL=chrome drives the system-installed Google Chrome instead of
// Playwright's bundled chromium. Useful on a box where the shared
// ~/Library/Caches/ms-playwright dir is lock-contended by another project's
// install, and it exercises a real browser rather than the headless shell.
if (process.env.E2E_CHANNEL) test.use({ channel: process.env.E2E_CHANNEL });

const FIXTURE = {
  location: "Austin, TX",
  kinds: ["buyer", "leasing"],
  queries: 3,
  results: [
    // Deliberately ordered so "best" (server order), "name" and "reviews" each
    // produce a DIFFERENT sequence — otherwise a broken sort could still pass.
    { id: "c", name: "Cobalt Commercial", address: "3 C St", rating: 4.1, reviews: 300,
      phone: null, website: null, mapsUrl: null, primaryType: "real_estate_agency", kinds: ["buyer"] },
    { id: "a", name: "Apex Realty Partners", address: "1 A St", rating: 4.9, reviews: 12,
      phone: null, website: null, mapsUrl: null, primaryType: "real_estate_agency", kinds: ["leasing"] },
    { id: "b", name: "Beacon Brokerage", address: "2 B St", rating: 3.2, reviews: 150,
      phone: null, website: null, mapsUrl: null, primaryType: "real_estate_agency", kinds: ["buyer"] },
  ],
};

const NAMES = FIXTURE.results.map((r) => r.name);

async function search(page: Page) {
  await page.route("**/api/agents/search**", (route) =>
    route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(FIXTURE) }),
  );
  await page.goto("/agents");
  await page.getByLabel(/city/i).first().fill("Austin");
  await page.getByRole("button", { name: /find agents|search/i }).first().click();
  await expect(page.getByText(/3 commercial agents/i).first()).toBeVisible();
}

/** Visible order of the result cards, by fixture name. */
async function cardOrder(page: Page): Promise<string[]> {
  const body = await page.locator("body").innerText();
  return NAMES.map((n) => ({ n, i: body.indexOf(n) }))
    .filter((x) => x.i >= 0)
    .sort((a, b) => a.i - b.i)
    .map((x) => x.n);
}

/** The results grid's live minmax() track size, in px. */
async function gridMin(page: Page): Promise<number> {
  const grid = page.locator('div.grid[style*="minmax"]').last();
  const style = (await grid.getAttribute("style")) ?? "";
  const m = style.match(/minmax\((\d+)px/);
  expect(m, `grid style should carry a minmax() px track, got: ${style}`).not.toBeNull();
  return Number(m![1]);
}

test("results grid exposes sort + density controls", async ({ page }) => {
  await search(page);
  await expect(page.getByLabel("Sort results")).toBeVisible();
  await expect(page.getByLabel("Grid density")).toBeVisible();
});

test("sort actually reorders the results", async ({ page }) => {
  await search(page);

  // "best" = server order, untouched.
  expect(await cardOrder(page)).toEqual(["Cobalt Commercial", "Apex Realty Partners", "Beacon Brokerage"]);

  await page.getByLabel("Sort results").selectOption("name");
  expect(await cardOrder(page)).toEqual(["Apex Realty Partners", "Beacon Brokerage", "Cobalt Commercial"]);

  await page.getByLabel("Sort results").selectOption("reviews");
  expect(await cardOrder(page)).toEqual(["Cobalt Commercial", "Beacon Brokerage", "Apex Realty Partners"]);

  await page.getByLabel("Sort results").selectOption("rating");
  expect(await cardOrder(page)).toEqual(["Apex Realty Partners", "Cobalt Commercial", "Beacon Brokerage"]);
});

test("density slider actually changes the grid track size", async ({ page }) => {
  await search(page);
  const before = await gridMin(page);

  // Slider is inverted (right = denser = smaller cards); drive it to its min,
  // then its max, and require the grid to move in both directions.
  const slider = page.getByLabel("Grid density");
  await slider.fill("220");
  const wide = await gridMin(page);
  await slider.fill("440");
  const dense = await gridMin(page);

  // Right = denser, so the max slider position must yield the SMALLER track.
  expect(dense).toBeLessThan(wide);
  // And the default must sit inside the control's range (not a frozen constant).
  expect(before).toBeGreaterThanOrEqual(dense);
  expect(before).toBeLessThanOrEqual(wide);
});

test("sort + density survive a reload (localStorage-persisted)", async ({ page }) => {
  await search(page);
  await page.getByLabel("Sort results").selectOption("name");
  await page.getByLabel("Grid density").fill("440");
  const densePx = await gridMin(page);

  await page.reload();

  // The controls only render once a search has returned >1 result, so re-run the
  // search first — the persisted prefs are hydrated from localStorage on mount
  // and must already be in force when the grid reappears.
  await page.getByLabel(/city/i).first().fill("Austin");
  await page.getByRole("button", { name: /find agents|search/i }).first().click();
  await expect(page.getByText(/3 commercial agents/i).first()).toBeVisible();

  // Both the control's own state AND the rendered output must reflect the
  // restored prefs — order alone could coincide, and a restored <select> value
  // that didn't drive the grid would be a silent half-fix.
  await expect(page.getByLabel("Sort results")).toHaveValue("name");
  expect(await cardOrder(page)).toEqual(["Apex Realty Partners", "Beacon Brokerage", "Cobalt Commercial"]);
  expect(await gridMin(page)).toBe(densePx);
});