Repository navigation
Decisions Code and tooling
Part of Decisions. Each decision has a name, which issues and code comments use to refer to it.
Code that only tests use lives with the tests, not in src/ (Axlog, 2026-10-18, #474): "If it's not used outside the tests, it should be among them." The intent is to put each file with its use and its purpose. src/ holds only what the app runs. Each app keeps its tests and their helpers in test/, each in the folder its subject has under src/; the issue pipeline and the instruction counter already did. In the site, #474 moved 267 test files, their snapshots and 46 test-only modules, among them the server tests' kit, now test/test-database/. test/src-holds-no-tests.test.ts fails on a test file or a fake-*, *-fixtures or *-test-kit helper under src/. Tests that read source files with import.meta.glob use Vite's base option to read src/, so their keys stay relative to the subject's folder.
It replaces the rule that unit-test helpers sit beside the code they fake (from 2026-10-12), which recorded no reason.
The repo's coding, test and production rules live in one file, AGENTS.md (Axlog, 2026-10-18). CONTRIBUTING.md was merged into it and deleted, and the repo has no CLAUDE.md. In the same change Axlog removed these rules: the scope paragraph and practices-cover-all-code; the config-wins note (biome-config-authoritative); the Product rules except the swappable AI provider; design-it-twice and the review red flags; the strictest-TypeScript, import type, exhaustive-switch, satisfies and as const rules; Biome's all preset (biome-infos-fail); scoped overrides (best-practice-over-lint); the 300-line file and 50-line function limits; imports naming the file extension; test-first with npm audit; the slug-title, AUTH_CASES and PENDING_CASES rules; and the rule against connecting to the production database. The lint config is unchanged, so whatever Biome enforces still fails the check. Coding practices digest summarises what remains.
Retired from AGENTS.md by Axlog on 2026-10-18 (agents-md-holds-the-rules). This was the blanket rule that coding practices cover every part of the software ecosystem. Read the current scope and requirements in AGENTS.md; do not restore the retired rule from older wiki text.
Use Ousterhout's A Philosophy of Software Design and the project's current typing and lint requirements in AGENTS.md (Axlog, 2026-10-10). Axlog removed the requirement to choose the strictest available TypeScript settings on 2026-10-18 (agents-md-holds-the-rules); tsconfig.json is unchanged.
Biome lints and formats instead of ESLint and Prettier, because eslint-config-next pulled in braces@3.0.3 (GHSA-vfj7-8cjw-p6xm), which had no patched release.
Every Biome group is set to error, so info diagnostics also fail npm run check. Axlog removed the all preset requirement from AGENTS.md on 2026-10-18, but the config is unchanged (agents-md-holds-the-rules). Axlog ruled (2026-10-10) that no rule is turned off, the UI may be redesigned to satisfy the rules, and tests get no looser rules. Rules for other frameworks stay off.
Axlog removed the rule that lint configuration wins over documentation from AGENTS.md on 2026-10-18 (agents-md-holds-the-rules). The root and app Biome configs still define what lint enforces; the former rule against reporting differences as drift is no longer contribution guidance.
drizzle/meta/ stays excluded from Biome, because drizzle-kit rewrites it on every generate (Axlog, 2026-10-10).
Satisfy lint through better design, never through a dodge (Axlog, 2026-10-10). Do not use computed keys, bracket reads or process.getBuiltinModule to evade a rule. A Biome bug with no supported fix goes back to Axlog. The one documented import suppression is biome-overload-false-positive.
Axlog removed the scoped-overrides and as const requirements from AGENTS.md on 2026-10-18 (agents-md-holds-the-rules). Existing platform, framework and external-field overrides remain in the Biome configs, which hold their exact file lists. The no-dodges rule remains contribution guidance.
Biome's noUndeclaredClasses and noUnusedClasses follow imports, and pages escape both because nothing imports them. Roles are plain classes in layered CSS files (page-roles.css, chat.css). Axlog approved (2026-10-11) a scoped override of both rules on the root layout, the site frame it imports (header, footer, theme picker) and the global CSS files, so the layout can use Tailwind utilities.
use-cases/ holds one file per area, with conventions in its README.md. Each case has a test or group titled exactly its slug, in Vitest or Playwright as e2e-visual-and-auth-only decides. Until implemented it may instead be pending in e2e/pending-cases.ts, never both. Version 1 is not done until every case has a passing test.
use-cases/use-cases.test.ts checks coverage, slug titles and links. It reads Vitest's test, it and describe titles in test files; other tests may keep plain titles, and ordinary text may name any slug. Every e2e test outside e2e/visual/ belongs under an auth-case slug from use-cases/auth-cases.ts. Tests use the mock model and a non-production database; only smoke tests use real Claude.
Vitest test placement:
- Component tests use
test/**/*.test.tsx. Browser tests use*.browser.test.tsx, run bynpm testin Chromium and WebKit. - Server tests use
test/**/*.server.test.ts, run in Node against the test database bynpm run test:serverand first bynpm run test:e2e, never bynpm test. - Time-zone tests use
*.zoned.browser.test.tsx. Each zone inZONEShas its ownbrowser-<zone>project and browser contexts with that zone and locale. The plain browser project excludes them. - Touch tests use
*.touch.browser.test.tsx, run bybrowser-touchwithhasTouch; the plain browser project excludes them.isMobilestays off because it would scale Vitest's runner iframe, not the tested page. The phone checks coverPHONE_PAGESintest/app/phone-pages-test-kit.tsx. - Layout measurements call
installThemeFonts()once and awaitloadFonts()before measuring, fromtest/themes/theme-fonts-test-kit.ts. Screen sizes andMINIMUM_TARGET_PXlive intest/viewports-test-kit.ts.
Rejected: keeping every use case in Playwright, which was too slow (#131, #341).
Axlog, 2026-10-17: "e2e should only test for visual regressions and the auth workflows."
Playwright e2e tests cover only two things. The first is visual regressions: the screenshot tests in e2e/visual/. The second is the auth workflows: sign-up, sign-in, sign-out, the sign-in limits, and the signed-out access gate, where a signed-out visitor, or one whose session has lapsed, is sent to log in. Every use case still has a test titled by its slug (use-cases-drive-tests), wherever that test lives.
Every other case is tested in Vitest:
- A case that needs a real server or database gets a server test against the test database in
.env.test. The test calls the Server Action, route handler or store with real users and sessions, not through a browser. - A case that needs only the UI gets a component or browser test (
*.browser.test.tsx). - Account isolation (
use_case_private_data) becomes a server test with two users, so it stays covered.
Reason: the e2e suite is the slowest one. It runs twice in the flake gate on every pipeline verify (e2e-test-database-speed), and it has timed out under load. Most of its cases don't need a browser.
The migration out of Playwright (#341, #342, #345–#347, #350, #351) is complete in this checkout. Its lasting decisions are:
-
One server suite:
vitest.server.config.mtsruns tests in Node. Its setup checks the test environment and mocks Next's request-bound APIs.npm run test:serverresets the test database first;npm run test:e2eruns server tests before Playwright, andnpm testnever runs them. Rejected: adding a server project to the default Vitest config, which would require a filter on everynpm test. -
One shared kit:
test/test-database/provides users (testUser), their data through stores (testData), real sessions (signIn,signOut,lapsed), requests (requestWithSession), and navigation checks (expectRedirect,expectNotFound). The setup cleans up after each test. E2e fixtures use the same user and data kit. Rejected: a Vitesttest.extendfixture object, which would make every server test import a kit-specifictestinstead of Vitest's. -
Async work ends with the test: the setup's mock of next/server uses
after-test-kit.ts. It starts eachafter()task immediately and keeps it;afterTasksDone()waits for all tasks, including ones they start. A test awaits it before reading what a reply saved, and the setup awaits it before cleanup, so no task outlives its test. -
One chat entry point:
chat-test-kit.tssends messages throughPOST /api/chatas the signed-in user.sendChatMessagereturns the status and whole body even on failure, after theafter()tasks finish.tutorReply,tutorPrompt,tutorEdits, resend helpers and saved-reply readers build on it. The kit reads the owner fromcurrentUser(); an owner argument would repeat the session. The migration's temporary chat and async kits were merged into this kit andafter-test-kit.ts. -
Shared specialised helpers: the same directory holds
spend-test-kit.ts,two-users-test-kit.ts,memory-form-test-kit.ts,storage-down-test-kit.tsandscratch-database-test-kit.ts. Storage-down tests pointDATABASE_URLat port 1 on the test database's host, restore the URL and reset the cached client afterwards, and use an unstored user so cleanup needs no database query during the outage. Memory-action tests mock next/cache'srefresh(), which needs a Server Action scope. -
Isolation through real users:
test/accounts/private-data*.server.test.tschecks layers with two users, real data and sessions. Another user's id must produce exactly the answer an unknown id produces. Pages run as async Server Components underexpectNotFound; lists render throughrenderToStaticMarkup. -
Whole-database tests own a database:
scratchDatabasegives each test a checked database configuration beside the test database, with a unique suffix. The test resets that database first and drops it afterwards. Full reindex and old-schema migration tests never need the shared database to themselves or last. Rejected: a whole-database project serialized after the other tests. -
Visual effects need visual evidence: the unpinned chat-header test compares element screenshots one screen down, pinned and unpinned. Rejected:
elementFromPointalone, which cannot see the header's fade because it haspointer-events: none. -
The scope is enforced:
use-cases/use-cases.test.tsrejects non-auth slug titles outsidee2e/visual/and rejects auth-list entries that aren't cases. The former storage-down web server, whole-database Playwright project and unused e2e helpers were removed.
Rejected: the earlier rule that a case needing a real server, database or navigation stays in Playwright (#131), because that rule put most cases in the slowest suite.
Moved to ecosystem.
The local test database uses one SCRAM iteration in compose.test-db.yml (orchestrator, 2026-10-12). The Neon proxy authenticates every HTTP query; this reduced the measured cost from about 76 ms to 8 ms per query. Production authentication is unchanged. Visual projects run in parallel, and chat screenshots use stored rows.
Local Playwright runs use 80% of the cores; CI keeps 50% (#20, 2026-10-13). On the measured 10-core machine, 8 workers gave the fastest full run (69–70 s, versus 80–81 s at half the cores and 73 s at 10).
Rejected: switching tests to the websocket Pool, which production does not use; dropping the second full run, which is the flake gate; and shortening mock delays or polling intervals, neither of which improved the full run's wall time.
A flake is a failure: fix its cause or the measurement, and never rerun until it passes (Axlog). A passing repeat adds no evidence, and a diagnosis gets two hours before it goes back to Axlog.
Wait for the event that marks async work done rather than for a clock when an event exists (orchestrator, #355). For example, warm the code highlighter through loadCodeHighlighting before measuring highlighted text, and await a stubbed reply's body before checking the rendered chat.
A remaining clocked wait covers only work with no completion event, such as a lazy code chunk and React commit. Give its timeout a top-level named constant and a comment recording what it covers, the worst measured time and the load used for that measurement. Set it to at least three times that worst time. A cold grammar import failed within the old 1000 ms colour poll in both browsers at 1-minute load averages of 43–83 on 10 cores.
Server tests set test and hook timeout SERVER_TEST_TIMEOUT_MS to 40 000 ms (#382). Database round trips, chat requests and cleanup exceeded Vitest's 5000 ms default under load. The worst measured test, including cleanup, took 12 742 ms at load averages of 38–50 on 10 cores, with other suites and one busy loop per core running. The timeout is three times that measurement, rounded up. Unit and browser projects keep Vitest's defaults.
Rejected: raising unrelated timeouts or rerunning until green (flakes-are-failures).
Moved to ecosystem.
The browser test projects (browser, browser-<zone> and browser-touch) pre-bundle a fixed list of dependencies with Vite's dependency discovery off (#375). The list is PREBUNDLED_DEPS in apps/web/vitest.browser-deps.mts, sorted, and holds only the app's own bare imports in the browser: the browser tests, vitest.browser-setup.ts and the src/ code they load. Vitest and @vitejs/plugin-react add their own entries. The module's one export, the Vite plugin prebundledBrowserDeps(), sets optimizeDeps.noDiscovery: true and that include. In each browser server's client environment, it fails an import, from one of the app's own files, of a package that the resolved optimizeDeps lists neither include nor exclude. The error names the package, the importing file and the list's file, so a new bare import fails at once rather than being served unbundled. To add a dependency, add it to the list.
Cause: Vitest 5 gives each project its own Vite server and optimizer cache (node_modules/.vite/vitest/<sha1 of the project's name>), and a project's Chromium and WebKit instances share that one server. With discovery on, an import the startup scan missed made Vite optimize again mid-run. That changed the deps' browser hash, so Vite turned away the other instance's in-flight imports of the old version: #346's verify failed in Tokyo/Chromium with "Failed to fetch dynamically imported module" under load. next/cache had been added to include alone for the same reason.
Rejected: a pre-bundle step before the tests, which adds a command before every npm test and still leaves discovery on; and leaving discovery on with a longer include, which can't stop the next missed import from re-bundling mid-run. Out of scope: two runs at once in the same checkout, which share the optimizer cache dirs, and which Vite doesn't support (vitejs/vite#12603). A test file that fails to import with a SyntaxError is not this case (#386): see served-syntax-check.
Browser test projects check that every JavaScript module Vite serves parses as an ES module (#386). The plugin servedSyntaxCheck() in apps/web/vitest.browser-syntax.mts logs the app-relative file, line, column, parser message and surrounding transformed code when parsing fails. It serves the module unchanged, so the test still fails normally.
The check sees the client environment's transformRequest result after every plugin and import analysis. A plugin's own transform hook is too early: Vitest rewrites dynamic imports afterwards.
Reason: browser test files intermittently failed to import with SyntaxErrors that named neither a module nor a position. The cause remains unproven; #386 records the investigation. Fixed dependency pre-bundling did not prevent these failures, so browser-deps-prebundled is a separate safeguard. If an import fails with a SyntaxError and the check logs nothing, the server sent code that parses; investigate what happened between the server and browser.
Rejected: a whole-run Vitest reporter inspecting cached transforms, and a transform hook that cannot see the final code. Out of scope: concurrent runs in one checkout, which share Vite optimizer caches and are unsupported.
Biome's noDeprecatedImports checks imports, not calls, so it reports drizzle's primaryKey because one of its overloads (the variadic one) is deprecated, even where code calls the documented primaryKey({ columns }). Biome closed biomejs/biome#7636 as a trade-off of the rule's design, so no release will fix it (Axlog, 2026-10-14). src/db/tables/monthly-spend.ts calls primaryKey({ columns }) and suppresses the rule on that one import with a biome-ignore that gives this reason and links the issue; test/db/monthly-spend-table.test.ts checks it is the only biome-ignore in src/. The rule stays on. Rejected: drizzle's undocumented PrimaryKeyBuilder class, turning the rule off, and waiting for a Biome release.
GitGuardian's NODE_CRYPTO_TOKEN alert on test/auth/session-token.test.ts is a false positive: a known-answer token signed with the in-repo test secret (Axlog). Don't rotate it, rewrite history, or derive the fixture at test time.
The repo is a monorepo of npm workspaces, with mise monorepo tasks across the apps (Axlog, 2026-10-14). The site lives in apps/web/ and the issue pipeline in apps/issue-pipeline/ (#160). The root holds only the shared tooling and the top-level docs: one package.json that declares the apps/* workspaces and holds Biome and the overrides, the one package-lock.json, mise.toml (the monorepo root, pinning node), the Biome policy in biome.jsonc, AGENTS.md and the README. Each app has its own mise.toml with its tasks, run from anywhere as mise //apps/web:check, or mise //...:check for every app.
Rejected: Turborepo, because its cache would replay the flake gate's second e2e run, its hashes miss the test database and .env.test, fresh pipeline worktrees never hit the cache, and two packages gain nothing from its task graph. Nx and moon, which are built for many projects and languages and have the same caching concern. Bazel, Buck2 and Pants, which need BUILD files per target and a build engineer to maintain them.
Moved to ecosystem.