# 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/vendor-purposes.spec.ts >> vendor purpose card — sortable headers >> clicking Group sorts alphabetically ascending, then descending
- Location: tests/ui/vendor-purposes.spec.ts:272:7

# Error details

```
Error: page.goto: net::ERR_CONNECTION_REFUSED at http://10.1.105.22/vendors/vendor-purpose-test/
Call log:
  - navigating to "http://10.1.105.22/vendors/vendor-purpose-test/", waiting until "load"

```

# Test source

```ts
  175 |     const last = JSON.parse(purposeBodies[purposeBodies.length - 1]);
  176 |     expect(last.variables?.cycle).toBe(2024);
  177 |   });
  178 | 
  179 |   test('switching back to All cycles sends cycle: null', async ({ page }) => {
  180 |     const graphqlBodies = await installMocks(page);
  181 | 
  182 |     await page.goto(`/vendors/${VENDOR_ID}/`);
  183 |     const select = page.locator('#purposes select[aria-label="Purpose cycle"]');
  184 |     await expect(select).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });
  185 | 
  186 |     await select.selectOption('2024');
  187 |     await expect(page.locator('#purposes [role="status"]')).toHaveText('', { timeout: DATA_LOAD_TIMEOUT });
  188 | 
  189 |     await select.selectOption('');
  190 |     await expect(page.locator('#purposes [role="status"]')).toHaveText('', { timeout: DATA_LOAD_TIMEOUT });
  191 | 
  192 |     const last = JSON.parse(graphqlBodies.filter(b => b.includes('VendorPurposeFlow')).pop()!);
  193 |     expect(last.variables?.cycle ?? null).toBeNull();
  194 |   });
  195 | });
  196 | 
  197 | test.describe('vendor purpose card — filter input', () => {
  198 |   test.beforeEach(async ({ page }) => {
  199 |     await page.addInitScript(() => localStorage.setItem('electinfo:money-flow-view', 'table'));
  200 |   });
  201 |   test('filter input has aria-label "Filter purpose groups"', async ({ page }) => {
  202 |     await installMocks(page);
  203 | 
  204 |     await page.goto(`/vendors/${VENDOR_ID}/`);
  205 |     await expect(page.locator('#purposes tbody tr')).toHaveCount(3, { timeout: DATA_LOAD_TIMEOUT });
  206 | 
  207 |     await expect(page.locator('#purposes input[aria-label="Filter purpose groups"]')).toBeVisible();
  208 |   });
  209 | 
  210 |   test('typing in the filter narrows visible rows by group name', async ({ page }) => {
  211 |     await installMocks(page);
  212 | 
  213 |     await page.goto(`/vendors/${VENDOR_ID}/`);
  214 |     await expect(page.locator('#purposes tbody tr')).toHaveCount(3, { timeout: DATA_LOAD_TIMEOUT });
  215 | 
  216 |     await page.locator('#purposes input[aria-label="Filter purpose groups"]').fill('Media');
  217 |     await expect(page.locator('#purposes tbody tr')).toHaveCount(1);
  218 |     await expect(page.locator('#purposes tbody tr').first()).toContainText('Media Buying');
  219 |   });
  220 | 
  221 |   test('filter matches topPurpose text as well as the group name', async ({ page }) => {
  222 |     await installMocks(page);
  223 | 
  224 |     await page.goto(`/vendors/${VENDOR_ID}/`);
  225 |     await expect(page.locator('#purposes tbody tr')).toHaveCount(3, { timeout: DATA_LOAD_TIMEOUT });
  226 | 
  227 |     await page.locator('#purposes input[aria-label="Filter purpose groups"]').fill('DIGITAL ADS');
  228 |     await expect(page.locator('#purposes tbody tr')).toHaveCount(1);
  229 |     await expect(page.locator('#purposes tbody tr').first()).toContainText('Digital');
  230 |   });
  231 | 
  232 |   test('no match shows the empty message and zero rows', async ({ page }) => {
  233 |     await installMocks(page);
  234 | 
  235 |     await page.goto(`/vendors/${VENDOR_ID}/`);
  236 |     await expect(page.locator('#purposes tbody tr')).toHaveCount(3, { timeout: DATA_LOAD_TIMEOUT });
  237 | 
  238 |     await page.locator('#purposes input[aria-label="Filter purpose groups"]').fill('xyzzy');
  239 |     await expect(page.locator('#purposes tbody tr')).toHaveCount(0);
  240 |     await expect(page.locator('#purposes .purpose-empty')).toBeVisible();
  241 |   });
  242 | });
  243 | 
  244 | test.describe('vendor purpose card — sortable headers', () => {
  245 |   test.beforeEach(async ({ page }) => {
  246 |     await page.addInitScript(() => localStorage.setItem('electinfo:money-flow-view', 'table'));
  247 |   });
  248 |   test('sortable headers Group, Amount, %, Payments, Clients are present', async ({ page }) => {
  249 |     await installMocks(page);
  250 | 
  251 |     await page.goto(`/vendors/${VENDOR_ID}/`);
  252 |     await expect(page.locator('#purposes thead')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });
  253 | 
  254 |     for (const label of ['Group', 'Amount', '%', 'Payments', 'Clients']) {
  255 |       await expect(
  256 |         page.locator('#purposes thead th button').filter({ hasText: label }),
  257 |         `"${label}" sort button missing`,
  258 |       ).toHaveCount(1);
  259 |     }
  260 |   });
  261 | 
  262 |   test('default sort is Amount descending — Media Buying first', async ({ page }) => {
  263 |     await installMocks(page);
  264 | 
  265 |     await page.goto(`/vendors/${VENDOR_ID}/`);
  266 |     await expect(page.locator('#purposes tbody tr')).toHaveCount(3, { timeout: DATA_LOAD_TIMEOUT });
  267 | 
  268 |     await expect(page.locator('#purposes tbody tr').first()).toContainText('Media Buying');
  269 |     await expect(page.locator('#purposes thead th[data-sort="amount"]')).toHaveAttribute('aria-sort', 'descending');
  270 |   });
  271 | 
  272 |   test('clicking Group sorts alphabetically ascending, then descending', async ({ page }) => {
  273 |     await installMocks(page);
  274 | 
> 275 |     await page.goto(`/vendors/${VENDOR_ID}/`);
      |                ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://10.1.105.22/vendors/vendor-purpose-test/
  276 |     await expect(page.locator('#purposes tbody tr')).toHaveCount(3, { timeout: DATA_LOAD_TIMEOUT });
  277 | 
  278 |     await page.locator('#purposes thead th[data-sort="group"] button').click();
  279 |     const rows = page.locator('#purposes tbody tr');
  280 |     await expect(rows.first()).toContainText('Consulting');
  281 |     await expect(page.locator('#purposes thead th[data-sort="group"]')).toHaveAttribute('aria-sort', 'ascending');
  282 | 
  283 |     await page.locator('#purposes thead th[data-sort="group"] button').click();
  284 |     await expect(rows.first()).toContainText('Media Buying');
  285 |     await expect(page.locator('#purposes thead th[data-sort="group"]')).toHaveAttribute('aria-sort', 'descending');
  286 |   });
  287 | 
  288 |   test('clicking the active Amount header flips from desc to asc and back', async ({ page }) => {
  289 |     await installMocks(page);
  290 | 
  291 |     await page.goto(`/vendors/${VENDOR_ID}/`);
  292 |     await expect(page.locator('#purposes tbody tr')).toHaveCount(3, { timeout: DATA_LOAD_TIMEOUT });
  293 | 
  294 |     // amount is active desc; clicking it (same key, desc → asc)
  295 |     await page.locator('#purposes thead th[data-sort="amount"] button').click();
  296 |     await expect(page.locator('#purposes thead th[data-sort="amount"]')).toHaveAttribute('aria-sort', 'ascending');
  297 |     // asc: Consulting (1M) first
  298 |     await expect(page.locator('#purposes tbody tr').first()).toContainText('Consulting');
  299 | 
  300 |     // click again → back to desc: Media Buying (6M) first
  301 |     await page.locator('#purposes thead th[data-sort="amount"] button').click();
  302 |     await expect(page.locator('#purposes thead th[data-sort="amount"]')).toHaveAttribute('aria-sort', 'descending');
  303 |     await expect(page.locator('#purposes tbody tr').first()).toContainText('Media Buying');
  304 |   });
  305 | 
  306 |   test('non-active headers show aria-sort="none"', async ({ page }) => {
  307 |     await installMocks(page);
  308 | 
  309 |     await page.goto(`/vendors/${VENDOR_ID}/`);
  310 |     await expect(page.locator('#purposes thead')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });
  311 | 
  312 |     // amount is the active sort; group, share, count, clients are none
  313 |     await expect(page.locator('#purposes thead th[data-sort="group"]')).toHaveAttribute('aria-sort', 'none');
  314 |     await expect(page.locator('#purposes thead th[data-sort="share"]')).toHaveAttribute('aria-sort', 'none');
  315 |   });
  316 | });
  317 | 
  318 | test.describe('vendor purpose card — expandable group detail', () => {
  319 |   test.beforeEach(async ({ page }) => {
  320 |     await page.addInitScript(() => localStorage.setItem('electinfo:money-flow-view', 'table'));
  321 |   });
  322 |   test('each row has a details/summary element with the group name', async ({ page }) => {
  323 |     await installMocks(page);
  324 | 
  325 |     await page.goto(`/vendors/${VENDOR_ID}/`);
  326 |     await expect(page.locator('#purposes tbody tr')).toHaveCount(3, { timeout: DATA_LOAD_TIMEOUT });
  327 | 
  328 |     const rows = page.locator('#purposes tbody tr');
  329 |     for (let i = 0; i < 3; i++) {
  330 |       await expect(rows.nth(i).locator('details')).toHaveCount(1);
  331 |     }
  332 |     // first row by default sort is Media Buying
  333 |     await expect(rows.first().locator('details summary')).toContainText('Media Buying');
  334 |   });
  335 | 
  336 |   test('expanding details reveals topPurposes for that group', async ({ page }) => {
  337 |     await installMocks(page);
  338 | 
  339 |     await page.goto(`/vendors/${VENDOR_ID}/`);
  340 |     await expect(page.locator('#purposes tbody tr')).toHaveCount(3, { timeout: DATA_LOAD_TIMEOUT });
  341 | 
  342 |     const firstDetails = page.locator('#purposes tbody tr').first().locator('details');
  343 |     await firstDetails.locator('summary').click();
  344 |     await expect(firstDetails).toHaveAttribute('open', '');
  345 |     await expect(page.locator('#purposes .purpose-raw-row')).toContainText('MEDIA BUY');
  346 |     await expect(page.locator('#purposes .purpose-raw-row')).toContainText('AD PLACEMENT');
  347 |   });
  348 | });
  349 | 
  350 | test.describe('vendor purpose card — Sankey / Table toggle', () => {
  351 |   test('Sankey is the default view with a canvas visible', async ({ page }) => {
  352 |     const errors: string[] = [];
  353 |     page.on('pageerror', e => errors.push(String(e)));
  354 |     await installMocks(page);
  355 | 
  356 |     await page.goto(`/vendors/${VENDOR_ID}/`);
  357 |     await expect(page.locator('#purposes .money-flow-chart canvas')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });
  358 |     await expect(page.locator('#purposes .mf-view-btn[data-view="sankey"]')).toHaveAttribute('aria-pressed', 'true');
  359 |     await expect(page.locator('#purposes .purpose-table-view')).toBeHidden();
  360 |     expect(errors).toEqual([]);
  361 |   });
  362 | 
  363 |   test('Table button shows the table and hides the chart', async ({ page }) => {
  364 |     await installMocks(page);
  365 | 
  366 |     await page.goto(`/vendors/${VENDOR_ID}/`);
  367 |     await expect(page.locator('#purposes .money-flow-chart canvas')).toBeVisible({ timeout: DATA_LOAD_TIMEOUT });
  368 | 
  369 |     await page.locator('#purposes .mf-view-btn[data-view="table"]').click();
  370 |     await expect(page.locator('#purposes .purpose-table-view')).toBeVisible();
  371 |     await expect(page.locator('#purposes .money-flow-chart')).toBeHidden();
  372 |     await expect(page.locator('#purposes .mf-view-btn[data-view="table"]')).toHaveAttribute('aria-pressed', 'true');
  373 |   });
  374 | 
  375 |   test('switching back to Sankey restores the canvas', async ({ page }) => {
```