Write automated tests for the automation runner in this repo. Treat the acceptance criteria below as the spec. Use Vitest for unit and integration tests against an on-disk temporary SQLite file per test file (create it under `os.tmpdir()`, run the migrations, delete it on teardown; point `DATA_DIR` at the same temp folder so notes, attachments, and the outbox land there too), and Playwright for the applet editor flow. No database server, no containers. Mock outbound HTTP with `msw` or `nock`, and mock only the outside services the app is allowed to talk to: Slack, Discord, ntfy, Pushover, the Google Sheets client, the nodemailer SMTP transport, and the OpenAI-compatible endpoint, each at the module boundary so the suite runs offline. Start the real `smtp-server` receiver on a random port for the email tests and deliver to it with nodemailer as the client. Run the real worker loop in-process against the test database for queue and scheduler tests, with an injected clock.

## Acceptance criteria to cover

1. `POST /api/hooks/<id>/<token>` returns 202 with a run id in under 50ms and the event payload has `body`, `headers`, and `query`. Wrong token returns 404 and inserts nothing. A disabled applet returns 200 with `queued: false`.
2. `renderTemplate`: dot paths, bracket paths, every filter (`upper`, `lower`, `trim`, `truncate`, `default`, `json`, `strip_html`, `slug`, `date` with timezone, `urlencode`), chained filters, missing path renders empty with a warning, `\{{` escapes, objects render as JSON.
3. Schedule `0 9 * * *` in `America/New_York` produces fire times of 13:00 UTC on 2026-03-07 and 14:00 UTC on 2026-03-09. With the clock advanced 3 hours past one fire time, the scheduler creates exactly one event, and a second tick creates none.
4. RSS: first poll inserts no runs; second poll with two new items inserts two runs oldest first; third poll with the same feed inserts none; a 304 inserts none and updates `last_polled_at`; a feed with no GUIDs dedupes on link.
5. Poll trigger: `changed` fires only on a differing value and the event carries `value` and `previous_value`. `lt` with target 100 over the sequence 120, 90, 80, 110, 70 fires exactly twice.
6. Inbound email: the three `.eml` fixtures delivered through the receiver normalize to the same field set, and the one without a `Message-ID` gets a generated one. A recipient matching `to_alias` creates a run; a recipient at `INBOUND_DOMAIN` matching no applet gets 250 and creates nothing; a recipient at another domain gets 550 at `RCPT TO`; the same `message_id` delivered twice creates one event. The attachment fixture writes the file under `DATA_DIR/files/events/<event_id>/` and its signed URL returns 200 before `exp` and 403 after.
7. Sandbox: `skip('x')` yields `skipped` and no action step; a thrown error yields `failed` with the message; `while (true) {}` fails within 2 seconds while a concurrent run for another applet still completes; `typeof fetch` and `typeof require` are `"undefined"` inside the sandbox; `set('k', 1)` shows up as `{{vars.k}}` in the action.
8. `http` action: a 500 response is retried with `next_attempt_at` offsets of 30s, 2m, 10m, 1h, 6h and the run is `dead` after attempt 5 fails; a 400 response marks the run `failed` on attempt 1 with no `next_attempt_at`.
9. Ordering: two queued runs for one applet never overlap (assert `started_at` of the second is after `finished_at` of the first); runs for two applets overlap when each action sleeps 500ms.
10. `sheets` action calls `values.append` once with the rendered columns in order, `valueInputOption: "USER_ENTERED"`, and the configured range.
11. `note` action writes under `NOTES_ROOT`, appends ` (2)` on collision, and rejects `../escape.md`, `/etc/passwd`, and a filename containing a null byte with a non-retryable error.
12. Secrets: `{{secrets.SLACK_WEBHOOK}}` resolves to the real value in the outbound request (assert on the mock) and appears as `[redacted]` in the stored step input.
13. Export then import into an empty database recreates every applet with identical ids, webhook tokens, and definitions.
14. `discord` action with 4,500 characters sends three requests in order, each `content` at most 2,000 characters, split on line breaks.
15. Fresh start: boot the app with an empty env and an empty `DATA_DIR`. `GET /` redirects to `/setup`, `POST /api/setup` creates the one user row and sets the session cookie, a second `POST /api/setup` returns 404, and `SESSION_SECRET` and `SECRETS_KEY` files exist under `DATA_DIR`.
16. Compose: `docker compose config` parses `docker-compose.yml` and the output has `app`, `worker`, `caddy`, and `backup` services, a shared `data` volume, and port 25 published to the worker. The HTTPS check on a real VPS stays manual; say so in the test name.
17. Job lock and crash safety: two worker instances started against the same database with the clock at a fire time create exactly one event and one run. A `job_locks` row with `expires_at` in the past is taken over by the next tick. A run left in `running` with `locked_at` 11 minutes old is requeued with the same `attempt` and completes once; the `http` mock sees one request per attempt, never two.
18. Backup: run `scripts/backup.sh` against the temp `DATA_DIR`, assert a file under `backups/`, delete `app.db`, copy the backup back, reopen, and assert every applet, event, run, and secret row count matches. Create 15 dated backup files and assert the script prunes to 14.
19. Outbox: with `SMTP_URL` unset, the `email` action succeeds, writes one `.eml` under `DATA_DIR/outbox/` whose `To`, `Subject`, and `Message-ID` match the rendered config, and the nodemailer transport mock is never called. With `SMTP_URL` set, the transport mock is called once and nothing is written.
20. Playwright: create a webhook-to-slack applet through the two-step editor, insert an ingredient with the dropdown, use Preview against the sample, save, then send a test event and watch the run go to `succeeded` on the run page through the SSE stream.

