# 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/leaf-details.spec.ts >> leaf collection client-side detail pages >> hub vendor lists its ranked top clients with a top-N-of-P footer and Sankey (sites#261)
- Location: tests/ui/leaf-details.spec.ts:277: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.75/vendors/hub-ranked/", waiting until "load"

```

# Test source

```ts
  226 |       const id = JSON.parse(body).variables?.id;
  227 |       if (body.includes('VendorPurposeFlow')) {
  228 |         await route.fulfill({ json: { data: { vendorPurposeFlow: {
  229 |           taxonomyVersion: null, totalAmount: 0, groups: [], links: [], otherLinks: [],
  230 |         } } } });
  231 |         return;
  232 |       }
  233 |       await route.fulfill({
  234 |         status: 200,
  235 |         contentType: 'application/json',
  236 |         body: JSON.stringify({
  237 |           data: {
  238 |             vendors: id === 'hub-vendor-id' ? [{
  239 |               id: 'hub-vendor-id',
  240 |               name: 'HUB VENDOR',
  241 |               name_normalized: 'hub vendor',
  242 |               city: 'AUSTIN',
  243 |               state: 'TX',
  244 |               total_received: 987654321,
  245 |               payment_count: 75000,
  246 |               purpose_categories: 'MEDIA',
  247 |               client_count: 50000,
  248 |               first_payment: '2020-01-01',
  249 |               last_payment: '2026-01-01',
  250 |               cycles: '2020,2022,2024,2026',
  251 |             }] : [],
  252 |           },
  253 |         }),
  254 |       });
  255 |     });
  256 | 
  257 |     const response = await page.goto('/vendors/consulting/hub-alias/');
  258 |     expect(response?.status()).toBe(200);
  259 |     await expect(page.locator('.leaf-detail-page h1')).toHaveText('HUB VENDOR', { timeout: DATA_LOAD_TIMEOUT });
  260 |     await expect(page.locator('.leaf-hub-deferred')).toContainText('50,000 related records');
  261 |     await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
  262 |       'href', 'https://us.elect.info/vendors/hub-canonical/'
  263 |     );
  264 |     // Alias/canonical profiles and bounded ranked/purpose reads are allowed.
  265 |     // The hub guard must still suppress both live-client query implementations.
  266 |     expect(graphqlBodies.filter(body => body.includes('VendorProfile'))).toHaveLength(2);
  267 |     expect(graphqlBodies.filter(body => body.includes('VendorRankedClients'))).toHaveLength(1);
  268 |     const purposeReads = graphqlBodies.filter(body => body.includes('VendorPurposeFlow'));
  269 |     expect(purposeReads).toHaveLength(1);
  270 |     expect(JSON.parse(purposeReads[0]).variables).toEqual({ id: 'hub-vendor-id', cycle: null });
  271 |     expect(graphqlBodies.some(body => body.includes('"id":"hub-vendor-id"'))).toBe(true);
  272 |     expect(graphqlBodies.every(body => !body.includes('committeesPaysConnection'))).toBe(true);
  273 |     expect(graphqlBodies.every(body => !body.includes('moneyCounterparties'))).toBe(true);
  274 |     await expect(page.locator('#purposes .purpose-raw')).toBeVisible();
  275 |   });
  276 | 
  277 |   test('hub vendor lists its ranked top clients with a top-N-of-P footer and Sankey (sites#261)', async ({ page }) => {
  278 |     const graphqlBodies: string[] = [];
  279 |     await page.route('**/_resolve/**', route => route.fulfill({
  280 |       status: 200,
  281 |       contentType: 'application/json',
  282 |       body: JSON.stringify({ entity_id: 'hub-ranked', canonical_path: '/vendors/hub-ranked/' }),
  283 |     }));
  284 |     await page.route('**/api/graphql', async route => {
  285 |       const body = route.request().postData() || '';
  286 |       graphqlBodies.push(body);
  287 |       const data = body.includes('VendorRankedClients')
  288 |         ? {
  289 |             vendorClients: {
  290 |               ranked: true,
  291 |               clientCount: 61204,
  292 |               totalReceived: 987654321,
  293 |               otherCount: 61202,
  294 |               otherAmount: 900000000,
  295 |               clients: [
  296 |                 {
  297 |                   committeeId: 'c-dem', name: 'DEM VICTORY FUND', committeeType: 'N', party: 'DEM',
  298 |                   affinityDem: null, affinityRep: null, amount: 60000000, count: 1200, purposes: 'MEDIA BUY', rank: 1,
  299 |                 },
  300 |                 {
  301 |                   committeeId: 'c-rep', name: 'REP LEADERSHIP PAC', committeeType: 'Q', party: null,
  302 |                   affinityDem: 0.05, affinityRep: 0.95, amount: 27654321, count: 640, purposes: 'DIGITAL, CONSULTING', rank: 2,
  303 |                 },
  304 |               ],
  305 |             },
  306 |           }
  307 |         : {
  308 |             vendors: [{
  309 |               id: 'hub-ranked',
  310 |               name: 'RANKED HUB VENDOR',
  311 |               name_normalized: 'ranked hub vendor',
  312 |               city: 'AUSTIN',
  313 |               state: 'TX',
  314 |               total_received: 987654321,
  315 |               payment_count: 75000,
  316 |               purpose_categories: 'MEDIA',
  317 |               client_count: 61204,
  318 |               first_payment: '2020-01-01',
  319 |               last_payment: '2026-01-01',
  320 |               cycles: '2020,2022,2024,2026',
  321 |             }],
  322 |           };
  323 |       await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data }) });
  324 |     });
  325 | 
