Write automated tests for the error tracking service in this repo. Treat the acceptance criteria below as the spec. Use Vitest for the fingerprint, source map, mail, and job functions and for the API routes against a real on-disk SQLite file, and Playwright for the example browser page and the dashboard. Every test file gets its own temp data directory (`DATA_DIR`) with a fresh `app.db`, a `files/` folder, and an `outbox/` folder. Mock only the Slack webhook (`fetch` to `hooks.slack.com`) and, in one test, the SMTP transport. Nothing else talks to the network. Record one real Slack Block Kit payload and one `.eml` from the outbox as fixtures.

## Acceptance criteria to cover

1. Two ingests with the same `TypeError`, same function names, and hashed files `main-8f3a2c.js` and `main-11ab2c.js` with different columns produce one issue with `event_count = 2`.
2. Same frames with class `RangeError` produce a second issue. Messages `"order 12"` and `"order 99"` with the same class and frames stay in one issue with `message_template = "order #"`. An event with no frames groups by class plus template.
3. After uploading a source map for release `1.0.0`, a minified frame resolves to `src/checkout.ts:42` in `applyDiscount`, `culprit` equals `src/checkout.ts in applyDiscount`, and the fingerprint matches an event from a second build of the same source with a different hash and its own map. The map file exists under `<DATA_DIR>/files/source_maps/`, `signFileUrl` produces a URL that returns the file, and the same URL with an expired signature returns 403. A path containing `..` returns 400.
4. Five events from two distinct `user.id` values give `user_count = 2` and two `issue_users` rows. Events with no user leave `user_count` unchanged.
5. A metadata key `password` is stored as `[REDACTED]` at any depth; a 300 KB body returns 413; a wrong API key returns 401; a body with no `exceptions` returns 400 naming the field; the 100,001st event of a UTC day returns 429 and increments `dropped`.
6. The first event for a fingerprint sends one Slack post and writes one `.eml` per address to the outbox. The second event sends nothing. A `staging` event sends nothing when `alert_environments = ["production"]`. A Slack webhook that throws does not change the ingest response.
7. Resolve, then ingest again: `status = open`, `regressed_at` set, one regression alert. Resolve in `1.2.0`, ingest from `1.1.9`: still resolved, no alert. Ingest from `1.2.1`: regressed with one alert. A second regression inside 60 minutes sends nothing.
8. 200 session starts and 3 unhandled events with distinct `sessionId` values on release `1.3.0` give stability `98.5`. Two unhandled events with one `sessionId` count once. A handled event never counts as a crash.
9. Twenty concurrent first events for one fingerprint (`Promise.all`) produce one issue, `event_count = 20`, and exactly one `new_issue` log row per channel. A batch POST of 100 events returns 100 ids, and a batch whose 50th event is malformed stores nothing (the transaction rolled back).
10. With `retention_days = 7` and events backdated 10 days, the retention job deletes them except the pinned first and last event per issue. `event_count`, `user_count`, and the 30-day chart data are unchanged. Running the job twice deletes nothing the second time.
11. Playwright on the example page: clicking "Crash" sends a payload whose breadcrumbs include the click and the earlier navigation, in order. Clicking "Reject" sends an `UnhandledRejection` event. Clicking "Handled" sends `handled: true`.
12. The example Express app: a throwing route produces an event with `request.url`, `request.method`, no `cookie` header, and `user.id` from `req.user`. Spawn the app as a child process, trigger an `uncaughtException`, and assert the server received the event before the process exited with code 1.
13. Playwright on the dashboard: the issues list filters by `status`, `environment`, and `q`, sorts by each column, and pages at 50. With the browser timezone set to `Asia/Kolkata`, "last seen" shows the local time and the tooltip shows UTC. Ingest one event while the page is open and assert the row's event count changes without a navigation.
14. Snooze "until 100 more events": the 99th event keeps `status = snoozed`; the 100th sets `open` and sends one regression alert.
15. Fresh clone: start the app with an empty `DATA_DIR` and no env vars. `GET /` redirects to `/setup`. Submitting the setup form creates one `users` row with `role = admin`, sets the session cookie, and `/setup` returns 404 afterwards. With `ADMIN_EMAIL` and `ADMIN_PASSWORD` set instead, the admin exists after boot and `/setup` is already 404.
16. `docker compose up -d` on a clean Ubuntu VPS serves HTTPS. This can't run in CI. Write `scripts/smoke.sh <domain>` that curls `https://<domain>/api/health`, fails on a bad certificate or a non-200, and document it in the README. Add a `test.skip` with that reason so the criterion shows in the report.
17. Job lock: with a fake clock, insert a `job_locks` row for `retention` held by `other-host:1` with `expires_at` 10 minutes ahead and assert the job returns without deleting anything. Move the clock past `expires_at`, run, and assert the deletes happened and the lock is cleared. Then simulate a crash: make the second batch throw, assert the lock is still held, advance the clock 31 minutes, run again, and assert the remaining backdated events are gone and the total deleted equals the backdated count. For `digest`, run it twice in one UTC day and assert one `.eml` per user and one `digest_log` row per project.
18. Backup round-trip: seed the temp database, run `pnpm backup` with `DATA_DIR` pointed at it, delete `app.db` and `files/`, restore from the newest backup, and assert every table's row count matches and one issue matches field by field. Assert a 15th backup prunes the oldest.
19. Outbox: with `SMTP_URL` unset, trigger a new-issue alert on an email channel with two addresses and assert two `.eml` files appear in `<DATA_DIR>/outbox` with the subject and the issue link in the body. With `SMTP_URL=smtp://user:pass@localhost:2525`, mock `nodemailer.createTransport` and assert `sendMail` is called twice and the outbox stays empty.
20. An `eventId` posted twice within 24 hours stores one event and returns the same ids both times.
21. `occurredAt` 10 minutes in the future is replaced with `received_at` and `metadata._clockSkew = true`.

