120 lines
5.2 KiB
TypeScript
120 lines
5.2 KiB
TypeScript
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<string[]> {
|
|
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);
|
|
});
|
|
});
|