docs/TESTING.md
Testing
Tranzfer's whole value is surviving failure, so the tests exist to cause failure and watch what the product does about it. A green suite that never cut a network cable proves nothing.
The philosophy comes from the executor reference checkout (~/dev/tranzfer2-references/apps/executor/e2e). We took the ideas and left its machinery behind. RELIABILITY.md says what must survive. This file says how we prove it.
New test files still need explicit user approval under AGENTS.md. This file explains how tests get written once they're approved. It doesn't grant approval.
Layers
Each layer answers one question. Don't ask a layer a question it can't answer.
| Layer | Tool | Answers |
|---|---|---|
| Types and lint | vp check | Is this code possible, and does it respect the boundaries? |
| Service tests | @effect/vitest through vp run test | Do the API services keep their rules on a real local D1, with storage faked? |
| Staging checks | vp run test:e2e (vitest in e2e/) | Does a deployed staging or PR preview build honor the API contract end to end? |
| Scenarios | Playwright inside e2e/ (planned) | Does a real user journey survive real failure against the running stack? |
| Release gates | People, real files, real networks | Does 10, 100 or 350 GB actually make it? The table in RELIABILITY.md decides |
Pure tests
Part-size policy, reconciliation planning, fingerprint versions, state transitions and schema round trips are functions. Hand them values and check the values that come back. "Local says parts 1 to 40 are done and ListParts returned these three pages, so upload 38 and 41 onward" is a pure test. It needs no network, no bucket and no mock.
Name tests after the guarantee they protect: recovery_never_resends_confirmed_parts, lost_finalize_response_converges_to_complete, process_exit_does_not_abort_remote_upload. Import from @effect/vitest, never vitest. Put the test file next to the code it covers.
Reactivity tests
Solid's dev build reports known anti-patterns as stable diagnostic codes, and @solidjs/diagnostics records them together with why each reactive node re-ran. captureArtifact(() => interaction, { scenario }) runs an interaction against a mounted tree and returns both. Assert on it:
expect(artifact).toHaveNoDiagnostics()fails on any rule code.assertBudget(artifact, { allow: [], maxReruns, maxWastedRuns })caps how much recomputes.artifact.attribution.rerunsnames each run (nodeName), whether its value changed, and the write that caused it. Give the memos you care about anameoption so they read in the report.- A run with
changed: falsewas wasted. Fix the cause (an unstable memo output, a read that is too wide) instead of raising the budget.
apps/web/src/dashboard/dashboard.test.tsx shows both shapes. One progress tick must recompute only the moving row and the grouping check. Cancel must leave Ready before a held server reply, and fail if the optimistic write is removed. Its fake server is RpcTest.makeClient(Api) over in-memory handlers, so the page runs its real typed client. Call flush() before reading the DOM after a write; Solid updates it asynchronously.
No R2 fake
We don't build an in-memory R2. R2 has rules a fake would get subtly wrong: uniform part sizes, the 10,000-part cap, incomplete uploads expiring, ListParts pagination, the exact ETag it hands back. A fake that gets one of those wrong makes the tests pass and the product lie. Pure logic doesn't need a fake, and wire behavior gets tested against the real thing.
Browsers upload through presigned URLs, and a presigned URL has to point at a real S3 endpoint. So scenarios already talk to real R2. Use small files, like three 5 MiB parts plus a tail, generated deterministically in the run directory. Big files belong to the release gates.
Staging checks locally
vp run test:e2e targets staging by default. Against a running vp run dev, point it at your Portless URL and trust the Portless CA:
| 1 | E2E_BASE_URL=https://tranzfer.localhost NODE_EXTRA_CA_CERTS=~/.portless/ca.pem TEST_LOGIN_KEY=<key from .env> vp run test:e2e |
The key must match TEST_LOGIN_KEY in the .env that alchemy dev loaded. A worktree uses its own host, such as https://<branch>.tranzfer.localhost.
Scenarios
Planned, not built. e2e/ today holds only the fetch-based staging checks; the Browser, NetControl and Storage services below, the parts ledger and scenario() don't exist yet. Build them with the resume milestone, which is the first thing that needs them.
A scenario is one user-meaningful journey, run black box against the local stack at https://tranzfer.localhost. It uses the real Workers and your dev stage's real D1 and R2.
| 1 | scenario( |
| 2 | "Recovery · a refresh mid-upload resumes without resending confirmed parts", |
| 3 | { timeout: 180_000 }, |
| 4 | Effect.gen(function* () { |
| 5 | const target = yield* Target; |
| 6 | const browser = yield* Browser; |
| 7 | const net = yield* NetControl; |
| 8 | const storage = yield* Storage; |
| 9 | // ... |
| 10 | }), |
| 11 | ); |
These rules don't bend:
scenario()is the only way to write one. Its body is an Effect, and the services it yields are its declaration of what it needs. There's no separate capability list.- Every scenario gets a fresh identity from
target.newIdentity(). Isolation comes from new users, never from resetting shared state. - Assert only through the typed RPC client, the browser, or storage the app really uses. Never import app internals. Never poke D1 to make a test pass.
- Clean up with
Effect.ensuring, so a failure halfway through doesn't leak uploads. Where possible, clean up through the product's own cancel and delete paths. - No sleeps. Wait for a network event, a navigation or a visible state.
- Assert values, not booleans.
expect(partCount(842)).toBe(1)explains the failure.expect(ok).toBe(true)explains nothing. - Names read as product guarantees: "Recovery · a lost finalize response still converges to complete".
- A failing assertion means the product or the scenario is wrong. Fix one of them. Never weaken, skip or retry-loop an assertion into green. A flaky scenario is a bug.
Each run writes e2e/runs/<slug>/ with result.json, a Playwright trace, video and failure.png. Git ignores it. When you hand off work that changes user-visible behavior, include the run directory and what to look at.
Services
| Service | Gives a scenario |
|---|---|
Target | Base URL and newIdentity() |
Api | The typed Effect RPC client from packages/contracts, signed in as an identity |
Browser | Playwright sessions with trace, video and step screenshots |
Browser.persistentSession | A persistent profile, so IndexedDB survives closing and relaunching the browser. This is how levels 3 and 4 get tested |
NetControl | Offline and online, plus failing, delaying or erroring chosen signing and part requests |
Storage | ListParts and HeadObject against the bucket the app signs for, plus the parts ledger |
The parts ledger is the most important thing in the harness. Part bytes go straight from the browser to R2, so the API never sees them. The browser surface records every page request to the R2 host as partNumber -> count. That's how a scenario proves part 842 went over the wire exactly once. It turns "avoidable bytes resent" from a metric into an assertion.
Identity
Sign-in is Google only, and scenarios don't go through Google. Outside production, when TEST_LOGIN_KEY is set, the API registers POST /api/auth/staging-login (apps/api/src/infrastructure/staging-login.ts). It checks the key, then creates the staging user and a real session. Every request after that goes through real session validation. Production never registers the route.
One scenario checks that sign-in redirects to Google with the right callback URL. The real OAuth round trip gets checked by hand against the deployment.
Why Playwright directly
Vitest browser mode runs test code inside an iframe next to a rendered component. It's a component testing tool. It can't toggle offline mid-scenario, intercept routes on the fly, relaunch a persistent profile, or drive a separately served app without fighting its own orchestrator page. Those four things are the whole harness. If Solid component tests ever outgrow jsdom and @solidjs/testing-library, browser mode is where they go next. It's not where e2e goes.
CI
vp check and vp run test gate every PR. The API service tests in apps/api/test run on wrangler's local D1 (@tranzfer/db/testing) with an in-memory Storage; use TestClock for anything time-based. Scenarios stay out of the PR gate until they prove fast and stable against the local stack. Then they get their own workflow.
PR previews (.github/workflows/preview.yml) run the PR branch's own deploy code with the staging environment's Cloudflare token and TEST_LOGIN_KEY. Fork PRs are skipped, so this is safe while everyone with write access is trusted. Before adding outside collaborators, require approval on the staging environment or give previews a token scoped to pr-* resources.