# Instructions

- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.

# Test info

- Name: tests/ui/entity-suggest.spec.ts >> entity type-ahead (sites#392) >> keyboard wraps, Escape dismisses, and selected Enter opens the published canonical on the same host
- Location: tests/ui/entity-suggest.spec.ts:74:7

# Error details

```
Test timeout of 30000ms exceeded.
```

```
Error: page.goto: net::ERR_ABORTED; maybe frame was detached?
Call log:
  - navigating to "http://10.1.105.22/politicians/?cycle=2024", waiting until "load"

```

# Test source

```ts
  1   | import { test, expect, type Page } from '@playwright/test';
  2   | 
  3   | /** Deterministic browser/API boundary tests; do not depend on index ranking or production. */
  4   | type Request = { query: string; variables?: Record<string, any> };
  5   | const candidate = {
  6   |   id: '11111111-1111-5111-8111-111111111111', type: 'Candidate', name: 'CRUZ, RAFAEL EDWARD',
  7   |   fecId: 'S2TX00312', state: 'TX', office: 'S', district: null, party: 'REP',
  8   |   totalFunds: 12345678, fundsBasis: 'receipts',
  9   | };
  10  | const browse = {
  11  |   results: [{ ...candidate, type: 'Politician', name: 'Browse politician', metadata: '{}' }],
  12  |   total: 100, page: 1, pageSize: 50, hasMore: false,
  13  | };
  14  | const search = {
  15  |   ...browse, results: [{ ...candidate, type: 'Politician', metadata: '{}' }], total: 0,
  16  | };
  17  | 
  18  | async function setup(page: Page, suggestions: unknown[] = [candidate], mode: 'ok' | 'http-error' | 'graphql-error' = 'ok') {
  19  |   const requests: Request[] = [];
  20  |   await page.route('**/auth/whoami*', route => route.fulfill({ json: { enabled: 'false', email: '', elevated: false, entitlements: {} } }));
  21  |   await page.route('**/auth/account/me*', route => route.fulfill({ json: { email: '', requests: [], agreement: null, granted: [] } }));
  22  |   await page.route('**/api/token', route => route.fulfill({ status: 204 }));
  23  |   await page.route('**/api/graphql', async route => {
  24  |     const request = route.request().postDataJSON() as Request;
  25  |     requests.push(request);
  26  |     if (request.query.includes('entitySuggest(')) {
  27  |       if (mode === 'http-error') return route.fulfill({ status: 503 });
  28  |       if (mode === 'graphql-error') return route.fulfill({ json: { errors: [{ message: 'Unavailable' }] } });
  29  |       return route.fulfill({ json: { data: { entitySuggest: suggestions } } });
  30  |     }
  31  |     await route.fulfill({ json: { data: request.query.includes('searchEntities(') ? { searchEntities: search } : { browseEntities: browse } } });
  32  |   });
  33  |   return requests;
  34  | }
  35  | const suggestRequests = (requests: Request[]) => requests.filter(r => r.query.includes('entitySuggest('));
  36  | 
  37  | test.describe('entity type-ahead (sites#392)', () => {
  38  |   test('waits for two characters, debounces, displays context and uses the Candidate/cycle contract', async ({ page }) => {
  39  |     const requests = await setup(page);
  40  |     await page.goto('/politicians/?cycle=2024');
  41  |     const input = page.getByRole('combobox', { name: 'Search entities' });
  42  |     await expect(input).toHaveAttribute('aria-autocomplete', 'list');
  43  |     await input.fill('t');
  44  |     await page.waitForTimeout(350);
  45  |     expect(suggestRequests(requests)).toHaveLength(0);
  46  |     await input.fill('ted');
  47  |     await page.waitForTimeout(80);
  48  |     await input.fill('ted cr');
  49  |     await page.waitForTimeout(80);
  50  |     await input.fill('ted cru');
  51  |     await expect(page.locator('.entity-suggestions').getByRole('option')).toHaveCount(1);
  52  |     expect(suggestRequests(requests)).toHaveLength(1);
  53  |     expect(suggestRequests(requests)[0].variables).toEqual({ prefix: 'ted cru', types: ['Candidate'], cycle: 2024 });
  54  |     expect(suggestRequests(requests)[0].query).toContain('limit: 8');
  55  |     await expect(page.locator('.entity-suggestions').getByRole('option')).toContainText('Candidate');
  56  |     await expect(page.locator('.entity-suggestions').getByRole('option')).toContainText('TX · Senate · REP');
  57  |     await expect(page.locator('.entity-suggestions').getByRole('option')).toContainText('$12,345,678 raised (all cycles)');
  58  |     await expect(input).toHaveAttribute('aria-expanded', 'true');
  59  |     await expect(input).not.toHaveAttribute('aria-activedescendant');
  60  |     expect((await page.getByRole('listbox').getAttribute('id'))).toBe(await input.getAttribute('aria-controls'));
  61  |     await expect(page.locator('#entity-results-title')).toHaveText('All Politicians');
  62  |   });
  63  | 
  64  |   for (const [path, type] of [['candidates', 'Candidate'], ['committees', 'Committee'], ['parties', 'Party']]) {
  65  |     test(`${path} scopes suggestions to ${type}, without a cycle by default`, async ({ page }) => {
  66  |       const requests = await setup(page, [{ ...candidate, type }]);
  67  |       await page.goto(`/${path}/`);
  68  |       await page.getByRole('combobox', { name: 'Search entities' }).fill('ted cru');
  69  |       await expect(page.locator('.entity-suggestions').getByRole('option')).toHaveCount(1);
  70  |       expect(suggestRequests(requests)[0].variables).toEqual({ prefix: 'ted cru', types: [type] });
  71  |     });
  72  |   }
  73  | 
  74  |   test('keyboard wraps, Escape dismisses, and selected Enter opens the published canonical on the same host', async ({ page }) => {
  75  |     await setup(page, [candidate, { ...candidate, id: '22222222-2222-5222-8222-222222222222', name: 'Second match' }]);
  76  |     await page.route(`**/candidates/${candidate.id}/`, route => route.fulfill({
  77  |       headers: { Link: '<https://preview.elect.info/candidates/ted-cruz-s2tx00312/>; rel="canonical"' }, body: '',
  78  |     }));
  79  |     await page.route('**/candidates/ted-cruz-s2tx00312/**', route => route.fulfill({ contentType: 'text/html', body: '<h1>Canonical candidate</h1>' }));
> 80  |     await page.goto('/politicians/?cycle=2024');
      |                ^ Error: page.goto: net::ERR_ABORTED; maybe frame was detached?
  81  |     const input = page.getByRole('combobox', { name: 'Search entities' });
  82  |     await input.fill('ted cru');
  83  |     await expect(page.locator('.entity-suggestions').getByRole('option')).toHaveCount(2);
  84  |     await input.press('ArrowUp');
  85  |     await expect(page.locator('.entity-suggestions').getByRole('option', { selected: true })).toContainText('Second match');
  86  |     await input.press('ArrowDown');
  87  |     await expect(page.locator('.entity-suggestions').getByRole('option', { selected: true })).toContainText(candidate.name);
  88  |     expect(await input.getAttribute('aria-activedescendant')).toBe(await page.locator('.entity-suggestions').getByRole('option', { selected: true }).getAttribute('id'));
  89  |     await expect(input).toBeFocused();
  90  |     await input.press('Escape');
  91  |     await expect(input).toHaveAttribute('aria-expanded', 'false');
  92  |     await expect(input).not.toHaveAttribute('aria-activedescendant');
  93  |     await input.fill('ted cruz');
  94  |     await expect(page.locator('.entity-suggestions').getByRole('option')).toHaveCount(2);
  95  |     await input.press('ArrowDown');
  96  |     await input.press('Enter');
  97  |     await expect(page).toHaveURL(/\/candidates\/ted-cruz-s2tx00312\/\?cycle=2024$/);
  98  |     await expect(page.getByRole('heading')).toHaveText('Canonical candidate');
  99  |   });
  100 | 
  101 |   for (const [collection, type] of [['committees', 'Committee'], ['parties', 'Party']]) {
  102 |     test(`pointer opens ${collection} via UUID fallback if canonical lookup is unavailable`, async ({ page }) => {
  103 |       const item = { ...candidate, type, name: '<img src=x onerror=alert(1)> & test' };
  104 |       await setup(page, [item]);
  105 |       await page.route(`**/${collection}/${candidate.id}/`, route => route.request().method() === 'HEAD'
  106 |         ? route.fulfill({ status: 503 })
  107 |         : route.fulfill({ contentType: 'text/html', body: '<h1>Entity page</h1>' }));
  108 |       await page.goto(`/${collection}/`);
  109 |       await page.getByRole('combobox', { name: 'Search entities' }).fill('test');
  110 |       await expect(page.locator('.entity-suggestions').getByRole('option')).toHaveText(new RegExp('onerror=alert'));
  111 |       await expect(page.locator('.entity-suggestions').getByRole('option').locator('img')).toHaveCount(0);
  112 |       await page.locator('.entity-suggestions').getByRole('option').click();
  113 |       await expect(page).toHaveURL(new RegExp(`/${collection}/${candidate.id}/$`));
  114 |     });
  115 |   }
  116 | 
  117 |   for (const mode of ['ok', 'http-error', 'graphql-error'] as const) {
  118 |     test(`submitted search still displays returned rows with total=0 (${mode})`, async ({ page }) => {
  119 |       const requests = await setup(page, mode === 'ok' ? [candidate] : [], mode);
  120 |       await page.goto('/politicians/');
  121 |       const input = page.getByRole('combobox', { name: 'Search entities' });
  122 |       await input.fill('ted cru');
  123 |       if (mode === 'ok') await expect(page.locator('.entity-suggestions').getByRole('option')).toHaveCount(1);
  124 |       else await expect(page.getByRole('status')).toContainText('Suggestions unavailable');
  125 |       // Without an arrow-key selection, Enter must retain the submit fallback.
  126 |       await input.press('Enter');
  127 |       await expect(page.locator('#entity-results-title')).toHaveText('Search Results for "ted cru"');
  128 |       await expect(page.locator('.entity-search-result')).toContainText(candidate.name);
  129 |       await expect(page.locator('.entity-search-result')).not.toContainText('Browse politician');
  130 |       await expect(page.getByRole('listbox')).toBeHidden();
  131 |       expect(requests.find(r => r.query.includes('searchEntities('))?.variables).toMatchObject({ query: 'ted cru', entityType: 'Politician' });
  132 |       await expect(page).toHaveURL(/q=ted\+cru/);
  133 |     });
  134 |   }
  135 | 
  136 |   test('empty suggestions keep explicit Search and Show All working', async ({ page }) => {
  137 |     await setup(page, []);
  138 |     await page.goto('/politicians/');
  139 |     await page.getByRole('combobox', { name: 'Search entities' }).fill('ted cruz');
  140 |     await expect(page.getByRole('status')).toContainText('No suggestions');
  141 |     await page.getByRole('button', { name: 'Search', exact: true }).click();
  142 |     await expect(page.locator('.entity-search-result')).toContainText(candidate.name);
  143 |     await page.getByRole('button', { name: 'Show All' }).click();
  144 |     await expect(page.locator('#entity-results-title')).toHaveText('All Politicians');
  145 |     await expect(page.getByRole('combobox', { name: 'Search entities' })).toHaveValue('');
  146 |   });
  147 | 
  148 |   test('ignores late replies after a new prefix, dismissal, or clearing below the minimum', async ({ page }) => {
  149 |     await setup(page);
  150 |     let release!: () => void;
  151 |     await page.route('**/api/graphql', async route => {
  152 |       if (!(route.request().postDataJSON() as Request).query.includes('entitySuggest(')) return route.fallback();
  153 |       await new Promise<void>(r => { release = r; });
  154 |       await route.fulfill({ json: { data: { entitySuggest: [{ ...candidate, name: 'Stale match' }] } } }).catch(() => {});
  155 |     });
  156 |     await page.goto('/politicians/');
  157 |     const input = page.getByRole('combobox', { name: 'Search entities' });
  158 |     for (const action of ['clear', 'escape', 'blur']) {
  159 |       release = undefined as any;
  160 |       await input.fill('ted cru');
  161 |       await expect.poll(() => !!release).toBe(true);
  162 |       if (action === 'clear') await input.fill('t');
  163 |       else if (action === 'escape') await input.press('Escape');
  164 |       else await input.press('Tab');
  165 |       release();
  166 |       await page.waitForTimeout(100);
  167 |       await expect(input).toHaveAttribute('aria-expanded', 'false');
  168 |       await expect(page.locator('.entity-suggestions').getByRole('option')).toHaveCount(0);
  169 |     }
  170 |   });
  171 | 
  172 |   test.describe('touch selection', () => {
  173 |     test.use({ hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 } });
  174 |     test('tap opens an option without blur removing it first', async ({ page }) => {
  175 |       await setup(page);
  176 |       await page.route(`**/candidates/${candidate.id}/`, route => route.request().method() === 'HEAD'
  177 |         ? route.fulfill({ status: 503 })
  178 |         : route.fulfill({ contentType: 'text/html', body: '<h1>Entity page</h1>' }));
  179 |       await page.goto('/politicians/');
  180 |       await page.getByRole('combobox', { name: 'Search entities' }).fill('ted cru');
```