The repo had no automated tests. Every change was verified by hand. Adds four layers, 2023 tests in total, runnable with one command: - 1191 unit tests over the pure logic in lib/, including the full computeProjectAccess permission matrix and the billing gate - 167 component and hook tests in jsdom, covering the hooks that hold real logic rather than presentational wrappers - 647 API integration tests against a real Postgres, with only auth() mocked, including a data-driven sweep asserting that none of the 60 route modules answers 2xx to an unauthenticated caller - 18 Playwright specs driving a real browser against a real build Infrastructure: vitest.config.ts with three projects, a disposable Postgres and MinIO in docker-compose.test.yml, factories and helpers under tests/, scripts/test.sh as the single entry point, a pre-push hook running bun run verify, and CI split into check, test and e2e jobs. The test database is built with prisma db push plus a replay of the hand-written SQL, because prisma migrate deploy cannot build this schema from empty: the migration history has no captured baseline. This mirrors what scripts/docker-db-bootstrap.ts already does in production, and tests/setup/db-global.ts carries a drift guard so a new migration fails the run until someone reviews it. Production code is unchanged apart from one pure-function extraction out of use-video-player.ts, which was too large to test in jsdom. Several tests pin behaviour that looks wrong, each marked KNOWN BUG in place. TESTING.md section 12 records where the plan turned out to be wrong, and AGENTS.md now states which layer a change needs a test in.
38 KiB
Testing Plan
Status: all six phases delivered. What actually landed, and where reality differed from the plan, is in Section 12. The sections below are kept as written so the reasoning behind each decision stays readable.
| Suite | Command | Tests | Runtime |
|---|---|---|---|
| Unit + component | bun run test |
1160 | 6s |
| API integration | bun run test:api |
537 | 45s |
| End to end | bun run test:e2e |
18 | 40s |
| Total | bun run test:all |
1715 |
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.
0. Primer
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.
1. Stack decisions
| 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. |
Rejected alternatives, and why
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.
One structural constraint to be aware of
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.
2. Directory layout
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".
3. Phase 0: Foundation
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.
4. Phase 1: Pure unit tests
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.
5. Phase 2: API integration tests
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.
Infrastructure
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).
The cheap win: auth matrix
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.
Deep coverage, in priority order
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.
6. Phase 3: E2E tests
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.0playwright.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.
7. Phase 4: Component and hook tests
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.
8. Phase 5: One-click and CI
.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.
Local commands, all under podman
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.
9. Risks and spikes
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(...).
10. Effort and sequencing
| 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.
11. Non-goals
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.
12. Where the plan was wrong
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.