> 326 |     const response = await page.goto('/vendors/hub-ranked/');
      |                                 ^ Error: page.goto: net::ERR_ABORTED; maybe frame was detached?
  327 |     expect(response?.status()).toBe(200);
  328 |     await expect(page.locator('.leaf-detail-page h1')).toHaveText('RANKED HUB VENDOR', { timeout: DATA_LOAD_TIMEOUT });
  329 |     await expect(page.locator('.leaf-hub-deferred')).toHaveCount(0);
  330 |     await expect(page.locator('#clients .leaf-list-footer')).toHaveText('Showing top 2 of 61,204 committees');
  331 |     await expect(page.locator('#clients .money-flow-chart canvas')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });
  332 | 
  333 |     await page.locator('#clients .mf-view-btn[data-view="table"]').click();
  334 |     const rows = page.locator('#clients .leaf-relationship-list tbody tr');
  335 |     await expect(rows).toHaveCount(2);
  336 |     await expect(rows.first()).toContainText('DEM VICTORY FUND');
  337 |     await expect(rows.first()).toContainText('MEDIA BUY');
  338 |     await expect(page.locator('#clients .leaf-list-footer')).toBeVisible();
  339 |     await page.locator('#clients .mf-view-btn[data-view="sankey"]').click();
  340 | 
  341 |     expect(graphqlBodies.every(body => !body.includes('committeesPaysConnection'))).toBe(true);
  342 |     expect(graphqlBodies.filter(body => body.includes('VendorRankedClients'))).toHaveLength(1);
  343 |   });
  344 | 
  345 |   test('individual Top Committees: one row per committee and a distinct-committee footer (sites#194)', async ({ page }) => {
  346 |     const graphqlBodies: string[] = [];
  347 |     await page.route('**/_resolve/**', route => route.fulfill({ status: 404, body: '' }));
  348 |     await page.route('**/api/graphql', async route => {
  349 |       const body = route.request().postData() || '';
  350 |       graphqlBodies.push(body);
  351 |       const data = body.includes('IndividualCommittees')
  352 |         ? {
  353 |             moneyCounterparties: {
  354 |               deferred: false,
  355 |               totalCount: 35,
  356 |               counterparties: [
  357 |                 // summed over the pair's per-cycle edges by the resolver (rundeck#758)
  358 |                 { id: 'c-1', name: 'FIRST VICTORY FUND', committeeType: 'N', amount: 12500, count: 7 },
  359 |                 { id: 'c-2', name: 'SECOND PAC', committeeType: 'Q', amount: 900, count: null },
  360 |               ],
  361 |             },
  362 |           }
  363 |         : {
  364 |             individuals: [{
  365 |               id: 'ind-194', name: 'DOE, JANE', city: 'AUSTIN', state: 'TX', zip_code: '78701',
  366 |               employer: 'SELF', occupation: 'WRITER', total_contributions: 13400, contribution_count: 40,
  367 |               committee_count: 35, first_contribution: '2020-01-01', last_contribution: '2024-10-01', cycles: '2020,2024',
  368 |               employedByEmployersConnection: { edges: [] },
  369 |               residesInPlacesConnection: { edges: [] },
  370 |               residesInPostalCodesConnection: { edges: [] },
  371 |             }],
  372 |           };
  373 |       await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data }) });
  374 |     });
  375 | 
  376 |     const response = await page.goto('/individuals/ind-194/');
  377 |     expect(response?.status()).toBe(200);
  378 |     await expect(page.locator('.leaf-detail-page h1')).toHaveText('DOE, JANE', { timeout: DATA_LOAD_TIMEOUT });
  379 |     const rows = page.locator('#committees .leaf-relationship-list tbody tr');
  380 |     await expect(rows).toHaveCount(2);
  381 |     await expect(rows.first()).toContainText('FIRST VICTORY FUND');
  382 |     await expect(rows.first()).toContainText('$12,500');
  383 |     await expect(rows.nth(1).locator('td').nth(3)).toHaveText('—');
  384 |     await expect(page.locator('#committees .leaf-list-footer')).toHaveText('Showing top 2 of 35 committees');
  385 |     const committees = graphqlBodies.find(body => body.includes('IndividualCommittees')) || '';
  386 |     expect(committees).toContain('relationship: CONTRIBUTED_TO, direction: OUT, first: 20');
  387 |     expect(graphqlBodies.every(body => !body.includes('contributedToCommitteesConnection'))).toBe(true);
  388 |   });
  389 | 
  390 |   test('vendor live clients come from moneyCounterparties when the graph has no ranks (sites#194)', async ({ page }) => {
  391 |     const graphqlBodies: string[] = [];
  392 |     await page.route('**/_resolve/**', route => route.fulfill({ status: 404, body: '' }));
  393 |     await page.route('**/api/graphql', async route => {
  394 |       const body = route.request().postData() || '';
  395 |       graphqlBodies.push(body);
  396 |       let data: unknown;
  397 |       if (body.includes('VendorRankedClients')) {
  398 |         data = { vendorClients: { ranked: false, clientCount: null, totalReceived: null, otherCount: null, otherAmount: null, clients: [] } };
  399 |       } else if (body.includes('VendorClientList')) {
  400 |         data = {
  401 |           moneyCounterparties: {
  402 |             deferred: false,
  403 |             totalCount: 3,
  404 |             counterparties: [
  405 |               { id: 'c-a', name: 'ALPHA FOR CONGRESS', committeeType: 'H', party: 'DEM', affinityDem: null, affinityRep: null,
  406 |                 amount: 5000, count: 4, purposes: 'MEDIA,PRINTING' },
  407 |               { id: 'c-b', name: 'BETA PAC', committeeType: 'Q', party: null, affinityDem: 0.2, affinityRep: 0.8,
  408 |                 amount: 3000, count: 2, purposes: null },
  409 |             ],
  410 |           },
  411 |         };
  412 |       } else {
  413 |         data = {
  414 |           vendors: [{
  415 |             id: 'vendor-194', name: 'LIVE VENDOR', name_normalized: 'live vendor', city: 'DALLAS', state: 'TX',
  416 |             total_received: 9000, payment_count: 7, purpose_categories: 'MEDIA', client_count: 3,
  417 |             first_payment: '2020-01-01', last_payment: '2024-01-01', cycles: '2020,2024',
  418 |           }],
  419 |         };
  420 |       }
  421 |       await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data }) });
  422 |     });
  423 | 
  424 |     const response = await page.goto('/vendors/vendor-194/');
  425 |     expect(response?.status()).toBe(200);
  426 |     await expect(page.locator('.leaf-detail-page h1')).toHaveText('LIVE VENDOR', { timeout: DATA_LOAD_TIMEOUT });
```