## What to mock and what to keep real

- Keep SQLite real. Grouping, the `changes === 1` insert detection, and the retention batches only mean something against the real database file. `tests/helpers/db.ts` creates `<tmp>/app.db`, runs the checked-in migrations, and returns the db plus `DATA_DIR`. Delete the directory in `afterAll`.
- Mock the Slack webhook by stubbing global `fetch` for URLs that start with `https://hooks.slack.com/`. Assert on the Block Kit body, not just the call count.
- Do not mock email. Read `<DATA_DIR>/outbox` and parse the `.eml` for `To`, `Subject`, and the issue link. Mock `nodemailer.createTransport` only in criterion 19.
- Do not mock `@jridgewell/trace-mapping`. Use a real map from the fixture build.
- Do not mock the file system. Source maps and the outbox are real files under the temp `DATA_DIR`.
- Use a throwaway `X-Api-Key` per test project so parallel test files don't share issues.

## Layout

- `tests/unit/fingerprint.test.ts`: table-driven cases `{ name, exceptions, groupingKey, expectSameAs | expectDifferentFrom }` for 1, 2, and the path normalization rules (origin, hash, `webpack:///`, Windows paths, `node_modules`).
- `tests/unit/sourcemaps.test.ts`: criterion 3 with a real map generated from a tiny fixture project in `fixtures/sourcemap-build/`.
- `tests/unit/mail.test.ts`: criterion 19. `tests/unit/files.test.ts`: the signed URL half of criterion 3.
- `tests/integration/`: API routes against the temp SQLite file for 4 through 10, 14, 15, 20, and 21. `tests/integration/jobs.test.ts` for 10 and 17 with an injected clock. `tests/integration/backup.test.ts` for 18 (needs the `sqlite3` CLI, which GitHub's Ubuntu runners have). Freeze time with `vi.useFakeTimers()` or the injected clock for 7, 10, 17, and 21.
- `tests/e2e/`: Playwright for 11, 12, and 13. Point the example page and Express app at the test server with a seeded project and a signed-in admin.
- `tests/sdk-size.test.ts`: fails if either SDK source file exceeds 150 lines.

## Rules

- Name every test after its criterion: `test("AC17: an expired lock is taken and a crashed run finishes on the next run")`.
- Each integration test creates its own project and truncates nothing shared. Tests must pass when run in parallel and in random order; one SQLite file per test file makes that cheap.
- Never sleep for real time. Use the fake clock for snooze expiry, the regression cap, the lock TTL, and the retention cutoff.
- Build fixtures in `fixtures/`: a V8 stack, a Firefox stack, a Safari stack, a Node stack with `node_modules` frames, a Windows path stack, and one payload for each SDK. Use them in the unit tests so both SDK parsers are covered.
- Tests must run in CI without a browser UI. Add `pnpm test` and a GitHub Actions workflow. No services needed: SQLite is a file, the outbox is a folder, and Slack is a stubbed `fetch`.
- Run the suite. Fix the app where the app is wrong and the test where the test is wrong. Report per-criterion pass/fail and what you changed.
