# 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/visitor-token.spec.ts >> visitor-token gate >> GET /api/token sets an HttpOnly token and a readable expiry
- Location: tests/ui/visitor-token.spec.ts:28:7

# Error details

```
Error: expect(received).toMatch(expected)

Expected pattern: /^ei_vt_exp=\d+\.(anon|member|premium); Path=\/;/
Received string:  "ei_vt_exp=1792006338.anon.fff; Path=/; Max-Age=900; SameSite=Strict"
```

# Test source

```ts
  1   | import { test, expect, request as playwrightRequest, type APIRequestContext } from '@playwright/test';
  2   | 
  3   | // Visitor-token gate on /api/graphql (electinfo/sites#277, njs/visitor_token.js):
  4   | // GET /api/token hands every visitor a short-lived token in an HttpOnly cookie;
  5   | // /api/graphql refuses calls without one (401) and lets anon and member tokens
  6   | // send only the site's own persisted documents (403 otherwise). Skipped on a
  7   | // target without the gate (the production config until its cutover).
  8   | 
  9   | // A persisted document (block.ts, precinct.ts and tract.ts send it).
  10  | const LISTED = 'query StateCounties($state: String!) { counties(where: { state: { eq: $state } }, sort: [{ name: ASC }], limit: 500) { id name state state_fips county_fips } }';
  11  | const UNLISTED = '{ counties(limit: 1) { id name } }';
  12  | 
  13  | /** A request context with an empty cookie jar, so a token from one probe does not leak into the next. */
  14  | async function freshContext(baseURL: string | undefined): Promise<APIRequestContext> {
  15  |   return playwrightRequest.newContext({ baseURL });
  16  | }
  17  | 
  18  | async function hasGate(baseURL: string | undefined): Promise<boolean> {
  19  |   const probe = await freshContext(baseURL);
  20  |   try {
  21  |     return (await probe.get('/api/token')).status() === 204;
  22  |   } finally {
  23  |     await probe.dispose();
  24  |   }
  25  | }
  26  | 
  27  | test.describe('visitor-token gate', () => {
  28  |   test('GET /api/token sets an HttpOnly token and a readable expiry', async ({ request }) => {
  29  |     const res = await request.get('/api/token');
  30  |     // Without the gate the path falls through to the site (404 or a page); a 5xx is a broken gate, not a skip.
  31  |     test.skip(res.status() !== 204 && res.status() < 500, 'this target has no visitor-token gate');
  32  |     expect(res.status()).toBe(204);
  33  |     expect(res.headers()['cache-control']).toContain('no-store');
  34  |     const cookies = res.headersArray().filter(h => h.name.toLowerCase() === 'set-cookie').map(h => h.value);
  35  |     const token = cookies.find(c => c.startsWith('ei_vt='));
  36  |     const exp = cookies.find(c => c.startsWith('ei_vt_exp='));
  37  |     expect(token).toMatch(/^ei_vt=[\w-]+\.[\w-]+\.[\w-]+; Path=\/api\/; Max-Age=\d+; HttpOnly; SameSite=Strict/);
> 38  |     expect(exp).toMatch(/^ei_vt_exp=\d+\.(anon|member|premium); Path=\/;/);
      |                 ^ Error: expect(received).toMatch(expected)
  39  |     expect(exp).not.toContain('HttpOnly');
  40  |   });
  41  | 
  42  |   test('without a token, /api/graphql answers 401', async ({ request, baseURL }) => {
  43  |     test.skip(!(await hasGate(baseURL)), 'this target has no visitor-token gate');
  44  |     const res = await request.post('/api/graphql', { data: { query: LISTED, variables: { state: 'VT' } } });
  45  |     expect(res.status()).toBe(401);
  46  | 
  47  |     const forged = await request.post('/api/graphql', {
  48  |       data: { query: LISTED, variables: { state: 'VT' } },
  49  |       headers: { Cookie: 'ei_vt=eyJhbGciOiJIUzI1NiJ9.eyJ0aWVyIjoicHJlbWl1bSJ9.AAAA' },
  50  |     });
  51  |     expect(forged.status()).toBe(401);
  52  |   });
  53  | 
  54  |   test('an anon token runs persisted documents and nothing else', async ({ request, baseURL }) => {
  55  |     test.skip(!(await hasGate(baseURL)), 'this target has no visitor-token gate');
  56  |     // The token goes into this context's cookie jar (Path=/api/), as in a browser.
  57  |     expect((await request.get('/api/token')).status()).toBe(204);
  58  | 
  59  |     const listed = await request.post('/api/graphql', { data: { query: LISTED, variables: { state: 'VT' } } });
  60  |     expect(listed.status()).toBe(200);
  61  |     const body = await listed.json() as { data?: { counties?: Array<{ state: string }> } };
  62  |     expect(body.data?.counties?.length).toBeGreaterThan(0);
  63  | 
  64  |     // Whitespace does not matter: the gate hashes the collapsed document.
  65  |     const reflowed = await request.post('/api/graphql', {
  66  |       data: { query: LISTED.replace(/ \{ /g, ' {\n    '), variables: { state: 'VT' } },
  67  |     });
  68  |     expect(reflowed.status()).toBe(200);
  69  | 
  70  |     const unlisted = await request.post('/api/graphql', { data: { query: UNLISTED } });
  71  |     expect(unlisted.status()).toBe(403);
  72  |     expect(await unlisted.json()).toEqual({ error: 'query not allowed' });
  73  | 
  74  |     const batch = await request.post('/api/graphql', {
  75  |       data: [{ query: LISTED, variables: { state: 'VT' } }, { query: UNLISTED }],
  76  |     });
  77  |     expect(batch.status(), 'a batch is refused when any document is unlisted').toBe(403);
  78  |   });
  79  | });
  80  | 
  81  | // Donor records are premium (sites#277): an anonymous visitor sees the premium
  82  | // notice instead of donors; the employer's totals stay public.
  83  | test.describe('donor records are premium', () => {
  84  |   test.beforeEach(async ({ baseURL }) => {
  85  |     test.skip(!(await hasGate(baseURL)), 'this target has no visitor-token gate');
  86  |   });
  87  | 
  88  |   test('an anonymous visitor gets the premium notice on Individual Donors', async ({ page }) => {
  89  |     await page.goto('/individuals/');
  90  |     await expect(page.locator('.premium-required')).toBeVisible({ timeout: 30_000 });
  91  |     await expect(page.locator('.entity-search-result')).toHaveCount(0);
  92  |   });
  93  | 
  94  |   test('an anonymous visitor sees employer totals but not donor names', async ({ page }) => {
  95  |     await page.goto('/employers/5f9a1727-2225-3699-685b-8fb15c17c8f3/');
  96  |     await expect(page.locator('#activity')).toBeVisible({ timeout: 30_000 });
  97  |     await expect(page.locator('#donors .premium-required')).toBeVisible();
  98  |     await expect(page.locator('#donors a[href^="/individuals/"]')).toHaveCount(0);
  99  |   });
  100 | });
  101 | 
```