Status: all six phases delivered, and a second round has since closed the coverage gaps the first one left. What actually landed, and where reality differed from the plan, is in Section 12; the gap-closing round is Section 13. The sections below are kept as written so the reasoning behind each decision stays readable.
| Suite | Command | Tests | Runtime |
|---|---|---|---|
| Unit + component | bun run test |
2079 | 12s |
| API integration | bun run test:api |
1015 | 92s |
| End to end | bun run test:e2e |
29 | 66s |
| Total | bun run test:all |
3123 |
OpenFrame is ~56k lines across 60 API route handlers, ~90 components and ~50 lib/
modules. Before this, every change was verified by hand. This document defines the stack,
the layout, the priority order, and the exact commands so that verification becomes
bun run test.
Short glossary, because this repo has no testing history:
- Unit test: calls one function directly with fixed inputs and asserts the return value. No database, no network, no browser. Runs in milliseconds.
- Integration test: exercises several real pieces together. Here that means calling an API route handler with a real request object against a real (test) Postgres, with only the session faked.
- E2E test: drives a real browser against a running app. Verifies what a user sees.
- Mock / stub: a fake stand-in for a dependency (
auth(), Stripe, S3). - Factory: a helper that inserts a realistic row into the test DB
(
createProject({ visibility: 'PUBLIC' })). - Fixture: a fixed input file or dataset a test reads from.
- Flaky test: passes and fails on the same code. Usually a timing bug in the test. Flaky tests are worse than no tests; fix or delete them, never retry them away.
- AAA: Arrange, Act, Assert. The shape every test in this repo should have.
The rule of thumb we follow: many unit tests, a solid layer of API integration tests, a handful of E2E tests, almost no component tests. Cost per test rises and stability falls as you go up that list.
| Layer | Tool | Why |
|---|---|---|
| Unit + API integration | Vitest 4 | Native ESM/TS, resolves the @/* alias via vite-tsconfig-paths, first-class module mocking (vi.mock) which we need for auth(), and multi-project config so node and jsdom suites live in one runner. |
| Component + hooks | @testing-library/react 16 + jsdom 29 | Standard for React 19. Gives us renderHook, which is what we actually want for the big hooks in components/video-page/hooks/. |
| E2E | Playwright 1.62 | Real Chromium/Firefox/WebKit, auto-waiting (kills most flakiness), trace viewer for debugging CI failures, official container image so it runs under podman. |
| Coverage | @vitest/coverage-v8 | Built in, no extra config. |
bun test: fast and already in the toolchain, but its jsdom/React story and Next.js module-mocking story are still thinner than Vitest's. We run Vitest with bun (bun run vitest), so we keep bun as the only package manager and task runner.- Jest: needs
next/jest, babel config and ESM workarounds. Strictly more setup for strictly less speed. - Mocked Prisma (
vitest-mock-extended/prismock): rejected for API tests. The bugs this repo actually produces are wrongwherefilters and missingORbranches (see thebuildBillingAccessWhereInputusage inapp/api/projects/route.ts). A mocked client asserts that we called Prisma, not that the query is correct, and the mock setup is more code than the test. A real Postgres in a container costs seconds. - MSW: not needed yet. External calls (Stripe, Bunny, R2) are reached through thin
wrappers in
lib/, sovi.mock('@/lib/stripe')is simpler than intercepting HTTP. Revisit only if we start testing client-side fetch flows in jsdom. - Cypress: Playwright is faster, has better parallelism and better container support.
- Snapshot tests: deliberately out of scope. They fail on every intentional markup change and assert nothing about behaviour.
There are no use server actions in this repo; all mutations go through
app/api/**/route.ts. That is good news: route handlers are plain exported functions, so
they can be imported and called directly in a test without a running server.
Conversely, async Server Components cannot be unit tested with Testing Library. Every
page.tsx in app/(dashboard) is therefore covered by E2E, not by component tests. This
is the single biggest reason the component layer stays thin.
tests/
unit/ # node env, no DB, no mocks
lib/
billing.test.ts
project-access.test.ts
validation.test.ts
...
api/ # node env, real test Postgres, auth() mocked
auth-matrix.test.ts # data-driven: no route returns 2xx unauthenticated
projects.test.ts
comments.test.ts
...
component/ # jsdom env
hooks/
use-watch-progress.test.ts
...
comment-rich-text.test.tsx
e2e/ # Playwright
auth.spec.ts
project-lifecycle.spec.ts
...
factories/ # test-DB row builders
index.ts
user.ts
project.ts
video.ts
helpers/
db.ts # truncate + connect
request.ts # NextRequest builders, route invocation
session.ts # session mock control
setup/
api.ts # per-file setup for the api project
component.ts # jsdom polyfills + jest-dom matchers
db-global.ts # global setup: migrate the test DB once
fixtures/
sample.mp4 # tiny (<100KB) media for upload paths
sample.png
Rationale for a top-level tests/ tree rather than colocated *.test.ts: it keeps app/
free of non-route files, makes the Docker build ignore rules trivial, and lets each layer
have its own environment without per-file pragmas.
Import style: no globals. Every test file does
import { describe, it, expect, vi } from 'vitest';. This keeps tsconfig.json
untouched and keeps bun run typecheck covering the test files, so a broken test is a
failed bun run check.
Naming: describe('functionName') / it('returns X when Y'). No "should".
Goal: bun run test runs and reports "no tests found" instead of erroring. Nothing is
tested yet; the wiring is done.
-
Add dev dependencies:
bun add -d vitest@^4.1.10 @vitest/coverage-v8@^4.1.10 \ @vitejs/plugin-react@^6.0.4 vite-tsconfig-paths@^6.1.1 \ jsdom@^29.1.1 @testing-library/react@^16.3.2 \ @testing-library/jest-dom@^7.0.0 @testing-library/user-event@^14.6.1(Playwright is added in Phase 3 so the browser download does not slow Phase 0.)
-
vitest.config.tsat the repo root, using Vitest 4projects:import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; import tsconfigPaths from 'vite-tsconfig-paths'; export default defineConfig({ plugins: [tsconfigPaths()], test: { projects: [ { extends: true, test: { name: 'unit', environment: 'node', include: ['tests/unit/**/*.test.ts'], }, }, { extends: true, test: { name: 'api', environment: 'node', include: ['tests/api/**/*.test.ts'], setupFiles: ['tests/setup/api.ts'], globalSetup: ['tests/setup/db-global.ts'], // One shared test database; parallel files would fight over TRUNCATE. fileParallelism: false, testTimeout: 20_000, }, }, { extends: true, plugins: [tsconfigPaths(), react()], test: { name: 'component', environment: 'jsdom', include: ['tests/component/**/*.test.{ts,tsx}'], setupFiles: ['tests/setup/component.ts'], }, }, ], }, });
-
package.jsonscripts:"test": "vitest run --project unit --project component", "test:watch": "vitest --project unit --project component", "test:api": "vitest run --project api", "test:e2e": "playwright test", "test:all": "bun run test && bun run test:api && bun run test:e2e", "test:coverage": "vitest run --project unit --coverage", "verify": "bun run check && bun run test", "test:db:up": "podman compose -f docker-compose.test.yml up -d --wait postgres-test", "test:db:down": "podman compose -f docker-compose.test.yml down -v"
testintentionally excludes theapiproject so the default command needs no infrastructure and stays instant.test:allis the full sweep. -
.prettierignore: addcoverage/,playwright-report/,test-results/. -
.gitignore: add/playwright-report,/test-results,/.playwright, and the exception!.env.test.example(the existing.env*rule would otherwise hide it). -
eslint.config.mjs: append an override fortests/**relaxing@typescript-eslint/no-explicit-anyand anyno-restricted-importsthat fight test helpers. Keep--max-warnings=0intact. -
.dockerignore: addtests/,vitest.config.ts,playwright.config.tsso the production image does not grow. -
AGENTS.md: add "runbun run verifybefore finishing" alongside the existingbun run checkrule, and a line pointing at this file.
Definition of done: bun run test exits 0.
Highest value per hour of work in the whole plan. No DB, no mocks, no async. This is also where the authorization logic lives, which is where the repo's real bugs have been.
Target: ~200 tests across 20 files, total runtime under 2 seconds.
Priority order, most valuable first:
-
tests/unit/lib/project-access.test.ts:computeProjectAccess()fromlib/auth.ts. This is the heart of every permission decision in the product. Build an explicit matrix: anonymous / non-member / project member / project ADMIN / workspace member / workspace ADMIN / workspace OWNER / project owner, crossed withvisibilityPRIVATE|PUBLIC and workspace-owner billing active|expired. Assert all ofhasAccess,canEdit,canDelete,isWorkspaceAdmin,ownerBillingActive. Recent history (fix/public-project-hides-workspace-admin-actions) says bugs land exactly here. ~24 tests. -
tests/unit/lib/billing.test.ts:hasActiveTrial,hasActiveSubscription,hasRecoverableSubscription,hasBillingAccess,getBillingAccessEndDate,getStorageCleanupEligibleAt,getDefaultTrialEndsAt,mapStripeSubscriptionStatus(every Stripe status string),selectAuthoritativeSubscription,getBillingStatusLabel. All take an injectablenow, so no fake timers needed. Also snapshot-free shape assertions onbuildBillingAccessWhereInputandbuildExpiredBillingWhereInput. ~32 tests. -
tests/unit/lib/validation.test.ts:validateAnnotationStrokes(limits: 500 strokes, 2000 points, colour regex, stroke width bounds, prototype-pollution payloads,__proto__keys, NaN/Infinity coords),isValidHttpUrl(javascript:,data:,file:),isSafeAppRelativePath(traversal, wrong UUID shape),validateOptionalUrlOrAppPath. Security boundaries that are impossible to test by hand. ~24 tests. -
tests/unit/lib/feature-flags.test.ts: env-driven, so usevi.stubEnv. CoverreadBooleanEnvdefaults and garbage values, the S3-over-Bunny precedence inisBunnyUploadsEnabled,isDirectFileUploadEnabled,getMaxVideoUploadBytesfallback on invalid/negative input, and thegetR2MultipartPartSizeBytes5 MiB clamp. ~20 tests. -
tests/unit/lib/rate-limit.test.ts:getClientIpunder eachTRUSTED_PROXY_MODE, spoofedx-forwarded-forchains, theIP_PATTERNreject path, plusrateLimitHeadersand a sanity check that every entry inRATE_LIMIT_CONFIGShas positive window and max. ~14 tests. -
tests/unit/lib/api-response.test.ts: eachapiErrors.*helper returns the right status andcode,successResponseserialisesmetaand BigInt values,withCacheControlsets the header. Guards the contract every route depends on. ~14 tests. -
tests/unit/lib/upload-validation.test.ts:lib/video-upload-validation.tsandlib/image-upload-validation.ts: extension/MIME allowlists, size limits, filename sanitisation. ~16 tests. -
tests/unit/lib/share-links.test.ts: token generation shape, expiry logic, permission comparison. ~10 tests. -
tests/unit/lib/guest-identity.test.ts: cookie parse/serialise, name sanitisation, invalid payloads. ~8 tests. -
tests/unit/lib/content-security-policy.test.ts:buildContentSecurityPolicyincludes runtime storage endpoints when set, omits them when not, and never emitsunsafe-evalin production mode. ~8 tests. -
tests/unit/lib/video-providers.test.ts: provider resolution inlib/video-providers/index.ts, YouTube ID extraction from every URL form (watch?v=,youtu.be,shorts/, with extra params, invalid),metadata-cachehit/miss/expiry. ~14 tests. -
tests/unit/lib/comment-export.test.ts: timecode formatting, CSV/text escaping of quotes and newlines, ordering. ~10 tests. -
tests/unit/lib/approval-workflow.test.ts: status transition rules. ~8 tests. -
tests/unit/lib/async-pool.test.ts: concurrency bound is respected, results keep input order, one rejection does not lose the others. ~6 tests. -
tests/unit/lib/json-serialize.test.ts:bigIntReplaceron nested structures,0n, negative values. ~5 tests. -
tests/unit/lib/email-validation.test.ts: ~6 tests. -
tests/unit/lib/cleanup-warnings.test.ts: warning threshold boundaries. ~6 tests. -
tests/unit/lib/email-brand.test.ts: HTML escaping in email templates. ~5 tests. -
tests/unit/lib/seo.test.ts+lib/marketing/metadata.ts: canonical URLs, title/description length bounds. ~6 tests. -
tests/unit/lib/comment-tags.test.ts:DEFAULT_COMMENT_TAGSinvariants (unique slugs, valid colours). ~4 tests.
Definition of done: bun run test runs ~200 assertions in under 2 seconds, and
bun run test:coverage reports >85% line coverage on the files listed above.
Goal: for each covered route, prove that an unauthorised caller cannot reach it, that malformed input is rejected with 400, and that the happy path writes the right rows.
-
docker-compose.test.yml: Postgres only (no MinIO for now; storage is mocked at thelib/r2.tsboundary), on port55432so it cannot collide with the dev stack, withtmpfsfor the data directory to keep it fast and disposable:services: postgres-test: image: postgres:16-alpine environment: POSTGRES_USER: openframe POSTGRES_PASSWORD: openframe POSTGRES_DB: openframe_test command: ['postgres', '-c', 'fsync=off', '-c', 'full_page_writes=off'] tmpfs: - /var/lib/postgresql/data healthcheck: test: ['CMD-SHELL', 'pg_isready -U openframe -d openframe_test'] interval: 2s timeout: 3s retries: 30 ports: - '127.0.0.1:55432:5432'
-
.env.test.examplecommitted,.env.testgitignored. Minimum set:DATABASE_URL(pointing at 55432),NEXTAUTH_URL,NEXTAUTH_SECRET,NEXT_PUBLIC_APP_URL,OPENFRAME_ENABLE_STRIPE=false,OPENFRAME_REQUIRE_INVITE_CODE=true,INVITE_CODE=test-invite,TRUSTED_PROXY_MODE=none,NODE_ENV=test. -
tests/setup/db-global.ts: global setup, runs once. Loads.env.test, waits for Postgres, runsprisma migrate deployagainst the test DB. Migrations (notdb push) becauseprisma/migrations/*/migration.sqlcontains hand-written SQL such ascleanup_rate_limits()that the routes depend on. -
tests/setup/api.ts: per-file setup. Loads.env.testbefore any@/lib/dbimport, registersafterEach(resetDb), and installs theauth()mock. -
tests/helpers/db.ts:resetDb()truncates every table except_prisma_migrations, discovered dynamically frominformation_schema.tablesso it never drifts from the schema:TRUNCATE TABLE <list> RESTART IDENTITY CASCADE. -
tests/helpers/session.ts: controls the mock:vi.mock('@/lib/auth', async (importOriginal) => { const actual = await importOriginal<typeof import('@/lib/auth')>(); return { ...actual, auth: vi.fn() }; });
plus `signedInAs(user)` / `signedOut()` wrappers. Partial mock, so the real `checkProjectAccess` / `checkWorkspaceAccess` still run against the real DB. That is the whole point: the authorization code under test is not the code being faked. -
tests/helpers/request.ts:apiRequest(url, { method, body, headers, cookies })returning aNextRequest, andcallRoute(handler, request, params)that wraps params in a resolved promise, matching theparams: Promise<...>convention fromAGENTS.md. -
tests/factories/:createUser({ trialEndsAt, subscriptionStatus }),createWorkspace({ ownerId }),addWorkspaceMember,createProject({ visibility }),addProjectMember({ role }),createVideo,createVersion,createComment,createShareLink,createApprovalRequest. Unique values from a module-level counter, no faker dependency. - Module mocks for external services, in
tests/setup/api.ts:@/lib/r2(presign returns a fake URL),@/lib/stripe,@/lib/bunny-upload-token, andnodemailer(assert on captured mail instead of sending).
-
tests/api/auth-matrix.test.ts: a table of all 60 route modules with their exported methods and a sample params object. For each, assert that an unauthenticated call returns 401 or 403, never 2xx. One file, one afternoon, coverage across every route in the app. Routes that are legitimately public (/api/watch/[videoId]with a share token,/api/stripe/webhook,/api/auth/*) go in an explicit allowlist inside the file, so making a route public becomes a visible diff.
Each of these gets unauthorised / forbidden / invalid-input / happy-path cases:
-
tests/api/projects.test.ts:app/api/projects/route.tsGET pagination guards (page 0, page 1001, limit 101, offset > 10000), the billing filter (projects of an expired-trial workspace owner are invisible), POST validation and default comment tags;[projectId]GET/PATCH/DELETE against thecanEdit/canDeletematrix. -
tests/api/project-members.test.tscoveringmembers/route.tsandmembers/[memberId]. A project ADMIN cannot promote itself past its scope, a VIEWER cannot invite, the owner cannot be removed. -
tests/api/comments.test.tscoveringversions/[versionId]/commentsPOST. Annotation payload validation wired tovalidateAnnotationStrokes, guest identity path, timecode bounds. Pluscomments/[commentId]DELETE/PATCH, where only the author or an admin may act. -
tests/api/approvals.test.ts: request creation,decisionroute rejecting a non-candidate approver,cancelrestricted to the requester, terminal-status transitions rejected. -
tests/api/share-links.test.ts: creation permissions, password-protected links, expiry,SharePermissionlevels honoured on read. -
tests/api/watch.test.tscoveringwatch/[videoId]andprogress. Share-session gate, private video without session,upload-tokenscoping. -
tests/api/videos.test.ts:videos/route.ts,bulk-delete(cross-project ids rejected),move(target project permission check),r2-init/r2-completesession lifecycle withlib/r2.tsmocked. -
tests/api/stripe-webhook.test.ts: invalid signature rejected, each handled event type maps to the right user state viasyncStripeSubscriptionToUser, replayed events are idempotent. Stripe SDK mocked; event payloads as fixtures. -
tests/api/register.test.ts: invite code required/not required, duplicate email, password hashing (never stored in clear), email normalisation to lowercase, verification-token creation. -
tests/api/workspaces.test.ts: creation eligibility viagetWorkspaceCreationEligibility, member add/remove roles. -
tests/api/storage-quota.test.ts:reserveStorageQuota/releaseStorageReservationconcurrency: two parallel reservations cannot exceedPLAN_STORAGE_LIMIT_BYTES. This exercises the advisory-lock SQL, which is exactly the kind of thing that cannot be verified by clicking. -
tests/api/rate-limit.test.ts: the DB-backedcheckRateLimitactually blocks after N requests and the window resets.
Definition of done: bun run test:api green against a fresh
bun run test:db:up, total runtime under 90 seconds.
Later optimisation, not now: give each Vitest worker its own Postgres schema
(?schema=test_w${VITEST_WORKER_ID}) and re-enable fileParallelism. Only worth it if
the suite passes ~2 minutes.
Goal: the UI is verified by a browser, not by hand. Keep this suite small and ruthlessly stable. Eight flows, not eighty.
-
bun add -d @playwright/test@^1.62.0 -
playwright.config.ts: Chromium as the default project, one Mobile Chrome project for the dashboard smoke test,retries: 2on CI and0locally,trace: 'on-first-retry', and awebServerrunningbun run build && bun run startwith.env.testandOPENFRAME_ENABLE_STRIPE=false. -
tests/e2e/fixtures.ts: a seeded-user fixture using PlaywrightstorageState, so only the auth spec pays the cost of logging in through the form. - Add the app + MinIO to
docker-compose.test.ymlas a separate profile, since E2E needs real storage for the upload flow.
Flows, in priority order:
-
auth.spec.ts: register with invite code, wrong invite code rejected, login, wrong password, logout, protected route redirects to/login. -
onboarding.spec.ts: a fresh user completes onboarding and lands with a workspace. -
project-lifecycle.spec.ts: create, rename, change visibility, delete a project; the list reflects each change. -
video-upload.spec.ts: uploadtests/fixtures/sample.mp4through the drag-drop uploader, wait for the version to appear, add a second version. -
comments.spec.ts: leave a timestamped comment, verify the timecode links back to the right frame, draw an annotation and confirm it persists after reload, reply and resolve. -
approvals.spec.ts: request approval, approve as a second user in a second browser context, verify both users' views. -
share-link.spec.ts: create a share link, open it in a fresh unauthenticated context, verify the guest name gate and the permission level, verify an expired link is refused. -
billing-gate.spec.ts: a seeded expired-trial user is pushed to/settingsand cannot open a project. -
dashboard-mobile.spec.ts: mobile viewport smoke test. Navigation opens, the project list renders, no horizontal scroll.
Rules for this suite (these are what keep E2E from becoming the thing everyone
disables): locate by role and accessible name or data-testid, never by CSS class; never
waitForTimeout; every spec creates its own data and cleans up after itself; nothing
depends on execution order.
Definition of done: bun run test:e2e green twice in a row locally and on CI.
Deliberately last and deliberately narrow. Most components here are presentational wrappers over Radix or are async Server Components (untestable in jsdom, already covered by E2E). The real logic sits in hooks.
-
tests/setup/component.ts:@testing-library/jest-dom/vitest, plus the jsdom polyfills Radix and the video player need:matchMedia,Element.prototype.scrollIntoView,ResizeObserver,PointerEventmethods,HTMLMediaElement.prototype.play/pause,URL.createObjectURL.
Worth testing (via renderHook):
-
components/video-page/hooks/use-watch-progress.ts: throttling, resume position, the boundary where progress counts as "watched". -
components/video-page/hooks/use-version-duration-sync.ts: small and pure enough to pin exactly. -
components/video-page/hooks/use-comment-export.ts: pairs with thelib/comment-export.tsunit tests. -
components/video-page/hooks/use-comment-actions.ts: 39k of logic. Test optimistic insert, rollback on failed request, reply threading, resolve toggling. Highest-value item in this phase. -
components/video-page/hooks/use-video-player.ts: 48k. Do not attempt full coverage in jsdom. Pull the pure parts (timecode parsing/formatting, frame stepping arithmetic, keyboard-shortcut mapping) into a sibling module and unit test those in Phase 1 style; leave playback behaviour to E2E.
Worth testing (via render):
-
components/video-page/comment-rich-text.tsx: URL linkification and@[name](asset:id)mention parsing, including the XSS-shaped inputs (javascript:hrefs must not render as links). -
components/linkify.tsx: same regex, different component. -
components/error-boundary.tsx: renders the fallback and does not swallow the error. -
components/share-link-unlock.tsxandcomponents/guest-gate.tsx: small forms with real validation branches.
Explicitly not tested here: everything in components/ui/ (upstream shadcn/Radix),
LandingPage.tsx, components/marketing/*, assets-pane.tsx, comments-pane.tsx,
video-page-content.tsx. Those are covered by E2E where they are covered at all.
-
.husky/pre-push(new hook):bun run verify
`pre-commit` stays as-is (`lint-staged`) so committing stays fast. Push is the right gate: it is where work leaves the machine. - Rewrite
.github/workflows/ci.ymlinto three jobs:jobs: check: # existing: lint + format + typecheck test: # unit + component + api, with a postgres:16-alpine service e2e: # playwright, needs: [check], uploads the report on failure
`test` runs `bun run test && bun run test:api` with `DATABASE_URL` pointing at the service container and `prisma migrate deploy` first. `e2e` uses `mcr.microsoft.com/playwright:v1.62.0-noble` as the job container and uploads `playwright-report/` via `actions/upload-artifact` when it fails. - Add a coverage summary comment or a
coverage-summary.jsonartifact. No coverage threshold gate initially: a hard gate on a suite this young turns into people writing tests for getters. Revisit once Phase 2 is complete. -
README.mdandCONTRIBUTING.md: a "Running the tests" section pointing here.
Per the project rule, no npm package touches the host filesystem.
# Unit + component, the everyday loop
podman run -it --rm -v "$PWD":/workspace:z -w /workspace docker.io/oven/bun:alpine \
sh -c "bun install && bun run test"
# Watch mode while writing code
podman run -it --rm -v "$PWD":/workspace:z -w /workspace docker.io/oven/bun:alpine \
sh -c "bun install && bun run test:watch"
# API integration: start the test DB on the shared network first
podman network create openframe-test # once
podman compose -f docker-compose.test.yml up -d --wait postgres-test
podman run -it --rm --network openframe-test -v "$PWD":/workspace:z -w /workspace \
docker.io/oven/bun:alpine sh -c "bun install && bun run test:api"
# E2E: browsers preinstalled in the Playwright image
podman run -it --rm --network openframe-test -v "$PWD":/workspace:z -w /workspace \
mcr.microsoft.com/playwright:v1.62.0-noble sh -c "bun run test:e2e"- Wrap these in
scripts/test.sh <unit|api|e2e|all>so the everyday invocation is one short command instead of a memorised podman line.
Each of these gets a 15-minute spike before the phase that depends on it. If a spike fails, the fallback is listed.
-
vi.mockpartial-mocking@/lib/auth(Phase 2). Importing the real module initialises NextAuth v5 beta withPrismaAdapter(db)at module load. It should be inert without a request, but beta versions surprise. Fallback: extractcomputeProjectAccess,checkProjectAccess,checkWorkspaceAccessandprojectAccessIncludeintolib/access.ts(a pure re-export fromlib/auth.tskeeps every call site working). Then tests importlib/access.tsand mocklib/auth.tswholesale. This is a better structure anyway. -
lib/db.tsenv timing (Phase 2).dbis a module-level singleton that readsprocess.env.DATABASE_URLat import. Setup files must load.env.testbefore the first@/lib/dbimport in the module graph. Fallback: pass env explicitly on the command line (DATABASE_URL=... vitest run --project api) instead of relying on a setup file. -
process.on('SIGINT'|'SIGTERM')inlib/db.ts(Phase 2). Every test file that importsdbadds listeners. With many files this trips Node'sMaxListenersExceededWarningand, with--max-warningsstyle strictness, noise. Fallback: guard the registration withif (process.env.NODE_ENV !== 'test'), or callprocess.setMaxListeners(0)in the api setup file. -
Radix + React 19 under jsdom 29 (Phase 4). Radix uses pointer-capture APIs jsdom does not implement. Fallback: the polyfill list in
tests/setup/component.ts; and if a component still resists, it moves to E2E instead. No fighting jsdom for hours. -
Playwright
webServerbuild time (Phase 3).next buildon a 56k-line app is not fast, so a naive config rebuilds on every local run. Fallback:reuseExistingServer: !process.env.CIand a cached.nextbetween runs; note that per this repo's worktree recipe, a cold.nextin a worktree causes Prisma 500s, so the E2E setup must seed.nextor run in the main checkout. -
BigInt in assertions (Phases 1-2). Storage sizes are
bigint.expect(x).toBe(1)fails against1n. Establish the convention early: always compareBigInt(...)toBigInt(...).
| Phase | Scope | Rough effort | Value |
|---|---|---|---|
| 0 Foundation | config, scripts, lint/ignore wiring | half a session | enabling |
| 1 Unit | ~200 tests, 20 files | 1-2 sessions | very high |
| 2 API | infra + auth matrix + 12 deep suites | 3-4 sessions | very high |
| 3 E2E | 9 specs + compose profile | 2-3 sessions | high |
| 4 Component/hooks | ~10 targets | 1-2 sessions | medium |
| 5 CI + hooks | 3 jobs, pre-push, docs | half a session | high |
Recommended order of delivery: 0 → 1 → 5 (partial: pre-push + test job) → 2 → 3 → 4.
Wiring CI right after Phase 1 means the tests start protecting master while they are
still cheap, instead of waiting for the whole pyramid.
Written down so they do not get relitigated:
- No snapshot tests.
- No tests for
components/ui/*(upstream shadcn/Radix). - No mocked Prisma client.
- No 100% coverage target. Coverage is a diagnostic, not a goal.
- No test for a getter, a re-export, or a constant.
- No visual regression testing (Percy/Chromatic) at this stage.
- No load or performance testing at this stage.
Corrections found while implementing it. Recorded so the sections above are read with them in mind, and so nobody "fixes" a deliberate deviation back.
Infrastructure
prisma migrate deploycannot build this database, which invalidates Section 5's instruction.prisma/migrationsis a stack of patches on top of a baseline that was never captured, so the second migration runsALTER TYPE "VideoAssetKind" ADD VALUEagainst a type nothing in the history creates, and dies with P3018 / 42704 on an empty database. The test schema therefore comes fromprisma db pushplus a replay of the hand-written SQL thatschema.prismacannot express (cleanup_rate_limits(), theUNLOGGEDrate-limit table, three partial unique indexes onvideo_versions). This is the same approachscripts/docker-db-bootstrap.tsalready takes in production.tests/setup/db-global.tsdocuments it in full and carries a drift guard that fails the run when a migration is added without review.- Coverage does not work under bun.
@vitest/coverage-v8needs the V8 inspector API, which bun does not implement, sobun run test:coveragereports zeros. The suites pass under both runtimes, so CI runs the coverage step under node instead. Getting the unit project to run under node neededserver.deps.inline: [/next-auth/], becausenext-auth/lib/env.jsimports the extensionlessnext/server, which node's ESM resolver cannot resolve and bun can. @playwright/testis pinned to 1.61.1, not the newest release. The container image is what fixes the ceiling:mcr.microsoft.com/playwright:v1.62.0-nobleis not published, and Playwright refuses a browser build that does not match the package. Bump the package and the image tag together, and check the tag exists first.- The Playwright image ships no bun, and
oven-sh/setup-buncannot run inside it either, because the image has nounzip. Both the CI job andscripts/test.shinstall bun withnpm install --global bunfirst. - eslint keeps its own ignore list, so a coverage run used to break
bun run linton the reporter's vendored JS.coverage/**,playwright-report/**andtest-results/**are now inglobalIgnores. tsconfig.jsontargets ES2017, so1nis a compile error (TS2737) even though the BigInt type resolves. AlwaysBigInt(1). Section 9 framed this as a runtime assertion mismatch and understated it.- Testing Library's auto-cleanup never installed. It only registers its own
afterEach(cleanup)when a globalafterEachis visible, and Section 2 mandates explicit imports. Components stayed mounted for the rest of each file, with their intervals and listeners live, which produced real cross-test contamination.tests/setup/component.tsnow callscleanup()itself. - Risk #1 did not materialize.
@/lib/authimports cleanly in a node test environment, so thelib/access.tsextraction was not needed and was not done. Risk #3 was real butprocess.setMaxListeners(0)in the api setup was enough, solib/db.tsstays untouched.
Test environment
OPENFRAME_ENABLE_STRIPEmust betruein the test environment, notfalseas Sections 5 and 6 suggested. With the flag off,hasBillingAccess()short-circuits totrueandbuildBillingAccessWhereInput()returns{}, which disarms the entire billing gate and makes every access-control assertion meaningless. Dummy Stripe keys are used and no test walks into checkout.DISABLE_RATE_LIMIT=truefor the api suite, because every request in it shares one client IP and one file exhausting a window would make the next file's 429 look like a passing authorization check.rate-limit.test.tsre-enables it per test.vi.stubEnvdoes not auto-restore.tests/setup/api.tscallsvi.unstubAllEnvs()inafterEachcentrally, after four tests were caught passing for the wrong reason.- The E2E suite deliberately does not truncate between tests: several Playwright workers drive one app against one database, so each test seeds uniquely tagged rows and deletes its own users, letting the schema cascade do the rest.
Assignments in the plan that did not match the code
lib/share-links.tsgenerates no tokens; it exportsvalidateShareLinkAccess.lib/approval-workflow.tshas no status transition rules; it exportsgetApprovalCandidatesForProject.runWithConcurrencyreturnsPromise<void>, so "results keep input order" is not a property it can have.lib/rate-limit.ts's real off-by-one lives incheckRateLimit, which Section 4 omitted.use-video-player.tscontains no timecode parser or formatter.formatTimeis injected as a parameter and is duplicated in four components; hoisting it intolib/is a separate change. The extraction covered frame and playhead arithmetic instead.- Section 5 lists
/api/watch/[videoId]as public. It is not: it 403s anonymously on a private project and needs a share-session cookie. - Section 8's
.dockerignoreitem does not achieve its stated goal. Image size comes from a non-productionbun installwhosenode_modulesis copied wholesale into the runner stage, not from source files. - A coverage PR comment needs
pull-requests: write, which conflicts with keepingpermissions: contents: read, so CI uploads an artifact instead.
The first round left an inventory of what it had not covered. This section records what the second round did about it, so the inventory is not read as still-current.
Where it ended up:
| Suite | Before | After |
|---|---|---|
| Unit | 1191 | 1702 |
| Component + hook | 167 | 377 |
| API integration | 647 | 1015 |
| End to end | 18 | 29 |
The page-level authorization layer. lib/route-access.ts had zero coverage, which
meant the API routes were guarded by tests and the pages were not. It now has 47, with
next/navigation mocked so that redirect() and notFound() throw the way they really
do. Every redirect target was verified against a second source rather than read off the
function under test, and one of the three the plan assumed turned out to be wrong: the
project paths have no billing branch at all and reach /settings in two hops through
/dashboard.
The media proxies. Five routes served user media with only anonymous coverage, and the
reason they had stayed that way was the positive control: no 2xx is reachable without R2
configured, and a 403 with nothing green beside it can pass for the wrong reason. The way
in was to stub r2Client.send() and leave lib/r2-media-proxy.ts itself real, so the
object keys, content types and range handling are production code paths. Every one of the
five now has a genuine 2xx in the same file as its 403.
Tests that could not fail. The auth matrix used to assert only "not 2xx" for an
anonymous caller. Two entries satisfied that without their guard existing at all, because
the route refused a malformed body one line further down, and Section 12's predecessor
recorded them as unfixable. They were fixable: requiring an authorization status (401, 403
or 404) rather than merely a non-2xx one makes both load-bearing, and all 60 routes pass
the stricter form, so NON_AUTHORIZATION_REFUSALS is empty and exists only as a drift
guard.
A stub with the wrong shape is worse than no stub. tests/setup/api.ts declared
readVideoObjectBytes as returning an object wrapping a Uint8Array when it really
returns the array. The object was truthy but had no .length, so hasKnownVideoMagicBytes()
saw zero bytes and every route reaching finalizeR2VideoUpload took the "not a valid
video" branch, cancelled the session and deleted both objects. Nothing failed. No test
drove that path to success until this round, and the whole api suite had been green over
it for weeks.
Parallel suites need parallel databases. Eight agents wrote suites at once, and the
api project empties every table between tests, so they cannot share one database. Each run
got its own, created by hand in the same container and named openframe_test_<suffix>.
tests/api/infrastructure.test.ts now accepts that shape instead of the exact name; the
guard that matters, that the dev database is called openframe and does not match, is
untouched.
Mutation testing. bun run test:mutation runs StrykerJS over the authorization and
input-validation modules listed in stryker.config.json, against
vitest.mutation.config.ts, which is the unit project alone. Not a merge gate, for the
same reason there is no coverage threshold, and CI runs it weekly and on demand rather
than on a push, because a full run is minutes. The module list is explicit rather than a
lib/** glob: a file whose only coverage is an API integration test would report every
mutant as survived and bury the real findings.
Safari. playwright.config.ts gains a webkit-player project behind E2E_WEBKIT=1,
scoped to player.spec.ts. Playback is where a video review tool's Safari risk actually
lives; running all fourteen specs under WebKit would mostly re-test React.
Reviewed by somebody else. Every suite in this round was read by a separate agent whose
only question was whether the tests deliver what they claim. That is now a standing rule in
AGENTS.md rather than a one-off.
What was deliberately left, and why:
- OAuth sign-in, Stripe checkout, and email verification end to end. All three leave the app or need a provider stub. The gate each one guards is covered at the API layer.
- Version comparison end to end. Two real uploads per test against 51 KB of its own client logic. Three solid specs beat five thin ones.
components/ui/*, the marketing pages, and the three large panes. Unchanged from Section 11. The panes' real logic is reachable by extraction, which is whatvideo-player-utils.tsandupload-chunking.tsdemonstrate.- A coverage threshold gate. Still a non-goal. Mutation testing answers the question a threshold was a proxy for.