# 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 >> entity-graph pop-over >> opens and draws on /committees/a75a9110-f1db-a945-39a8-7503ed3c04da/
- Location: tests/ui/entity-graph.spec.ts:135:9

# Error details

```
Error: expect(locator).not.toHaveAttribute(expected) failed

Locator:  locator('#auth-slot [data-control="graph"]')
Expected: not "true"
Received: "true"
Timeout:  5000ms

Call log:
  - Expect "not toHaveAttribute" locator('#auth-slot [data-control="graph"]') with timeout 5000ms
  - waiting for locator('#auth-slot [data-control="graph"]')
    14 × locator resolved to <button type="button" data-control="graph" aria-disabled="true" class="ei-control ei-control--locked" title="Needs Graph access: request it" aria-label="Graph (Needs Graph access: request it)">…</button>
       - unexpected value "true"

```

```yaml
- 'button "Graph (Needs Graph access: request it)" [disabled]': Graph
```

# Test source

```ts
  42  | 
  43  |   for (const path of OTHER_PAGES) {
  44  |     test(`not injected on ${path}`, async ({ request }) => {
  45  |       const res = await request.get(path);
  46  |       expect(res.status()).toBe(200);
  47  |       expect(await res.text()).not.toContain('entity-graph.js');
  48  |     });
  49  |   }
  50  | });
  51  | 
  52  | test.describe('entityGraph API', () => {
  53  |   for (const fecId of ['C00010603', 'P00009423']) {
  54  |     test(`${fecId}: neighbours without individuals, money edges with amounts`, async ({ request }) => {
  55  |       const res = await request.post('/api/graphql', {
  56  |         data: {
  57  |           query: 'query($f: String!) { entityGraph(fecId: $f) { focalId nodes { id label labels focal displayName } edges { type amount } } }',
  58  |           variables: { f: fecId },
  59  |         },
  60  |         headers: visitorHeaders(),
  61  |         timeout: 60000,
  62  |       });
  63  |       expect(res.status()).toBe(200);
  64  |       const g = (await res.json())?.data?.entityGraph;
  65  |       expect(g).toBeTruthy();
  66  |       expect(g.nodes.filter((n: { focal: boolean }) => n.focal)).toHaveLength(1);
  67  |       expect(g.nodes.length).toBeGreaterThan(5);
  68  |       expect(g.nodes.some((n: { labels: string[] }) => n.labels.includes('Individual'))).toBe(false);
  69  |       expect(g.nodes.every((n: { displayName: string }) => !!n.displayName)).toBe(true);
  70  |       expect(g.edges.some((e: { amount: number | null }) => e.amount != null && e.amount > 0)).toBe(true);
  71  |     });
  72  |   }
  73  | });
  74  | 
  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');
      |                                ^ Error: expect(locator).not.toHaveAttribute(expected) failed
  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);
  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 | 
```