# 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-graph.spec.ts >> graph pop-over on geography and leaf pages >> opens and draws on /tracts/0078f0bb-c0ad-4639-13fc-a29547adddac/
- Location: tests/ui/entity-graph.spec.ts:161:9

# Error details

```
Error: Test timeout of 30000ms exceeded
```

# Page snapshot

```yaml
- generic [active] [ref=e1]:
  - banner [ref=e2]:
    - heading "Census Tract 1343.02, Los Angeles County, California" [level=1] [ref=e3]
    - paragraph [ref=e4]: Census Tract
  - generic [ref=e5]:
    - generic [ref=e6]:
      - generic [ref=e7]:
        - generic "e2e-member@example.org" [ref=e8]
        - button "Registered" [ref=e10] [cursor=pointer]
      - generic [ref=e13]:
        - button "Request additional access" [ref=e14] [cursor=pointer]
        - link "Log out" [ref=e15] [cursor=pointer]:
          - /url: /outpost.goauthentik.io/sign_out
    - toolbar "Member tools" [ref=e16]:
      - 'button "All cycles (Needs Election cycles access: request it)" [disabled] [ref=e17] [cursor=pointer]':
        - generic [ref=e21]: All cycles
      - button "API keys" [ref=e24] [cursor=pointer]
      - 'button "Graph (Needs Graph access: request it)" [disabled] [ref=e29] [cursor=pointer]':
        - generic [ref=e38]: Graph
  - generic [ref=e41]:
    - heading "Map" [level=2] [ref=e42]
    - generic [ref=e43]:
      - paragraph [ref=e44]: Loading map...
      - generic:
        - generic:
          - img:
            - generic:
              - generic [ref=e45] [cursor=pointer]
              - generic [ref=e46] [cursor=pointer]
              - generic [ref=e47] [cursor=pointer]
              - generic [ref=e48] [cursor=pointer]
              - generic [ref=e49] [cursor=pointer]
              - generic [ref=e50] [cursor=pointer]
              - generic [ref=e51] [cursor=pointer]
              - generic [ref=e52] [cursor=pointer]
              - generic [ref=e53] [cursor=pointer]
              - generic [ref=e54] [cursor=pointer]
              - generic [ref=e55] [cursor=pointer]
              - generic [ref=e56] [cursor=pointer]
              - generic [ref=e57] [cursor=pointer]
              - generic [ref=e58] [cursor=pointer]
      - generic:
        - generic [ref=e59]:
          - button "Zoom in" [ref=e60] [cursor=pointer]: +
          - button "Zoom out" [ref=e61] [cursor=pointer]: −
        - generic [ref=e62]:
          - link "Leaflet" [ref=e63] [cursor=pointer]:
            - /url: https://leafletjs.com
          - text: "| ©"
          - link "OpenStreetMap" [ref=e68] [cursor=pointer]:
            - /url: https://www.openstreetmap.org/copyright
  - generic [ref=e69]:
    - heading "Overview" [level=2] [ref=e70]
    - generic [ref=e71]:
      - term [ref=e72]: Tract Name
      - definition [ref=e73]: Census Tract 1343.02
      - term [ref=e74]: GEOID
      - definition [ref=e75]: "06037134302"
      - term [ref=e76]: State
      - definition [ref=e77]:
        - link "California" [ref=e78] [cursor=pointer]:
          - /url: /states/ca/
      - term [ref=e79]: County
      - definition [ref=e80]:
        - link "Los Angeles County" [ref=e81] [cursor=pointer]:
          - /url: /counties/92472335-c8ae-c198-6366-9eb734141e61/
      - term [ref=e82]: Congressional District
      - definition [ref=e83]: CA-32
      - term [ref=e84]: Congressional District
      - definition [ref=e85]: CA-027
      - term [ref=e86]: Congressional District
      - definition [ref=e87]: CA-046
      - term [ref=e88]: State FIPS
      - definition [ref=e89]: "06"
      - term [ref=e90]: County FIPS
      - definition [ref=e91]: "06037"
      - term [ref=e92]: Tract Number
      - definition [ref=e93]: "134302"
  - generic [ref=e94]:
    - heading "Demographics" [level=2] [ref=e95]
    - generic [ref=e96]:
      - term [ref=e97]: Total Population
      - definition [ref=e98]: 4,172 (±637)
    - heading "Population by Race" [level=3] [ref=e99]
    - table [ref=e100]:
      - rowgroup [ref=e101]:
        - row [ref=e102]:
          - columnheader "Group" [ref=e103]
          - columnheader "Population" [ref=e104]
      - rowgroup [ref=e105]:
        - row [ref=e106]:
          - cell "White" [ref=e107]
          - cell "1,778" [ref=e108]
        - row [ref=e109]:
          - cell "Black" [ref=e110]
          - cell "129" [ref=e111]
        - row [ref=e112]:
          - cell "Asian" [ref=e113]
          - cell "794" [ref=e114]
  - generic [ref=e115]:
    - heading "Voter Registration" [level=2] [ref=e116]
    - generic [ref=e117]:
      - term [ref=e118]: Total Registered Voters
      - definition [ref=e119]: 2,568
    - heading "By Party" [level=3] [ref=e120]
    - table [ref=e121]:
      - rowgroup [ref=e122]:
        - row [ref=e123]:
          - columnheader "Party" [ref=e124]
          - columnheader "Registered" [ref=e125]
      - rowgroup [ref=e126]:
        - row [ref=e127]:
          - cell "Democrat" [ref=e128]
          - cell "1,286" [ref=e129]
        - row [ref=e130]:
          - cell "Republican" [ref=e131]
          - cell "579" [ref=e132]
        - row [ref=e133]:
          - cell "Independent / No Party" [ref=e134]
          - cell "538" [ref=e135]
        - row [ref=e136]:
          - cell "Other" [ref=e137]
          - cell "22" [ref=e138]
    - heading "By Gender" [level=3] [ref=e139]
    - table [ref=e140]:
      - rowgroup [ref=e141]:
        - row [ref=e142]:
          - columnheader "Gender" [ref=e143]
          - columnheader "Registered" [ref=e144]
      - rowgroup [ref=e145]:
        - row [ref=e146]:
          - cell "Male" [ref=e147]
          - cell "1,187" [ref=e148]
        - row [ref=e149]:
          - cell "Female" [ref=e150]
          - cell "1,344" [ref=e151]
    - heading "By Race / Ethnicity" [level=3] [ref=e152]
    - table [ref=e153]:
      - rowgroup [ref=e154]:
        - row [ref=e155]:
          - columnheader "Group" [ref=e156]
          - columnheader "Registered" [ref=e157]
      - rowgroup [ref=e158]:
        - row [ref=e159]:
          - cell "White" [ref=e160]
          - cell "1,109" [ref=e161]
        - row [ref=e162]:
          - cell "Hispanic" [ref=e163]
          - cell "621" [ref=e164]
        - row [ref=e165]:
          - cell "Black" [ref=e166]
          - cell "26" [ref=e167]
        - row [ref=e168]:
          - cell "Asian" [ref=e169]
          - cell "205" [ref=e170]
    - paragraph [ref=e171]: "Source: L2 voter file via Redistricting Data Hub. Aggregated from census block data."
  - generic [ref=e172]:
    - heading "2022 General Election Turnout" [level=2] [ref=e173]
    - generic [ref=e174]:
      - term [ref=e175]: Total Voted
      - definition [ref=e176]: 1,323
      - term [ref=e177]: Turnout Rate
      - definition [ref=e178]: 51.5%
    - heading "By Race / Ethnicity" [level=3] [ref=e179]
    - table [ref=e180]:
      - rowgroup [ref=e181]:
        - row [ref=e182]:
          - columnheader "Group" [ref=e183]
          - columnheader "Voted" [ref=e184]
          - columnheader "Turnout" [ref=e185]
      - rowgroup [ref=e186]:
        - row [ref=e187]:
          - cell "White" [ref=e188]
          - cell "684" [ref=e189]
          - cell "61.7%" [ref=e190]
        - row [ref=e191]:
          - cell "Hispanic" [ref=e192]
          - cell "274" [ref=e193]
          - cell "44.1%" [ref=e194]
        - row [ref=e195]:
          - cell "Black" [ref=e196]
          - cell "14" [ref=e197]
          - cell "53.8%" [ref=e198]
        - row [ref=e199]:
          - cell "Asian" [ref=e200]
          - cell "94" [ref=e201]
          - cell "45.9%" [ref=e202]
    - heading "By Gender" [level=3] [ref=e203]
    - table [ref=e204]:
      - rowgroup [ref=e205]:
        - row [ref=e206]:
          - columnheader "Gender" [ref=e207]
          - columnheader "Voted" [ref=e208]
          - columnheader "Turnout" [ref=e209]
      - rowgroup [ref=e210]:
        - row [ref=e211]:
          - cell "Male" [ref=e212]
          - cell "603" [ref=e213]
          - cell "50.8%" [ref=e214]
        - row [ref=e215]:
          - cell "Female" [ref=e216]
          - cell "706" [ref=e217]
          - cell "52.5%" [ref=e218]
    - paragraph [ref=e219]: "Source: L2 voter file via Redistricting Data Hub. Aggregated from census block data."
  - generic [ref=e220]:
    - heading "Income & Poverty" [level=2] [ref=e221]
    - generic [ref=e222]:
      - term [ref=e223]: Median Household Income
      - definition [ref=e224]: $131,250 (±$48,394)
      - term [ref=e225]: Poverty Rate
      - definition [ref=e226]: 11.7%
      - term [ref=e227]: Population Below Poverty
      - definition [ref=e228]: "485"
  - generic [ref=e229]:
    - heading "Education" [level=2] [ref=e230]
    - table [ref=e231]:
      - rowgroup [ref=e232]:
        - row [ref=e233]:
          - columnheader "Attainment" [ref=e234]
          - columnheader "Population" [ref=e235]
          - columnheader "Share" [ref=e236]
      - rowgroup [ref=e237]:
        - row [ref=e238]:
          - cell "Bachelor's Degree or Higher" [ref=e239]
          - cell "1,080" [ref=e240]
          - cell "35.9%" [ref=e241]
        - row [ref=e242]:
          - cell "High School Diploma" [ref=e243]
          - cell "317" [ref=e244]
          - cell "10.5%" [ref=e245]
  - generic [ref=e246]:
    - heading "Housing" [level=2] [ref=e247]
    - generic [ref=e248]:
      - term [ref=e249]: Total Housing Units
      - definition [ref=e250]: 1,418
      - term [ref=e251]: Median Home Value
      - definition [ref=e252]: $859,400
      - term [ref=e253]: Median Gross Rent
      - definition [ref=e254]: $3,501
  - generic [ref=e255]:
    - heading "Transportation & Health" [level=2] [ref=e256]
    - table [ref=e257]:
      - rowgroup [ref=e258]:
        - row [ref=e259]:
          - columnheader "Indicator" [ref=e260]
          - columnheader "Rate" [ref=e261]
      - rowgroup [ref=e262]:
        - row [ref=e263]:
          - cell "Drove Alone to Work" [ref=e264]
          - cell "56.0%" [ref=e265]
        - row [ref=e266]:
          - cell "Public Transit" [ref=e267]
          - cell "2.4%" [ref=e268]
        - row [ref=e269]:
          - cell "Work from Home" [ref=e270]
          - cell "33.0%" [ref=e271]
        - row [ref=e272]:
          - cell "Health Insurance Coverage" [ref=e273]
          - cell "31.1%" [ref=e274]
    - paragraph [ref=e275]: "Source: American Community Survey (ACS) 5-Year Estimates."
  - contentinfo [ref=e276]:
    - paragraph [ref=e277]:
      - link "elect.info" [ref=e278] [cursor=pointer]:
        - /url: https://us.elect.info/
      - text: "|"
      - link "Browse Census Tracts" [ref=e279] [cursor=pointer]:
        - /url: /tracts/
```

