ERNIE uses a split local validation workflow.
- PHP, Composer, Artisan, Pest, and PHPStan are container-first.
- Vitest, ESLint, TypeScript, and Playwright run from the host shell.
- Host-side frontend checks require local
node_modulesin the repository checkout. - The default PHP path stays fast by using SQLite in memory.
- MySQL-specific verification stays targeted and explicit.
Canonical entry points:
npm run check:backendnpm run check:frontendnpm run check:parity
Run npm ci after cloning and whenever package-lock.json changes. Use npm install only when intentionally adding or updating dependencies so npm can update the lockfile. The Docker entrypoints install npm packages only inside Docker-managed volumes and do not satisfy host-side frontend commands.
| Check | Where to run it | Command | Notes |
|---|---|---|---|
| Pest complete suite | Host shell via npm wrapper | npm run test:php |
Linux-native workspace; serial/Arch split; parallel remainder |
| Pest TIA | Host shell via npm wrapper | npm run test:php:tia |
Local-only affected-test loop; records a baseline on first use |
| Pest deprecation details | Host shell via npm wrapper | npm run test:php:deprecations |
Use this instead of forwarding --display-* flags through npm |
| Pest Agent probe | Host shell via npm wrapper | npm run test:php:agent -- '<PHP snippet>' |
One-off verification; not a replacement for a regression test |
| PHPStan | Host shell via npm wrapper | npm run phpstan:check |
Required before finishing PHP changes |
| Pest type coverage | Host shell via npm wrapper | npm run test:php:type-coverage |
Enforces the measured 92% minimum; expensive on a cold cache |
| MySQL-sensitive Pest slice | Host shell via npm wrapper | npm run test:php:mysql-sensitive |
Uses isolated ernie_test schema |
| Vitest one-shot | Host shell | npm run test:run |
Preferred for focused frontend validation |
| Vitest coverage | Host shell | npm run test:coverage |
Use only when coverage detail is needed |
| Vitest performance doctor | Host shell | npm run test:doctor |
Runs the suite repeatedly; use for measured tuning only |
| ESLint check | Host shell | npm run lint:check |
Non-mutating validation |
| ESLint auto-fix | Host shell | npm run lint |
Applies ESLint fixes |
| TypeScript | Host shell | npm run types |
Runs app and test TS checks |
| Playwright dev stack | Host shell | npm run test:e2e:devstack |
Requires the Docker dev stack |
| Playwright stage | Host shell | npm run test:e2e:stage |
Use only for stage-specific bug reproduction |
| Backend umbrella check | Host shell | npm run check:backend |
Pest plus PHPStan |
| Frontend umbrella check | Host shell | npm run check:frontend |
ESLint plus OpenAPI lint plus TypeScript plus one-shot Vitest |
| Parity umbrella check | Host shell | npm run check:parity |
Parity profile plus MySQL slice plus Playwright |
The default PHP suite is intentionally optimized for speed.
tests/pest/CreatesApplication.phpforcesAPP_ENV=testing.- The same bootstrap defaults
DB_CONNECTION=sqliteandDB_DATABASE=:memory:. - Setting
ERNIE_TEST_DB_CONNECTIONswitches the dedicated MySQL-sensitive slice to its isolated Docker test schema instead.
Whenever a test opts into MySQL, the repository wrapper starts the pinned
MySQL 9.7 service and waits for a healthcheck that also verifies the 9.7.x
server series. The separate MySQL 8.4 mysqldump build stage is only a legacy
export client and is not a test database.
Use the SQLite path for the routine local loop.
Use a MySQL-backed slice only when one of the following is true:
- a migration behaves differently across drivers
- a query depends on MySQL-specific behavior
- a failing production or stage bug cannot be reproduced against SQLite
The npm wrapper runs the current explicit schema-mutating MySQL-sensitive file slice against a dedicated MySQL schema named ernie_test.
That wrapper recreates the schema before each file so DDL-heavy migration tests do not leak state into the next process.
Recommended commands:
npm run test:php
npm run test:php:tia
npm run test:php:deprecations -- tests/pest/Unit/Enums/UserRoleTest.php
npm run phpstan:check
npm run test:php:mysql-sensitivenpm run test:php is the only supported entry point for the routine complete
PHP suite. The wrapper always applies a 2 GB PHP memory limit, including to
ParaTest workers, and reports the duration of every phase plus the total.
On Docker Desktop, the checked-out source is a Windows/macOS bind mount. Pest
and Laravel load hundreds of PHP files in every worker, so running directly
from /var/www/html makes filesystem I/O dominate the suite. Before a complete
run, the wrapper copies the current checkout once to the Linux-native
ernie-pest-workspace Docker volume. It then follows the CI-safe split:
- tests marked
serial - the
Archtestsuite without coverage - all remaining Unit and Feature tests in parallel without coverage
The default worker count is half of the available CPUs, rounded down, with a minimum of one and a maximum of eight. Use a measured override only when the local Docker resource allocation differs substantially:
ERNIE_PEST_PROCESSES=4 npm run test:phpSet ERNIE_PEST_PROFILE=1 to add Pest's slowest-test report to every complete
suite phase. Focused paths and filters still run directly against the checkout,
so generated snapshots and other intentional source changes are not trapped in
the disposable test workspace:
npm run test:php -- tests/pest/Unit/Support/UrlNormalizerTest.phpAfter a failure, rerun the failing path first. Run the complete suite again only after the focused failure passes; the 2 GB wrapper settings must not be replaced with the container's former 512 MB limit.
Use TIA for the short local feedback loop after the first baseline has been recorded:
npm run test:php:tiaThe wrapper enables Xdebug coverage inside the app container only for TIA. The normal test:php command and CI continue to execute the complete suite. Structural dependency changes invalidate the local TIA graph automatically; use npm run test:php:tia -- --fresh if a manual rebuild is needed.
The Agent plugin runs disposable verification snippets with the real Laravel/Pest setup. Keep the outer quotes single so the shell does not expand PHP variables:
npm run test:php:agent -- 'expect(\App\Models\User::query()->count())->toBeInt();'Turn a useful probe into a permanent test whenever it protects behavior that can regress.
PHPStan uses Pest-aware type inference at level 8. The first migration slice covers the enum tests; expand the test paths in phpstan.neon as legacy test typing is repaired instead of masking findings with a baseline:
npm run phpstan:checkType coverage remains an explicit, slower quality check. The Pest 5 baseline covers all 743 configured PHP source files at 92.93%, so the reproducible command enforces a conservative 92% floor. Mutation testing was evaluated against a focused, fully covered unit: all 35 tests passed, but the stable Pest 5.0.0 mutation plugin then failed internally because it still expects the pre-PHPUnit-13 code-coverage API. Pest itself currently requires the plugin, but Ernie does not expose a broken mutation command. Pest Rector was evaluated in dry-run mode, but its broad style set would rewrite 341 existing test files and was therefore not retained as a dependency.
Why backend validation stays Docker-backed:
- PHP version and extensions remain aligned with the local app container.
- Laravel configuration matches the local Docker runtime.
- Windows developers do not need a separate local PHP installation.
- Complete runs avoid Docker Desktop bind-mount overhead through a Linux-native synchronized test workspace.
- Deprecation detail mode has a dedicated npm script because some npm versions treat forwarded
--display-*flags as npm config and emit warning noise.
Host prerequisite:
npm ciRecommended commands:
npm run lint:check
npm run types
npm run test:runTypeScript 7 uses four parallel type checkers for the application and test projects. This fixed value was the fastest configuration in local measurements across two to twelve checkers while avoiding the substantially higher memory use of larger worker counts.
For continuous application feedback during development, run the native TypeScript 7 watcher alongside the Docker development stack. Use the separate test watcher when editing Vitest types or helpers:
npm run types:watch
npm run types:watch:testVitest can repeat a focused test file to expose flaky behavior without multiplying the complete suite:
npm run test:run -- tests/vitest/path/to/file.test.tsx --repeats=5Vitest 5 reports performance hints when its timing data indicates a likely configuration improvement. For a measured comparison of pools, isolation, DOM environments, worker counts, and the filesystem module cache, run:
npm run test:doctorDoctor executes the complete suite several times. Use it for deliberate performance work, not as part of the normal validation loop. Adopt a suggested setting only after its result is reproducible and the complete suite remains green with shuffled file order and normal project isolation requirements.
The Vitest 5 migration measurement on the standard Node 26.8.1 workstation
confirmed the existing defaults: the isolated thread-pool baseline took
320.57 seconds, while four workers took 533.83 seconds (+67%). The VM pools
failed because the MSW setup needs a global WritableStream, and the
non-isolated run exceeded four times the baseline while exposing shared-state
failures. Keep the local eight-worker thread pool and per-file isolation.
For slow startup or import-heavy tests, print the import-duration breakdown before changing optimizer settings:
npm run test:run -- tests/vitest/path/to/file.test.tsx --experimental.importDurations.printThe persistent filesystem module cache is stable in Vitest 5 but remains opt-in because Wayfinder and other plugin inputs must be invalidated correctly. It can be compared on focused repeated runs and cleared explicitly:
npm run test:run -- tests/vitest/path/to/file.test.tsx --fsModuleCache
npx vitest --clearCacheThe cache is intentionally not enabled by default. The full Vitest 5 Doctor run measured 319.29 seconds with a warm cache versus 320.57 seconds without it, which is not a meaningful improvement. The suite is dominated by DOM interactions rather than module transformation.
Vitest 5 reserves toMatchTextContent for Browser Mode. In the host-side jsdom
suite, use toHaveTextContent for string assertions and Vitest's regular
toMatch against element.textContent when a regular expression is needed.
The large DataCite form suite is registered through six datacite-form.part-*.test.tsx entrypoints. They distribute direct tests while keeping nested describe groups intact, allowing Vitest to schedule the formerly serial suite across isolated workers. Keep shared tests and setup in datacite-form.test-suite.tsx; do not add that support file to the Vitest include pattern.
Local Vitest runs use the faster thread pool and default to half of the
available CPUs, rounded down, with a minimum of one and a maximum of eight. On
the standard 16-CPU workstation, using all 16 workers oversubscribes the
CPU-heavy jsdom DataCite form suites and causes otherwise healthy tests to miss
their timeouts. Override the calculated limit only for a measured reason with
ERNIE_VITEST_WORKERS=<n>; CI keeps its own shard and worker allocation.
If your host cannot start Laravel Artisan locally, start the Docker backend stack before Vitest:
npm run docker:dev:backend:d
npm run test:runThe Vitest wrapper checks whether the host can run php artisan ernie:wayfinder-generate --with-form before starting Vitest. The check writes to a temporary directory, so it does not touch the committed Wayfinder output. It also has a timeout, so a hanging host Artisan process falls back to Docker instead of blocking Vitest startup.
On Windows, the probe resolves PowerShell's active php command and executes
the PHP binary selected by Laravel Herd's php.bat shim directly. This
preserves Herd's version selection even when an older Herd Lite php.exe also
appears later in PATH, and ensures a timed-out probe cannot leave a child PHP
process writing into the temporary directory.
If that host check fails, the wrapper prints the failing command, the exit reason, and any captured output before falling back to the app container for Wayfinder route generation. Keep the Docker backend stack running for that fallback path:
npm run docker:dev:backend:dWAYFINDER_COMMAND is the supported escape hatch for custom setups, for example:
WAYFINDER_COMMAND="php artisan ernie:wayfinder-generate" npm run test:runThe separate vitest.browser.config.ts deliberately contains only browser-test transforms. Laravel HMR and Wayfinder generation stay in the main Vite configuration: Vitest 5 starts multiple browser environments, and generating files from their buildStart hooks can repeatedly invalidate the browser test server. Generate Wayfinder sources before introducing or running browser tests that import them.
Why frontend validation stays on the host:
- Host-side Node feedback is faster than spawning short-lived container commands.
npm run testremains available for watch mode, but it is not the default validation command.npm run lintremains the auto-fix command, whilenpm run lint:checkis the safe validation path.
CI formatter jobs are non-mutating and check the complete frontend and PHP codebases. Use npm run format and npm run lint for frontend auto-fixes; run Pint without --test when intentionally applying PHP formatting changes.
Use the Docker dev stack behind Traefik:
npm run docker:dev:up:d
npm run test:e2e:devstackThis path exercises the local routing setup at https://ernie.localhost:3333.
Use stage only when the problem is known to be stage-specific or was explicitly reported there:
npm run test:e2e:stage- Run local coverage only when targeted feedback is needed.
- Keep day-to-day backend runs on
--no-coverage. - Let CI remain the primary source of complete coverage reporting.
- CI runs the Vitest coverage suite on three machines with
--shard=1/3,--shard=2/3, and--shard=3/3. Each machine uploads its Vitest 5 blob report from.vitest/blob; the finalvitestjob merges all test and V8 coverage results before uploading the single completecoverage/lcov.infoto Codecov. - Keep the blob upload and merge job together when changing the workflow. Uploading either shard's partial LCOV report would make the Codecov result incomplete.
- CI runs the serial and architecture Pest slices alongside two disjoint shards of the remaining test suite. Serial and parallel tests collect the configured line coverage with PCOV into separate Clover reports; the final
Pest PHP Testsjob uploads all three reports together. PCOV remains rooted at the repository soroutes/,config/, anddatabase/can contribute coverage, whilevendor/andtests/are excluded before instrumentation. Each parallel shard gives the coverage-merging parent process 4 GB of memory while its ParaTest workers retain the configured 1 GB limit. Architecture tests remain coverage-free because their structural assertions do not produce meaningful runtime coverage.
Backend change:
npm run check:backendFrontend change:
npm run check:frontendCross-stack or browser-facing change:
npm run check:backend
npm run check:frontend
npm run check:parity