# 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/serving.spec.ts >> collection roots >> a collection without its trailing slash redirects relatively to the index page
- Location: tests/ui/serving.spec.ts:86:7

# Error details

```
Error: /candidates Location

expect(received).toBe(expected) // Object.is equality

Expected: "/candidates/"
Received: "http://10.1.21.163/candidates/"
```

# Test source

```ts
  1   | import { test, expect } from '@playwright/test';
  2   | import { canonicalPath, toRelativePath } from './helpers';
  3   | 
  4   | /*
  5   |  * How pages, sitemaps and assets are served (electinfo/sites#210, #211).
  6   |  *
  7   |  * Preview has no NFS since sites#258: entity pages come from the us-pages renderer (candidates, committees and the
  8   |  * small linkable collections) or the baked client shells (leaf collections); sitemaps are read from the beta-pages
  9   |  * bucket and /images/, /geo/ from the static-assets bucket, over HTTP. Production still serves the NFS files until its
  10  |  * own Phase 4. The contracts below hold on both targets; the renderer checks run on preview only.
  11  |  */
  12  | 
  13  | const PREVIEW = /preview/.test(process.env.BASE_URL || '');
  14  | 
  15  | const BIDEN = 'ffd8f1ef-a47c-ad4f-d23a-b4bbc0b92fd3'; // P80000722, President
  16  | const PELOSI = 'bfcafdd4-62da-f107-22fb-ff5413c33fa4'; // H8CA05035, House CA-11
  17  | const DNC = 'a75a9110-f1db-a945-39a8-7503ed3c04da'; // C00010603
  18  | const LEAD_BUCKETS = ['0-9', ...'abcdefghijklmnopqrstuvwxyz'];
  19  | 
  20  | const locs = (xml: string) => [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1] as string);
  21  | const count = (haystack: string, needle: string) => haystack.split(needle).length - 1;
  22  | const sectionIds = (html: string) => [...html.matchAll(/<section id="([^"]+)"/g)].map((m) => m[1]);
  23  | 
  24  | test.describe('sitemaps', () => {
  25  |   for (const collection of ['candidates', 'committees']) {
  26  |     test(`${collection}: the index lists one file per lead character, each a urlset`, async ({ request }) => {
  27  |       const res = await request.get(`/${collection}/sitemap-${collection}.xml`);
  28  |       expect(res.status()).toBe(200);
  29  |       expect(res.headers()['content-type']).toContain('xml');
  30  |       const xml = await res.text();
  31  |       expect(xml).toContain('<sitemapindex');
  32  |       const children = locs(xml).map(toRelativePath);
  33  |       expect(children).toEqual(LEAD_BUCKETS.map((b) => `/${collection}/sitemap-${collection}-${b}.xml`));
  34  |       for (const child of [children[1], children[children.length - 1]] as string[]) {
  35  |         const c = await request.get(child);
  36  |         expect(c.status(), child).toBe(200);
  37  |         const body = await c.text();
  38  |         expect(body).toContain('<urlset');
  39  |         for (const loc of locs(body)) expect(toRelativePath(loc)).toMatch(new RegExp(`^/${collection}/[^/]+/$`));
  40  |       }
  41  |     });
  42  |   }
  43  | 
  44  |   test('a single-file collection sitemap is a urlset of its own pages', async ({ request }) => {
  45  |     const res = await request.get('/parties/sitemap-parties.xml');
  46  |     expect(res.status()).toBe(200);
  47  |     const all = locs(await res.text());
  48  |     expect(all.length).toBeGreaterThan(50);
  49  |     for (const loc of all) expect(toRelativePath(loc)).toMatch(/^\/parties\/[^/]+\/$/);
  50  |   });
  51  | 
  52  |   test('a sitemap that does not exist is 404', async ({ request }) => {
  53  |     for (const path of ['/candidates/sitemap-nope.xml', '/candidates/sitemap-candidates-zz.xml']) {
  54  |       expect((await request.get(path)).status(), path).toBe(404);
  55  |     }
  56  |   });
  57  | });
  58  | 
  59  | test.describe('static assets', () => {
  60  |   test('state images are SVG with CORS and caching headers', async ({ request }) => {
  61  |     const res = await request.get('/images/states/ak.svg');
  62  |     expect(res.status()).toBe(200);
  63  |     const h = res.headers();
  64  |     expect(h['content-type']).toContain('image/svg+xml');
  65  |     expect(h['access-control-allow-origin']).toBe('*');
  66  |     expect(h['cache-control']).toContain('max-age=');
  67  |     expect(await res.text()).toContain('<svg');
  68  |   });
  69  | 
  70  |   test('district GeoJSON is application/geo+json and parses', async ({ request }) => {
  71  |     const res = await request.get('/geo/districts/al/al-01.geojson');
  72  |     expect(res.status()).toBe(200);
  73  |     expect(res.headers()['content-type']).toContain('application/geo+json');
  74  |     const geo = await res.json();
  75  |     expect(['FeatureCollection', 'Feature']).toContain(geo.type);
  76  |   });
  77  | 
  78  |   test('a missing asset is 404', async ({ request }) => {
  79  |     for (const path of ['/images/states/zz.svg', '/geo/districts/zz/zz-99.geojson']) {
  80  |       expect((await request.get(path)).status(), path).toBe(404);
  81  |     }
  82  |   });
  83  | });
  84  | 
  85  | test.describe('collection roots', () => {
  86  |   test('a collection without its trailing slash redirects relatively to the index page', async ({ request }) => {
  87  |     for (const collection of ['candidates', 'committees', 'parties']) {
  88  |       const res = await request.get(`/${collection}`, { maxRedirects: 0 });
  89  |       expect(res.status(), `/${collection}`).toBe(301);
> 90  |       expect(res.headers()['location'], `/${collection} Location`).toBe(`/${collection}/`);
      |                                                                    ^ Error: /candidates Location
  91  |       expect((await request.get(`/${collection}/`)).status(), `/${collection}/`).toBe(200);
  92  |     }
  93  |   });
  94  | });
  95  | 
  96  | test.describe('rendered candidate and committee pages (us-pages)', () => {
  97  |   test.skip(!PREVIEW, 'Candidate and committee pages are rendered on preview only until production Phase 4 (sites#211)');
  98  | 
  99  |   test('a candidate page is rendered for the current graph build', async ({ request }) => {
  100 |     const res = await request.get(`/candidates/${BIDEN}/`);
  101 |     expect(res.status()).toBe(200);
  102 |     expect(res.headers()['x-graph-build'], 'the renderer stamps its build key').toBeTruthy();
  103 |     const html = await res.text();
  104 |     expect(canonicalPath(html)).toMatch(/^\/candidates\/[a-z0-9-]+-p80000722\/$/);
  105 |     expect(sectionIds(html).slice(0, 3)).toEqual(['profile', 'timeline', 'funding']);
  106 |     expect(html).not.toContain('Supporting Committees'); // rundeck#779
  107 |     expect(count(html, '<script src="/js/money-flow.js" defer></script>')).toBe(1);
  108 |     expect(count(html, '<script src="/js/entity-graph.js" defer></script>')).toBe(1);
  109 |     const generated = /Generated (\d{4}-\d{2}-\d{2})/.exec(html)?.[1];
  110 |     expect(generated, 'footer date').toBeTruthy();
  111 |     expect(Date.now() - Date.parse(generated as string), 'rendered within the cache lifetime').toBeLessThan(4 * 86_400_000);
  112 |     const rows = (/<section id="funding">[\s\S]*?<\/section>/.exec(html)?.[0] ?? '').match(/<tr>/g)?.length ?? 0;
  113 |     expect(rows - 1, 'Top Contributors rows (minus the header)').toBeGreaterThan(0);
  114 |     expect(rows - 1).toBeLessThanOrEqual(10);
  115 |   });
  116 | 
  117 |   test('/politicians/<id>/ is the candidate page', async ({ request }) => {
  118 |     const a = await request.get(`/candidates/${BIDEN}/`);
  119 |     const b = await request.get(`/politicians/${BIDEN}/`);
  120 |     expect(b.status()).toBe(200);
  121 |     expect(canonicalPath(await b.text())).toBe(canonicalPath(await a.text()));
  122 |   });
  123 | 
  124 |   test('a House candidate links its party, office and district', async ({ request }) => {
  125 |     const res = await request.get(`/candidates/${PELOSI}/`);
  126 |     expect(res.status()).toBe(200);
  127 |     const html = await res.text();
  128 |     for (const [label, collection] of [
  129 |       ['Party', 'parties'],
  130 |       ['Office', 'offices'],
  131 |       ['District', 'districts'],
  132 |     ]) {
  133 |       expect(html, `${label} link`).toMatch(new RegExp(`<dt>${label}</dt><dd><a href="[^"]*/${collection}/[^"]+/">`));
  134 |     }
  135 |   });
  136 | 
  137 |   test('a committee page lists its money and its party affinity', async ({ request }) => {
  138 |     const res = await request.get(`/committees/${DNC}/`);
  139 |     expect(res.status()).toBe(200);
  140 |     const html = await res.text();
  141 |     expect(canonicalPath(html)).toMatch(/^\/committees\/[a-z0-9-]+-c00010603\/$/);
  142 |     const ids = sectionIds(html);
  143 |     expect(ids.slice(0, 5)).toEqual(['overview', 'financials', 'connected', 'supported', 'funded']);
  144 |     expect(ids.slice(-2)).toEqual(['outgoing', 'incoming']);
  145 |     expect(html).toContain('<dt>Party Affinity</dt>');
  146 |     for (const id of ['funded', 'outgoing', 'incoming']) {
  147 |       const section = new RegExp(`<section id="${id}">[\\s\\S]*?</section>`).exec(html)?.[0] ?? '';
  148 |       const items = section.match(/<li>/g)?.length ?? 0;
  149 |       expect(items, `#${id} items`).toBeLessThanOrEqual(20);
  150 |     }
  151 |   });
  152 | 
  153 |   test('an upper-case path renders with the lower-case canonical', async ({ request }) => {
  154 |     const res = await request.get(`/CANDIDATES/${BIDEN.toUpperCase()}/`, { maxRedirects: 0 });
  155 |     expect(res.status()).toBe(200);
  156 |     expect(canonicalPath(await res.text())).toMatch(/^\/candidates\/[a-z0-9-]+-p80000722\/$/);
  157 |   });
  158 | 
  159 |   test('unknown candidate, committee and party paths are 404 without a static fallback', async ({ request }) => {
  160 |     for (const path of ['/candidates/no-such-entity-zz/', '/committees/no-such-entity-zz/', '/parties/no-such-party-zz/']) {
  161 |       const started = Date.now();
  162 |       const res = await request.get(path, { maxRedirects: 0 });
  163 |       expect(res.status(), path).toBe(404);
  164 |       expect(Date.now() - started, `${path} answers promptly`).toBeLessThan(5000);
  165 |     }
  166 |   });
  167 | 
  168 |   test('the money-flow Sankey and the Graph button work on a rendered candidate page', async ({ page }) => {
  169 |     const errors: string[] = [];
  170 |     page.on('pageerror', (e) => errors.push(String(e)));
  171 |     await page.goto(`/candidates/${PELOSI}/`);
  172 |     await expect(page.locator('h1')).toContainText('PELOSI');
  173 |     await expect(page.locator('#funding .money-flow-chart canvas')).toHaveCount(1, { timeout: 15000 });
  174 |     await expect(page.locator('button.eg-open')).toHaveCount(1, { timeout: 15000 });
  175 |     expect(errors).toEqual([]);
  176 |   });
  177 | });
  178 | 
```