## Fixtures

- `fixtures/inbound/plain.eml`, `fixtures/inbound/with-attachment.eml`, `fixtures/inbound/no-message-id.eml`: same subject and text body, so the normalization test can compare them field by field. The attachment one carries a small PDF.
- `fixtures/feeds/rss.xml` and `fixtures/feeds/atom.xml`: five items each with stable GUIDs or ids and published dates spanning three days. `fixtures/feeds/rss-no-guid.xml` for the link fallback.
- `fixtures/poll/`: a JSON response per value in the sequence 120, 90, 80, 110, 70 under `data.price`.
- `fixtures/applets/`: one valid definition per trigger type and one invalid definition with an unknown key, used by the zod validation tests and the import test.
- `fixtures/export.json`: three applets with fixed ids and tokens for criterion 13.

## Layout

- `tests/unit/`: `template.test.ts` (criterion 2, table-driven), `sandbox.test.ts` (7), `chunk.test.ts` (14), `notes-path.test.ts` (11), `email-normalize.test.ts` (6, the mailparser part), `job-locks.test.ts` (17, the lock statement alone).
- `tests/integration/`: worker and API against the test database with a fake clock. Cover 1, 3, 4, 5, 6, 8, 9, 10, 12, 13, 15, 16, 17, 18, 19.
- `tests/e2e/`: Playwright for 20, with the Slack webhook mocked via an environment flag.
- `tests/helpers/`: `db.ts` (create and migrate a fresh SQLite file per test file and set `DATA_DIR`), `clock.ts` (the injected clock), `worker.ts` (start and stop one or more worker instances in-process), `smtp.ts` (start the receiver on port 0 and return a nodemailer client pointed at it), `http.ts` (the msw server with handlers for Slack, Discord, ntfy, Pushover, and a generic `/status/:code` endpoint).
- Also add a zod validation test file, `tests/unit/definition.test.ts`, that loads every fixture in `fixtures/applets/` and asserts the valid ones parse and the invalid one fails with the unknown key named in the error.

## Rules

- Name every test after its criterion: `test("AC8: 500 retries with backoff then dead after 5 attempts")`.
- Use `vi.useFakeTimers()` or the worker's injected clock for anything involving time. Never sleep for real except in criterion 9, and cap that at 2 seconds total.
- Use the fixtures in `fixtures/`. If any are missing, create realistic ones, including the `.eml` with an attachment and an Atom feed with `<id>` but no `<guid>`.
- Add `pnpm test` and a GitHub Actions workflow that runs unit and integration tests on push and the Playwright spec on pull requests. No services needed: everything runs against the temp SQLite file, the in-process receiver, and the mocks.
- Run the whole 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.
