import { expect, test } from '@playwright/test'; import { DEMO_ANALYST } from './support/env'; /** * End-to-end regression test for the bug reported against the running app: * ticking one value in a facet (e.g. "Job family") made every other value in * that same checkbox list disappear, because the backend's aggregations were * computed on the already-filtered result set. Fixed in * apps/search/backends/{elastic,postgres}.py (self-exclusion aggregations) * and covered at the unit/API level by test_query_builder.py and * test_search_api.py. This spec drives the real browser against the real, * Elasticsearch-backed stack (`docker compose up`) end to end, so it also * catches anything a backend-only test cannot: wiring in FacetGroup/ * FilterPanel, the network round trip, and URL state. */ async function signIn(page: import('@playwright/test').Page) { await page.goto('/login'); await page.getByLabel('Email').fill(DEMO_ANALYST.email); await page.getByLabel('Password').fill(DEMO_ANALYST.password); await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page.getByRole('button', { name: /^Job family/ })).toBeVisible(); } /** The raw facet value lives in the label's `title` attribute (see FacetGroup.tsx). */ async function facetValues(group: import('@playwright/test').Locator): Promise { const labels = group.locator('.facet__label'); const count = await labels.count(); const values: string[] = []; for (let i = 0; i < count; i += 1) { values.push((await labels.nth(i).getAttribute('title')) ?? ''); } return values; } test.describe('multi-select facets', () => { test('ticking one job-family value keeps every sibling value in the list', async ({ page }) => { await signIn(page); const jobFamily = page.locator('.facet').filter({ has: page.getByRole('button', { name: /^Job family/ }) }); const before = await facetValues(jobFamily); expect(before.length).toBeGreaterThan(1); const firstCheckbox = jobFamily.getByRole('checkbox').first(); const firstValue = await jobFamily.locator('.facet__label').first().getAttribute('title'); const responsePromise = page.waitForResponse((response) => response.url().includes('/api/v1/search/')); await firstCheckbox.check(); await responsePromise; await expect(firstCheckbox).toBeChecked(); // The exact bug: this used to collapse to just the ticked value. const after = await facetValues(jobFamily); expect(after.sort()).toEqual(before.sort()); // The URL is the source of truth for the filter state. expect(page.url()).toContain(`roles=${encodeURIComponent(firstValue ?? '')}`); }); test('ticking a second job-family value accumulates instead of replacing the first', async ({ page }) => { await signIn(page); const jobFamily = page.locator('.facet').filter({ has: page.getByRole('button', { name: /^Job family/ }) }); const checkboxes = jobFamily.getByRole('checkbox'); const labels = jobFamily.locator('.facet__label'); const firstValue = await labels.nth(0).getAttribute('title'); const secondValue = await labels.nth(1).getAttribute('title'); let responsePromise = page.waitForResponse((response) => response.url().includes('/api/v1/search/')); await checkboxes.nth(0).check(); await responsePromise; responsePromise = page.waitForResponse((response) => response.url().includes('/api/v1/search/')); await checkboxes.nth(1).check(); await responsePromise; await expect(checkboxes.nth(0)).toBeChecked(); await expect(checkboxes.nth(1)).toBeChecked(); const url = new URL(page.url()); const roles = (url.searchParams.get('roles') ?? '').split(','); expect(roles).toContain(firstValue); expect(roles).toContain(secondValue); // Ticking the second one must not have removed the first from the DOM. const after = await facetValues(jobFamily); expect(after).toContain(firstValue); expect(after).toContain(secondValue); // "Clear all" must be able to undo both selections in one click. await page.getByRole('button', { name: /clear all/i }).click(); await expect(checkboxes.nth(0)).not.toBeChecked(); }); test('a different facet still narrows when job family is filtered (no over-relaxing)', async ({ page }) => { await signIn(page); const jobFamily = page.locator('.facet').filter({ has: page.getByRole('button', { name: /^Job family/ }) }); const country = page.locator('.facet').filter({ has: page.getByRole('button', { name: /^Country/ }) }); // Country is collapsed by default (only the first three facets start // open) - expand it so its checkbox list is actually in the DOM. await page.getByRole('button', { name: /^Country/ }).click(); const countryBefore = await facetValues(country); expect(countryBefore.length).toBeGreaterThan(0); const responsePromise = page.waitForResponse((response) => response.url().includes('/api/v1/search/')); await jobFamily.getByRole('checkbox').first().check(); await responsePromise; const countryAfter = await facetValues(country); // A facet with no selection of its own must still be scoped by the // active filter - it should not also stay untouched. expect(countryAfter.length).toBeLessThanOrEqual(countryBefore.length); }); });