# 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: 30-clock-payroll.spec.ts >> manager correction and approval leads to owner browser CSV download and immutable replay
- Location: e2e-scheduling-real/30-clock-payroll.spec.ts:53:5

# Error details

```
TimeoutError: locator.click: Timeout 20000ms exceeded.
Call log:
  - waiting for getByRole('region', { name: 'Timesheet detail' }).getByRole('row').filter({ has: getByRole('cell', { name: 'clock in', exact: true }) }).getByRole('button', { name: 'Edit punch', exact: true })

```

# Test source

```ts
  1   | import { test, expect, fixture, api, login, dbRows, evidence, API, storePath, runDir } from './helpers';
  2   | import fs from 'node:fs';
  3   | import path from 'node:path';
  4   | import { createHash } from 'node:crypto';
  5   | 
  6   | // Isolated synthetic clock sessions use real server time, never browser date mocks.
  7   | test('staff clocks and breaks through real buttons with durable idempotent replay', async ({ page, request }) => {
  8   |   await api(request, 'owner', 'PATCH', '/schedule/settings', { clock_in_early_minutes: 120, clock_in_late_minutes: 120, clock_out_late_minutes: 120 });
  9   |   const initial = await api(request, 'staff', 'GET', '/my/clock');
  10  |   const now = new Date(initial.server_time);
  11  |   const startsAt = new Date(now.getTime() - 60_000).toISOString();
  12  |   const endsAt = new Date(now.getTime() + 60 * 60_000).toISOString();
  13  |   const shift = await api(request, 'owner', 'POST', '/schedule/shifts', {
  14  |     store_membership_id: fixture.actors.staff.memberships.a, starts_at: startsAt, ends_at: endsAt, notes: fixture.marker + ' live clock',
  15  |   }, 201);
  16  |   const label = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Los_Angeles' }).format(now);
  17  |   const week = await api(request, 'owner', 'GET', `/schedule/week?week_start=${label}`);
  18  |   await api(request, 'owner', 'POST', '/schedule/publish', { week_start: week.week_start });
  19  |   let firstClockRequest: { data: unknown; key: string } | undefined;
  20  |   page.on('request', r => {
  21  |     if (!firstClockRequest && r.method() === 'POST' && r.url().endsWith('/my/clock')) {
  22  |       firstClockRequest = { data: r.postDataJSON(), key: r.headers()['idempotency-key'] };
  23  |     }
  24  |   });
  25  |   await login(page, 'staff', '/staff/clock');
  26  |   await expect(page.getByRole('button', { name: 'Clock in', exact: true })).toBeEnabled();
  27  |   let lastSecond = Math.floor(now.getTime() / 1000);
  28  |   async function nextSecond() {
  29  |     await expect.poll(async () => {
  30  |       const clock = await api(request, 'staff', 'GET', '/my/clock');
  31  |       const second = Math.floor(new Date(clock.server_time).getTime() / 1000);
  32  |       if (second > lastSecond) { lastSecond = second; return true; }
  33  |       return false;
  34  |     }, { intervals: [150, 250, 500] }).toBe(true);
  35  |   }
  36  |   for (const [button, status] of [['Clock in', 'clocked_in'], ['Start break', 'on_break'], ['End break', 'clocked_in'], ['Clock out', 'clocked_out']]) {
  37  |     await nextSecond();
  38  |     await page.getByRole('button', { name: button, exact: true }).click();
  39  |     await expect.poll(async () => (await api(request, 'staff', 'GET', '/my/clock')).status).toBe(status);
  40  |   }
  41  |   expect(firstClockRequest?.key).toBeTruthy();
  42  |   const replayedClock = await api(request, 'staff', 'POST', '/my/clock', firstClockRequest!.data, 200, 'a', firstClockRequest!.key);
  43  |   expect(replayedClock.status).toBe('clocked_in');
  44  |   const clock = await api(request, 'staff', 'GET', '/my/clock');
  45  |   expect(clock.status).toBe('clocked_out');
  46  |   expect(clock.punches.map((p: any) => p.punch_type).sort()).toEqual(['break_end', 'break_start', 'clock_in', 'clock_out']);
  47  |   expect(dbRows('time_punches').filter((p: any) => !p.deleted_at)).toHaveLength(4);
  48  |   const clockIn = clock.punches.find((p: any) => p.punch_type === 'clock_in');
  49  |   await evidence('clock-real-buttons', { shift, clock, sql: dbRows('time_punches') });
  50  | 
  51  | });
  52  | 
  53  | test('manager correction and approval leads to owner browser CSV download and immutable replay', async ({ request, browser }) => {
  54  |   const week = { week_start: '2026-05-04' }, label = '2026-05-04';
  55  |   const startsAt = '2026-05-04T16:00:00Z';
  56  |   const clockIn = await api(request, 'manager', 'POST', '/punches', {
  57  |     store_membership_id: fixture.actors.manager.memberships.a,
  58  |     punch_type: 'clock_in', punched_at: startsAt, note: fixture.marker + ' correction setup',
  59  |   }, 201);
  60  |   await api(request, 'manager', 'POST', '/punches', {
  61  |     store_membership_id: fixture.actors.manager.memberships.a,
  62  |     punch_type: 'clock_out', punched_at: '2026-05-04T17:00:00Z', note: fixture.marker + ' correction setup',
  63  |   }, 201);
  64  | 
  65  |   const context = await browser.newContext({ timezoneId: 'America/Los_Angeles' });
  66  |   const manager = await context.newPage();
  67  |   try {
  68  |     await login(manager, 'manager', '/seller/timesheets');
  69  |     await manager.getByLabel('Week containing').fill(week.week_start);
  70  |     await manager.getByRole('button', { name: 'Recalculate week', exact: true }).click();
  71  |     await manager.getByRole('button', { name: fixture.actors.manager.name, exact: true }).click();
  72  |     const detail = manager.getByRole('region', { name: 'Timesheet detail' });
  73  |     const row = detail.getByRole('row').filter({ has: manager.getByRole('cell', { name: 'clock in', exact: true }) });
> 74  |     await row.getByRole('button', { name: 'Edit punch', exact: true }).click();
      |                                                                        ^ TimeoutError: locator.click: Timeout 20000ms exceeded.
  75  |     const corrected = new Date(new Date(clockIn.punched_at).getTime() - 60 * 60_000);
  76  |     const parts = new Intl.DateTimeFormat('sv-SE', { timeZone: 'America/Los_Angeles', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(corrected).replace(' ', 'T');
  77  |     await manager.getByLabel(/Punch time/).fill(parts);
  78  |     await manager.getByLabel('Correction note', { exact: true }).fill(fixture.marker + ' correct missed first hour');
  79  |     await manager.getByRole('button', { name: 'Save correction', exact: true }).click();
  80  |     await expect(manager.getByLabel('Correction note', { exact: true })).toHaveCount(0);
  81  |     let sheets = await api(request, 'manager', 'GET', `/timesheets?week_start=${week.week_start}`);
  82  |     let sheet = sheets.find((t: any) => t.store_membership_id === fixture.actors.manager.memberships.a);
  83  |     expect(sheet).not.toHaveProperty('total_pay_cents');
  84  |     await manager.getByRole('button', { name: 'Recalculate week', exact: true }).click();
  85  |     await expect.poll(async () => {
  86  |       sheets = await api(request, 'manager', 'GET', `/timesheets?week_start=${week.week_start}`);
  87  |       sheet = sheets.find((t: any) => t.store_membership_id === fixture.actors.manager.memberships.a);
  88  |       return sheet.regular_minutes;
  89  |     }).toBe(120);
  90  |     await api(request, 'manager', 'GET', '/payroll/exports', undefined, 403);
  91  |     await api(request, 'owner', 'GET', `/timesheets/${sheet.id}`, undefined, 404, 'b');
  92  |     await manager.getByRole('button', { name: 'Approve timesheet', exact: true }).click();
  93  |     await expect(manager.getByRole('button', { name: 'Unlock timesheet', exact: true })).toBeVisible();
  94  |     sheet = await api(request, 'owner', 'GET', `/timesheets/${sheet.id}`);
  95  |     expect(sheet.status).toBe('approved');
  96  |     expect(sheet.total_pay_cents).toBe(sheet.regular_minutes * 40);
  97  |     // Zero-hour coworker sheets are still real rows and must also be approved.
  98  |     for (const other of sheets.filter((t: any) => t.id !== sheet.id)) await api(request, 'manager', 'POST', `/timesheets/${other.id}/approve`, {});
  99  |     await evidence('manager-correction-approval', { sheet, sql: dbRows('timesheets'), punches: dbRows('time_punches') });
  100 | 
  101 |     const ownerContext = await browser.newContext();
  102 |     const owner = await ownerContext.newPage();
  103 |     try {
  104 |       await login(owner, 'owner', '/seller/payroll');
  105 |       await owner.getByLabel(/First week|Start week|Week starting|Week start/i).first().fill(week.week_start);
  106 |       // Export controls are finalized with the production UI; no API substitute for this action.
  107 |       const lastWeek = owner.getByLabel(/Last week|End week/i);
  108 |       if (await lastWeek.count()) await lastWeek.fill(week.week_start);
  109 |       const exportRequest = owner.waitForRequest(r => r.method() === 'POST' && r.url().endsWith('/payroll/exports'));
  110 |       await owner.getByRole('button', { name: 'Create CSV export', exact: true }).click();
  111 |       const sent = await exportRequest;
  112 |       const key = sent.headers()['idempotency-key'];
  113 |       expect(key).toBeTruthy();
  114 |       const body = sent.postDataJSON();
  115 |       const downloadPromise = owner.waitForEvent('download');
  116 |       await owner.getByRole('button', { name: /Download/i }).first().click();
  117 |       const download = await downloadPromise;
  118 |       const file = path.join(runDir, 'payroll-download.csv');
  119 |       await download.saveAs(file);
  120 |       fs.chmodSync(file, 0o600);
  121 |       const csv = fs.readFileSync(file);
  122 |       expect(csv.toString()).toContain(fixture.actors.manager.name);
  123 |       const history = await api(request, 'owner', 'GET', '/payroll/exports');
  124 |       expect(history).toHaveLength(1);
  125 |       const replay = await api(request, 'owner', 'POST', '/payroll/exports', body, 200, 'a', key);
  126 |       expect(replay.id).toBe(history[0].id);
  127 |       const response = await request.get(API + storePath(`/payroll/exports/${replay.id}/download`), { headers: { Authorization: ['Bearer', fixture.actors.owner.token].join(' ') } });
  128 |       expect(response.status()).toBe(200);
  129 |       expect(await response.body()).toEqual(csv);
  130 |       await api(request, 'owner', 'POST', '/payroll/exports', { week_starts: ['2026-01-05'] }, 409, 'a', key);
  131 |       await api(request, 'manager', 'POST', `/timesheets/${sheet.id}/unlock`, {}, 409);
  132 |       const freshPunch = (await api(request, 'manager', 'GET', `/punches?store_membership_id=${fixture.actors.manager.memberships.a}&from=${week.week_start}&to=${label}`)).find((p: any) => p.id === clockIn.id);
  133 |       await api(request, 'manager', 'PATCH', `/punches/${clockIn.id}`, { revision: freshPunch.revision, note: 'must not mutate exported history', punched_at: startsAt }, 409);
  134 |       expect(dbRows('payroll_exports')).toHaveLength(1);
  135 |       await evidence('payroll-browser-download-replay', { export: replay, csv_sha256: createHash('sha256').update(csv).digest('hex'), csv_bytes: csv.length, sql: dbRows('payroll_exports') });
  136 |     } finally { await ownerContext.close(); }
  137 |   } finally { await context.close(); }
  138 | });
  139 | 
```