# Test source

```ts
  75  | /** nodeGraph, or null where the API predates it (a CI pod against an older API): the caller skips. */
  76  | async function nodeGraph(request: import('@playwright/test').APIRequestContext, label: string, id: string) {
  77  |   const res = await request.post('/api/graphql', {
  78  |     data: { query: NODE_QUERY, variables: { l: label, i: id } },
  79  |     headers: visitorHeaders(),
  80  |     timeout: 60000,
  81  |   });
  82  |   expect(res.status()).toBe(200);
  83  |   const body = await res.json();
  84  |   if (body?.errors?.some((e: { message: string }) => /Cannot query field "nodeGraph"/.test(e.message))) return { missing: true as const };
  85  |   return { missing: false as const, body };
  86  | }
  87  | 
  88  | test.describe('nodeGraph API', () => {
  89  |   test('a county: neighbours drawn, blocks counted, no individuals', async ({ request }) => {
  90  |     const r = await nodeGraph(request, 'County', LA_COUNTY);
  91  |     test.skip(r.missing, 'the API has no nodeGraph yet');
  92  |     const g = r.body?.data?.nodeGraph;
  93  |     expect(g).toBeTruthy();
  94  |     expect(g.nodes.filter((n: { focal: boolean }) => n.focal)).toHaveLength(1);
  95  |     expect(g.nodes.length).toBeGreaterThan(10);
  96  |     expect(g.nodes.some((n: { label: string }) => n.label === 'Individual' || n.label === 'Block')).toBe(false);
  97  |     const blocks = g.groups.find((x: { label: string }) => x.label === 'Block');
  98  |     expect(blocks.shown).toBe(0);
  99  |     expect(blocks.total).toBeGreaterThan(10000);
  100 |     expect(g.edges.some((e: { type: string }) => e.type === 'LOCATED_IN')).toBe(true);
  101 |   });
  102 | 
  103 |   test('an unknown label is null', async ({ request }) => {
  104 |     const r = await nodeGraph(request, 'Committee', LA_COUNTY);
  105 |     test.skip(r.missing, 'the API has no nodeGraph yet');
  106 |     expect(r.body?.data?.nodeGraph).toBeNull();
  107 |   });
  108 | 
  109 |   test('voterGraph is refused on the public endpoint', async ({ request }) => {
  110 |     const res = await request.post('/api/graphql', {
  111 |       data: { query: 'query($i: String!) { voterGraph(id: $i) { focalId } }', variables: { i: 'x' } },
  112 |       timeout: 30000,
  113 |     });
  114 |     const body = await res.json().catch(() => null);
  115 |     test.skip(!!body?.errors?.some((e: { message: string }) => /Cannot query field "voterGraph"/.test(e.message)), 'the API has no voterGraph yet');
  116 |     // the visitor-token gate (an unlisted document) or the API's voters scope
  117 |     expect(res.status() >= 400 || body?.errors?.length > 0).toBe(true);
  118 |     expect(body?.data?.voterGraph ?? null).toBeNull();
  119 |   });
  120 | });
  121 | 
  122 | /**
  123 |  * Where the host has accounts the graph is a member control in the identity bar
  124 |  * (sites#355), locked for anonymous visitors (preview's e2e run is anonymous);
  125 |  * the drawing tests sign in a mocked member. hosts without accounts keep the
  126 |  * header button (last test).
  127 |  */
  128 | async function asMember(page: import('@playwright/test').Page): Promise<void> {
  129 |   await page.route('**/auth/whoami*', r => r.fulfill({ json: { enabled: 'true', email: 'e2e-member@example.org', elevated: false, entitlements: {} } }));
  130 |   await page.route('**/auth/account/me*', r => r.fulfill({ json: { requests: [], agreement: null, granted: [] } }));
  131 | }
  132 | 
  133 | test.describe('entity-graph pop-over', () => {
  134 |   for (const path of [DNC, HARRIS]) {
  135 |     test(`opens and draws on ${path}`, async ({ page }) => {
  136 |       const errors: string[] = [];
  137 |       page.on('pageerror', (e) => errors.push(String(e)));
  138 |       await asMember(page);
  139 |       await page.goto(path);
  140 |       const button = page.locator('#auth-slot [data-control="graph"]');
  141 |       await expect(button).toHaveCount(1, { timeout: 15000 });
  142 |       await expect(button).not.toHaveAttribute('aria-disabled', 'true');
  143 |       await button.click();
  144 |       const dialog = page.locator('dialog.eg-dialog');
  145 |       await expect(dialog).toBeVisible();
  146 |       await expect(dialog.locator('.eg-canvas canvas').first()).toBeVisible({ timeout: 60000 });
  147 |       // centred in the viewport, horizontally and vertically
  148 |       const box = (await dialog.boundingBox())!;
  149 |       const vp = page.viewportSize()!;
  150 |       expect(Math.abs(box.x - (vp.width - box.x - box.width))).toBeLessThanOrEqual(2);
  151 |       expect(Math.abs(box.y - (vp.height - box.y - box.height))).toBeLessThanOrEqual(2);
  152 |       await page.keyboard.press('Escape');
  153 |       await expect(dialog).toBeHidden();
  154 |       expect(errors).toEqual([]);
  155 |     });
  156 |   }
  157 | });
  158 | 
  159 | test.describe('graph pop-over on geography and leaf pages', () => {
  160 |   for (const path of [AK, LA_TRACT]) {
  161 |     test(`opens and draws on ${path}`, async ({ page, request }) => {
  162 |       const probe = await nodeGraph(request, 'County', LA_COUNTY);
  163 |       test.skip(probe.missing, 'the API has no nodeGraph yet');
  164 |       if (path === AK) test.skip(!(await (await request.get(AK)).text()).includes('data-graph-id'), 'the us-pages renderer predates GraphScript');
  165 |       const errors: string[] = [];
  166 |       page.on('pageerror', (e) => errors.push(String(e)));
  167 |       await asMember(page);
  168 |       await page.goto(path);
  169 |       const button = page.locator('#auth-slot [data-control="graph"]');
  170 |       await expect(button).toHaveCount(1, { timeout: 30000 });
  171 |       // the real control, not the placeholder hint (the leaf page registers it after rendering)
  172 |       await expect.poll(async () => {
  173 |         await button.click();
  174 |         return page.locator('dialog.eg-dialog').count();
> 175 |       }, { timeout: 30000 }).toBe(1);
      |                              ^ Error: Test timeout of 30000ms exceeded
  176 |       const dialog = page.locator('dialog.eg-dialog');
  177 |       await expect(dialog.locator('.eg-canvas canvas').first()).toBeVisible({ timeout: 60000 });
  178 |       await page.keyboard.press('Escape');
  179 |       await expect(dialog).toBeHidden();
  180 |       expect(errors).toEqual([]);
  181 |     });
  182 |   }
  183 | });
  184 | 
  185 | test.describe('expanding the graph one hop at a time', () => {
  186 |   test(`a county in ${AK}'s graph expands into the drawing`, async ({ page, request }) => {
  187 |     const probe = await nodeGraph(request, 'State', AK_ID);
  188 |     test.skip(probe.missing, 'the API has no nodeGraph yet');
  189 |     test.skip(!(await (await request.get(AK)).text()).includes('data-graph-id'), 'the us-pages renderer predates GraphScript');
  190 |     const county = probe.body?.data?.nodeGraph?.nodes?.find((n: { label: string }) => n.label === 'County');
  191 |     expect(county, 'Alaska has a county neighbour').toBeTruthy();
  192 | 
  193 |     const errors: string[] = [];
  194 |     page.on('pageerror', (e) => errors.push(String(e)));
  195 |     await asMember(page);
  196 |     await page.goto(AK);
  197 |     await page.locator('#auth-slot [data-control="graph"]').click({ timeout: 30000 });
  198 |     const dialog = page.locator('dialog.eg-dialog');
  199 |     await expect(dialog.locator('.eg-canvas canvas').first()).toBeVisible({ timeout: 60000 });
  200 |     type Hook = { electinfoGraph?: { nodeIds(): string[]; renderedPosition(id: string): { x: number; y: number } | null } };
  201 |     const count = () => page.evaluate(() => (document.querySelector('dialog.eg-dialog') as unknown as Hook).electinfoGraph!.nodeIds().length);
  202 |     const before = await count();
  203 |     const at = await page.evaluate((id) => (document.querySelector('dialog.eg-dialog') as unknown as Hook).electinfoGraph!.renderedPosition(id), county.id);
  204 |     expect(at).toBeTruthy();
  205 |     const box = (await dialog.locator('.eg-canvas').boundingBox())!;
  206 |     await page.mouse.click(box.x + at!.x, box.y + at!.y);
  207 |     const menu = dialog.locator('.eg-menu');
  208 |     await expect(menu).toContainText('County');
  209 |     await menu.locator('[data-eg-action="expand"]').click();
  210 |     await expect(menu.locator('[data-eg-status]')).toContainText(/added|No new connections/, { timeout: 60000 });
  211 |     expect(await count()).toBeGreaterThan(before);
  212 |     await expect(dialog.locator('.eg-foot')).toContainText(`Expanded: ${county.displayName}`);
  213 |     expect(errors).toEqual([]);
  214 |   });
  215 | });
  216 | 
  217 | test.describe('entity-graph without accounts', () => {
  218 |   test('a host without Authentik keeps the header Graph button', async ({ page }) => {
  219 |     await page.route('**/auth/whoami*', r => r.fulfill({ json: { enabled: 'false', email: '', elevated: false, entitlements: {} } }));
  220 |     await page.goto(DNC);
  221 |     await expect(page.locator('button.eg-open')).toHaveCount(1, { timeout: 15000 });
  222 |     await expect(page.locator('#auth-slot [data-control="graph"]')).toHaveCount(0);
  223 |   });
  224 | });
  225 | 
```