diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 55b997a63..0774f2320 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -2,9 +2,6 @@ name: Set up the workspace description: pnpm, Node, dependencies, and optionally a Playwright browser, the Naga validator and the built dist. inputs: - node-version: - description: Node version for setup-node - default: '24.x' browser: description: Playwright browser to install (chromium or firefox); empty installs none default: '' @@ -26,9 +23,11 @@ runs: with: run_install: false + # The Node version comes from `.nvmrc`, the same file `devEngines` in + # package.json enforces locally, so CI and a contributor's shell agree. - uses: actions/setup-node@v6 with: - node-version: ${{ inputs.node-version }} + node-version-file: .nvmrc check-latest: true cache: pnpm cache-dependency-path: pnpm-lock.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6a264746..09929d572 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,7 +89,7 @@ jobs: # No install: the planner is dependency-free TypeScript that node strips. - uses: actions/setup-node@v6 with: - node-version: '24.x' + node-version-file: .nvmrc - id: plan env: @@ -303,7 +303,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: - node-version: '24.x' + node-version-file: .nvmrc - env: NEEDS: ${{ toJSON(needs) }} PLAN: ${{ toJSON(needs.plan.outputs) }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5284a4f9e..967e80f03 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,7 +52,7 @@ jobs: # the developer's machine. - uses: actions/setup-node@v6 with: - node-version: '24.x' + node-version-file: .nvmrc - id: resolve env: @@ -144,7 +144,7 @@ jobs: - uses: ./.github/actions/setup - uses: actions/setup-node@v6 with: - node-version: '24.x' + node-version-file: .nvmrc registry-url: 'https://registry.npmjs.org' - uses: actions/download-artifact@v4 diff --git a/.gitignore b/.gitignore index 38cd110f7..0367fb21f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ docs/ !.gitattributes !.gitignore !.gitkeep +!.nvmrc !.prettierignore !.prettierrc diff --git a/.husky/pre-push b/.husky/pre-push index 4ab22c8f2..ca8eeff6d 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -24,10 +24,6 @@ # push that brought the commit to main; a tag that would be # rejected remotely is rejected here first. # -# Also, on branch pushes only: a path-gated `@codexo/exojs-bench` typecheck -# (see the block below verify:quick — it is intentionally NOT part of -# verify:quick/CI; see packages/exojs-bench/README.md for why). -# # Refs being pushed are read from stdin in the format: # is_tag_push=0 @@ -105,43 +101,3 @@ if [ "$is_branch_push" = "1" ]; then npm run lanes -- --run --tests-only --all || exit 1 fi fi - -# Path-gated bench typecheck. @codexo/exojs-bench is deliberately kept out of -# verify:quick / CI: its competitor devDependencies (pixi/phaser/excalibur/ -# matter/rapier) only resolve via `bench:setup`'s `pnpm install --dir -# competitors --ignore-workspace`, which bypasses pnpm-workspace.yaml's -# minimumReleaseAge supply-chain gate — installing that inside the shared-CI -# trust boundary is exactly what we don't want. So this hook is the only -# automated backstop, and it stays cheap for everyone else: -# - fires ONLY when the pushed commits touch packages/exojs-bench/** (zero -# cost otherwise — no diff, no pnpm call); -# - only runs the actual typecheck when the competitor deps are already -# linked locally (never forces the ~235MB bench:setup install on push — -# that would punish a push just because the optional deps aren't there -# yet, so it prints a warning and skips instead of failing). -# Residual gap (see packages/exojs-bench/README.md): an engine API change -# under src/ that breaks the bench adapters' types, without ALSO touching -# packages/exojs-bench/**, is not caught here. -if [ "$is_branch_push" = "1" ] && [ -n "$push_head_sha" ]; then - if [ -z "$push_base_sha" ]; then - # New branch / no remote tracking ref yet — fall back to the - # merge-base with origin/HEAD so the check still has a diff range. - push_base_sha=$(git merge-base "$push_head_sha" origin/HEAD 2>/dev/null || true) - fi - - bench_changed="" - if [ -n "$push_base_sha" ]; then - bench_changed=$(git diff --name-only "$push_base_sha" "$push_head_sha" -- packages/exojs-bench 2>/dev/null) - fi - - if [ -n "$bench_changed" ]; then - if [ -e packages/exojs-bench/node_modules/pixi.js ]; then - echo "[pre-push] packages/exojs-bench changed — running bench typecheck" - pnpm --filter @codexo/exojs-bench typecheck || exit 1 - else - echo "[pre-push] WARNING: packages/exojs-bench changed but its competitor deps aren't linked locally." - echo "[pre-push] Run 'pnpm --filter @codexo/exojs-bench bench:setup' (or 'pnpm typecheck:bench') to type-check it, then push again." - echo "[pre-push] Skipping bench typecheck for this push (not blocking)." - fi - fi -fi diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..a45fd52cc --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/.prettierignore b/.prettierignore index 83a43362e..166c28289 100644 --- a/.prettierignore +++ b/.prettierignore @@ -27,3 +27,6 @@ packages/exojs-bench/results/*.json *.min.* pnpm-lock.yaml + +# API docs are generated here before being moved into src/content/api. +site/.api-staging diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd1154720..7a530e55d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,9 +32,17 @@ the file. `pnpm release:changelog` previews what the cut would add. Run this once per clone: ```sh +pnpm bootstrap:dev git config pull.ff only ``` +`bootstrap:dev` installs the dependencies, the git hooks, every build output, the benchmark +competitor libraries and a Chromium for the browser lanes, then runs `pnpm doctor` - which +you can run again at any time to see what a clone is missing and how to fix it. The plain +`pnpm bootstrap` is the CI form: dependencies and build tooling only, with install scripts +disabled - which means it does not itself install the git hooks. `pnpm exec husky` adds them, +and `pnpm doctor` says whether they are there. + `main` and `next` only ever advance through a reviewed PR, so a local commit on either cannot reach the remote — it just makes the branch diverge from its remote counterpart. On a diverged branch, Git's default `pull` silently builds diff --git a/README.md b/README.md index ddc1850e6..c9ad9f3db 100644 --- a/README.md +++ b/README.md @@ -214,13 +214,27 @@ new Application({ backend: { type: 'auto' } }); // default ## Development +Prerequisites: Node 24 (`.nvmrc`; `devEngines` in `package.json` refuses any other major) and +pnpm (`packageManager` pins the version; with Corepack enabled, or any installed pnpm 10+, it +switches itself). + +```bash +pnpm bootstrap:dev # dependencies, git hooks, every build, the bench competitors, a Chromium +pnpm doctor # what is missing, and the command that fixes it +``` + +`pnpm bootstrap` alone is what CI runs: dependencies and the build tooling, nothing else. It +installs with scripts disabled, so whether a clone ends up with git hooks depends on whether pnpm +ran an install of its own first - and it builds nothing. `pnpm doctor` reports the actual state +either way. + ```bash -pnpm bootstrap pnpm typecheck pnpm lint pnpm test -pnpm build +pnpm build:all # core plus every extension package pnpm verify:package +pnpm clean:artifacts # what a local test, benchmark or release run left behind ``` Package-internal imports use Node `package.json#imports` subpath imports: `./X` for the same directory, `#dir/X` for any other path in the same package, and the public bare specifier (`@codexo/exojs`) across packages. See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full import policy, per-package commands, and the shared `@codexo/exojs-config` tooling. Building the library requires TypeScript 6. diff --git a/package.json b/package.json index aeeddf007..4d1619433 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,13 @@ "version": "0.17.0", "type": "module", "packageManager": "pnpm@11.4.0", + "devEngines": { + "runtime": { + "name": "node", + "version": "^24", + "onFail": "error" + } + }, "files": [ "dist/esm/", "dist/exo.esm.js", @@ -73,10 +80,16 @@ "{src,test,examples,scripts,packages}/**/*.{ts,tsx,mts,cts}": "eslint --fix --no-warn-ignored --max-warnings=0" }, "scripts": { - "bootstrap": "pnpm install --frozen-lockfile --ignore-scripts && pnpm build:tooling && pnpm bench:setup", + "bootstrap": "pnpm install --frozen-lockfile --ignore-scripts && pnpm build:tooling", + "bootstrap:dev": "pnpm bootstrap && pnpm exec husky && pnpm build:all && pnpm bench:setup && pnpm exec playwright install chromium && pnpm doctor", + "doctor": "tsx ./scripts/doctor.ts", "build:tooling": "pnpm --filter @codexo/exojs-build build", "clean": "rimraf dist", + "clean:artifacts": "tsx ./scripts/clean-artifacts.ts", + "clean:all": "tsx ./scripts/clean-artifacts.ts --all", "build": "pnpm build:tooling && pnpm clean && tsx scripts/build.ts", + "build:packages": "pnpm -r --filter \"@codexo/exojs-*\" --filter \"!@codexo/exojs-examples\" --filter \"!@codexo/exojs-build\" --filter \"!@codexo/exojs-config\" --filter \"!@codexo/exojs-bench\" build", + "build:all": "pnpm build && pnpm build:packages", "build:dev": "pnpm build:tooling && pnpm clean && tsx scripts/build.ts --dev", "build:watch": "tsx scripts/build.ts --dev --watch", "verify:exports": "tsx ./scripts/verify-exports.ts", @@ -143,10 +156,11 @@ "lint:shaders": "tsx scripts/check-shader-sources.ts", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "vitest run --project=exojs --project=exojs-build --project=exojs-particles --project=exojs-tilemap --project=exojs-tiled --project=exojs-physics --project=exojs-tilemap-physics --project=exojs-lighting --project=exojs-pathfinding --project=exojs-audio-fx --project=exojs-aseprite --project=exojs-ldtk --project=exojs-react --project=rendering-perf --project=rendering-alloc", + "test": "vitest run --project=exojs --project=exojs-build --project=exojs-particles --project=exojs-tilemap --project=exojs-tiled --project=exojs-physics --project=exojs-tilemap-physics --project=exojs-lighting --project=exojs-pathfinding --project=exojs-audio-fx --project=exojs-aseprite --project=exojs-ldtk --project=exojs-react --project=exojs-bench --project=rendering-perf --project=rendering-alloc", "test:core": "vitest run --project=exojs", "test:coverage": "vitest run --coverage --project=exojs --project=exojs-build --project=exojs-particles --project=exojs-tilemap --project=exojs-tiled --project=exojs-physics --project=exojs-tilemap-physics --project=exojs-lighting --project=exojs-pathfinding --project=exojs-audio-fx --project=exojs-aseprite --project=exojs-ldtk --project=exojs-react --project=rendering-perf", "test:alloc": "vitest run --project=rendering-alloc", + "test:physics-perf": "vitest run --project=physics-perf", "test:watch": "vitest --project=exojs", "test:production-stripping": "vitest run --project=exojs test/build-defines/production-stripping.test.ts", "test:skips": "pnpm run test --reporter=default --reporter=junit --outputFile.junit=./test-results/unit.junit.xml && tsx scripts/check-skipped-tests.ts", @@ -168,9 +182,7 @@ "test:parity:webkit": "tsx ./scripts/run-parity.ts --project=browser-parity-webkit", "test:parity:safari": "tsx ./scripts/run-parity.ts --project=browser-parity-safari", "webgpu:probe": "tsx scripts/webgpu-probe.ts", - "bench": "vitest bench", - "bench:run": "vitest bench --run", - "site:install": "pnpm bootstrap", + "bench:micro": "vitest bench --run", "site:build": "tsx scripts/check-dist-fresh.ts && pnpm --filter @codexo/exojs-examples build", "site:build:api": "pnpm --filter @codexo/exojs-examples build:api", "test:examples:smoke": "pnpm --filter @codexo/exojs-examples examples:smoke", diff --git a/packages/exojs-bench/README.md b/packages/exojs-bench/README.md index 5b1084378..b4fb68b0d 100644 --- a/packages/exojs-bench/README.md +++ b/packages/exojs-bench/README.md @@ -681,65 +681,38 @@ caveats stated with the result, and a defined process for re-measuring when any those move. Until that exists, a cross-library number measured here is an engineering signal for the maintainers, not a claim. -## Why this package is out of required CI - -`bench:setup` runs `pnpm install --dir competitors --ignore-workspace`. That -`--ignore-workspace` install: - -- resolves a **separate lockfile** outside `pnpm-workspace.yaml`, so it - bypasses the root workspace's `minimumReleaseAge` supply-chain quarantine - (see `pnpm-workspace.yaml`) — a version bump here needs a manual release-age - sanity check instead of the automatic gate everything else gets; -- pulls in ~235MB of competitor libraries that a normal contributor should - never have to download just to typecheck their own PR. - -Running that inside the shared-CI trust boundary (a required, always-on gate) -would mean every contributor's PR — and the shared CI runners — install and -trust third-party libraries whose only purpose is being compared against, not -shipped. So `@codexo/exojs-bench` is deliberately excluded from -`typecheck:packages` / `verify:quick` / CI. A standalone `typecheck:bench` -root script exists for on-demand/manual runs: - -**The one exception is the structural gate**, and it is an exception precisely -because it needs none of that: it measures only the ExoJS arms on the software -rasterizer, so its CI job installs no competitor library and needs no GPU. -Nothing in that job runs `bench:setup`, so the competitor packages never enter -the CI trust boundary. It is path-gated on the rendering source, the harness and -the baseline itself — narrower than the `engine` area, since a change to audio or -input cannot move a draw-call count. - -```sh -pnpm typecheck:bench # bench:setup + typecheck, in one step -``` - -## Local backstop: the pre-push hook - -`.husky/pre-push` runs a **path-gated, local-only** check on branch pushes: - -- it fires **only** when the commits being pushed touch - `packages/exojs-bench/**` — zero cost for every other push; -- if the competitor deps are already linked locally (i.e. - `packages/exojs-bench/node_modules/pixi.js` exists from a prior - `bench:setup`), it runs `pnpm --filter @codexo/exojs-bench typecheck` and - **fails the push** on a type error; -- if they aren't linked, it prints a warning telling you to run `bench:setup` - and **skips without failing** — an optional, uninstalled dependency should - never block an unrelated push. - -## Known gap - -This is a local, path-gated backstop, not a CI gate — it only runs on the -machine that pushes a bench-touching commit, and only if that machine has -already run `bench:setup`. An engine API change under `src/` that breaks the -bench adapters' types, without a commit that also touches -`packages/exojs-bench/**`, is not caught by this hook. - -The structural-gate CI lane closes part of that gap, but only part: a change -under `src/rendering/` now runs the harness (and therefore compiles and executes -the ExoJS adapters) in CI, so a break there fails a PR. A change elsewhere under -`src/` still does not, and neither does anything that only affects a competitor -adapter. This is -an accepted trade-off to keep the bench package's ~235MB of competitor -dependencies out of the shared-CI trust boundary entirely. A future -self-hosted-GPU bench tier (see the engine's perf-tracking roadmap) is the -right place to run a full, unconditional `typecheck:bench` as a real backstop. +## What runs in CI, and what does not + +Three different things live in this package, and CI treats them differently. + +**The harness's own tests** (`test/`) exercise the profile contract, the slug, +the signature, the run pooling and the archetype definitions. They need none +of the competitor libraries - the adapters that import those are loaded by +`import()` inside the benchmark page, never by a test - so they run in the +ordinary `pnpm test` project list on every push, like any other package. + +**The typecheck** does need the competitors: the adapters are typed against +their APIs, which is exactly what catches an upstream change on a version +bump. `pnpm typecheck:bench` installs them first. It runs in the path-gated +`bench` CI lane, alongside the structural gate, whenever a change touches this +package, the rendering source or the baselines. + +**Measurements** never run in CI. A shared runner is neither idle nor a known +machine, and a number it produced would carry provenance nobody can reproduce. +Reference profiles are measured by hand on an idle machine and committed as +signed files - see `results/README.md`. + +## The competitor install and the supply-chain gate + +`bench:setup` runs `pnpm install --dir competitors --frozen-lockfile`. The +`competitors/` directory is its own workspace root (it carries a +`pnpm-workspace.yaml`), which does two things: a plain root `pnpm install` +never resolves or downloads anything in it, so a contributor who never +benchmarks pays nothing for ~235MB of libraries whose only purpose is being +compared against; and the install applies the same `minimumReleaseAge` +quarantine the repository workspace enforces, so a version bump here is held +back exactly as long as any other dependency. The lockfile is frozen in both +CI and local use: a new version enters through a reviewed lockfile change, not +through an install. + +`pnpm doctor` reports whether the competitors are linked. diff --git a/packages/exojs-bench/competitors/package.json b/packages/exojs-bench/competitors/package.json index eac77b2f7..64da46291 100644 --- a/packages/exojs-bench/competitors/package.json +++ b/packages/exojs-bench/competitors/package.json @@ -2,7 +2,7 @@ "name": "exojs-bench-competitors", "version": "0.0.0", "private": true, - "description": "Pinned exact-version competitor libraries for @codexo/exojs-bench (Pixi, Phaser, Excalibur, matter-js, planck, rapier2d-compat arms). Deliberately NOT a pnpm-workspace.yaml member: a plain root `pnpm install` never resolves or downloads anything here, so a normal contributor pays zero weight for competitor libraries. Only `pnpm --filter @codexo/exojs-bench bench:setup` installs this folder (via `pnpm install --ignore-workspace`, so a supply-chain review of a version bump should also sanity-check its release age by hand -- `--ignore-workspace` means the root pnpm-workspace.yaml's minimumReleaseAge gate does not apply here) and links the results into ../node_modules so the adapters' plain `import 'pixi.js'` (etc.) resolve unmodified.", + "description": "Pinned exact-version competitor libraries for @codexo/exojs-bench (Pixi, Phaser, Excalibur, matter-js, planck, rapier2d-compat arms). Deliberately NOT a member of the repository workspace: a plain root `pnpm install` never resolves or downloads anything here, so a normal contributor pays zero weight for competitor libraries. Only `pnpm --filter @codexo/exojs-bench bench:setup` installs this folder - as its own workspace root (see pnpm-workspace.yaml beside this file), which applies the same minimumReleaseAge supply-chain quarantine as the repository workspace - and links the results into ../node_modules so the adapters' plain `import 'pixi.js'` (etc.) resolve unmodified.", "dependencies": { "pixi.js": "8.19.0", "phaser": "4.2.1", diff --git a/packages/exojs-bench/competitors/pnpm-workspace.yaml b/packages/exojs-bench/competitors/pnpm-workspace.yaml new file mode 100644 index 000000000..45f10520c --- /dev/null +++ b/packages/exojs-bench/competitors/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +# Standalone install root for the benchmark competitor libraries. Keeping a +# workspace file here makes pnpm stop its upward lookup at this directory, so +# the install never joins the repository workspace - and it applies the same +# supply-chain quarantine the root workspace enforces, which an +# --ignore-workspace install would silently skip. +minimumReleaseAge: 4320 diff --git a/packages/exojs-bench/package.json b/packages/exojs-bench/package.json index a6396d4fd..5f6e6760a 100644 --- a/packages/exojs-bench/package.json +++ b/packages/exojs-bench/package.json @@ -5,7 +5,7 @@ "private": true, "type": "module", "scripts": { - "bench:setup": "pnpm install --dir competitors --ignore-workspace && node competitors/link.ts", + "bench:setup": "pnpm install --dir competitors --frozen-lockfile && node competitors/link.ts", "bench": "node --conditions=@codexo/exojs-source --import ../../scripts/glsl-register.ts --import tsx/esm src/run.ts", "perf:baseline": "node --conditions=@codexo/exojs-source --import ../../scripts/glsl-register.ts --import tsx/esm src/run.ts", "gate:timing": "node --conditions=@codexo/exojs-source --import ../../scripts/glsl-register.ts --import tsx/esm src/runTimingGate.ts", diff --git a/packages/exojs-physics/test/fields.ts b/packages/exojs-physics/test/fields.ts new file mode 100644 index 000000000..a68a089dd --- /dev/null +++ b/packages/exojs-physics/test/fields.ts @@ -0,0 +1,52 @@ +/** + * Shared scene builder for the physics performance suites. + * + * The field is the scene both suites measure: independent box columns settled + * on a static floor, so the steady state is persistent contacts plus the + * broad-phase load of one AABB per body. + */ +import { BoxShape, PhysicsWorld } from '../src/index'; +import { PhysicsBody } from '../src/PhysicsBody'; + +export const FRAME = 1 / 60; + +/** A wide field of `columns` independent `rows`-high box stacks on a static floor. */ +export const buildField = (columns: number, rows: number, worldOptions: { enableSleeping?: boolean } = {}): { world: PhysicsWorld; bodies: PhysicsBody[] } => { + const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 }, ...worldOptions }); + const size = 16; + const spacing = 20; + const floorTop = 1000; + const width = columns * spacing + 200; + + world.add(new PhysicsBody({ type: 'static', position: { x: width / 2, y: floorTop + 20 }, colliders: [{ shape: new BoxShape(width, 40), friction: 0.5 }] })); + + const bodies: PhysicsBody[] = []; + + for (let c = 0; c < columns; c++) { + const x = 100 + c * spacing; + + for (let r = 0; r < rows; r++) { + const body = world.add( + new PhysicsBody({ + type: 'dynamic', + position: { x, y: floorTop - size / 2 - 1 - r * size }, + colliders: [{ shape: new BoxShape(size, size), density: 1, friction: 0.5 }], + }), + ); + + bodies.push(body); + } + } + + return { world, bodies }; +}; + +export const stepTimes = (world: PhysicsWorld, steps: number): number => { + const start = performance.now(); + + for (let i = 0; i < steps; i++) { + world.step(FRAME); + } + + return (performance.now() - start) / steps; +}; diff --git a/packages/exojs-physics/test/perf.test.ts b/packages/exojs-physics/test/perf.test.ts index 8edbcaf7b..db9454e8a 100644 --- a/packages/exojs-physics/test/perf.test.ts +++ b/packages/exojs-physics/test/perf.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { BoxShape, PhysicsWorld } from '../src/index'; -import { PhysicsBody } from '../src/PhysicsBody'; import { measureAllocationRate } from './allocationSampler'; +import { buildField, FRAME, stepTimes } from './fields'; /** * Performance gates: steady-state allocation and 1,000-body step time. The @@ -22,49 +21,6 @@ import { measureAllocationRate } from './allocationSampler'; * allocation-free), removable only by an invasive typed-array rewrite (post-1.0). */ -const FRAME = 1 / 60; - -/** A wide field of `columns` independent `rows`-high box stacks on a static floor. */ -const buildField = (columns: number, rows: number, worldOptions: { enableSleeping?: boolean } = {}): { world: PhysicsWorld; bodies: PhysicsBody[] } => { - const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 }, ...worldOptions }); - const size = 16; - const spacing = 20; - const floorTop = 1000; - const width = columns * spacing + 200; - - world.add(new PhysicsBody({ type: 'static', position: { x: width / 2, y: floorTop + 20 }, colliders: [{ shape: new BoxShape(width, 40), friction: 0.5 }] })); - - const bodies: PhysicsBody[] = []; - - for (let c = 0; c < columns; c++) { - const x = 100 + c * spacing; - - for (let r = 0; r < rows; r++) { - const body = world.add( - new PhysicsBody({ - type: 'dynamic', - position: { x, y: floorTop - size / 2 - 1 - r * size }, - colliders: [{ shape: new BoxShape(size, size), density: 1, friction: 0.5 }], - }), - ); - - bodies.push(body); - } - } - - return { world, bodies }; -}; - -const stepTimes = (world: PhysicsWorld, steps: number): number => { - const start = performance.now(); - - for (let i = 0; i < steps; i++) { - world.step(FRAME); - } - - return (performance.now() - start) / steps; -}; - describe('physics dynamics performance', () => { it('1,000-body settled field: step time + steady-state allocation', async () => { const { world, bodies } = buildField(200, 5); @@ -135,49 +91,4 @@ describe('physics dynamics performance', () => { // (~484 KB/step) trips it. expect(bytesPerStep).toBeLessThan(250 * 1024); }); - - it('5,000-mostly-sleeping field: sleeping sharply cuts step time', () => { - // Baseline: the identical field with sleeping disabled stays fully active. - const awake = buildField(1000, 5, { enableSleeping: false }); - - // Skipped entirely under istanbul coverage: instrumentation inflates the - // per-step cost enough (see the identical `cov_` guard above) that the full - // 840-step awake+sleeping budget across two 5,000-body fields blows even the - // 60s timeout below. The sharp gate runs in the normal `pnpm test` run + - // `verify:ci`. - if (awake.world.step.toString().includes('cov_')) { - console.log('sleeping-vs-awake perf gate skipped under coverage (instrumentation slows the measurement past the timeout)'); - - return; - } - - for (let i = 0; i < 240; i++) { - awake.world.step(FRAME); - } - - const awakeMs = stepTimes(awake.world, 120); - - // Sleeping on (default): let the field settle and nap. - const sleeping = buildField(1000, 5, { enableSleeping: true }); - - for (let i = 0; i < 360; i++) { - sleeping.world.step(FRAME); - } - - const sleptCount = sleeping.bodies.filter(body => body.isSleeping).length; - const sleepingMs = stepTimes(sleeping.world, 120); - - expect(sleeping.bodies.length).toBe(5000); - console.log( - `awake ${awakeMs.toFixed(3)} ms/step vs sleeping ${sleepingMs.toFixed(3)} ms/step · ${sleptCount}/5000 asleep (${(awakeMs / sleepingMs).toFixed(1)}× faster)`, - ); - - // The vast majority of a settled field naps, and skipping their integration - // and constraint solve sharply cuts the per-step cost (measured ~3.4× faster - // on the reference machine - the remainder is detection, which still runs). - // The gate is a relative ratio (same machine, sleeping vs awake), so it is - // machine-independent; ≥2× leaves headroom for variance. - expect(sleptCount).toBeGreaterThan(4500); - expect(sleepingMs).toBeLessThan(awakeMs * 0.5); - }, 60_000); }); diff --git a/packages/exojs-physics/test/sleeping-perf.test.ts b/packages/exojs-physics/test/sleeping-perf.test.ts new file mode 100644 index 000000000..25353b44c --- /dev/null +++ b/packages/exojs-physics/test/sleeping-perf.test.ts @@ -0,0 +1,75 @@ +/** + * Sleeping gate: a settled field must skip the work its sleeping bodies would + * otherwise cost. + * + * Split out of the main performance suite because this is the one assertion + * here that reads WALL-CLOCK time. Wall-clock is not merely machine-dependent, + * it is load-dependent: run alongside the rest of the test suite, the light + * sleeping phase loses proportionally more to scheduling gaps than the heavy + * awake phase does, the ratio collapses (measured 3.4x alone against 1.8x under + * the parallel suite) and the gate fails without anything having regressed. + * + * So it runs in its own project, invoked separately after the parallel suite + * rather than inside it. Keep it that way: the assertion is only meaningful + * when this process has the machine to itself. + * + * The behavioural half of the claim - that a settled field falls asleep at all - + * carries no timing and is covered by `sleeping.test.ts` and + * `sleep-penetration.test.ts`, which stay in the main suite and catch a + * regression in the sleep bookkeeping regardless of load. What is left here is + * the part only a clock can show: that the solver skips the sleepers' work + * rather than merely flagging them. + */ +import { describe, expect, it } from 'vitest'; + +import { buildField, FRAME, stepTimes } from './fields'; + +describe('physics sleeping performance', () => { + it('5,000-mostly-sleeping field: sleeping sharply cuts step time', () => { + // Baseline: the identical field with sleeping disabled stays fully active. + const awake = buildField(1000, 5, { enableSleeping: false }); + + // Skipped entirely under istanbul coverage: instrumentation inflates the + // per-step cost enough (see the identical `cov_` guard above) that the full + // 840-step awake+sleeping budget across two 5,000-body fields blows even the + // 60s timeout below. The sharp gate runs in the normal `pnpm test` run + + // `verify:ci`. + if (awake.world.step.toString().includes('cov_')) { + console.log('sleeping-vs-awake perf gate skipped under coverage (instrumentation slows the measurement past the timeout)'); + + return; + } + + for (let i = 0; i < 240; i++) { + awake.world.step(FRAME); + } + + const awakeMs = stepTimes(awake.world, 120); + + // Sleeping on (default): let the field settle and nap. + const sleeping = buildField(1000, 5, { enableSleeping: true }); + + for (let i = 0; i < 360; i++) { + sleeping.world.step(FRAME); + } + + const sleptCount = sleeping.bodies.filter(body => body.isSleeping).length; + const sleepingMs = stepTimes(sleeping.world, 120); + + expect(sleeping.bodies.length).toBe(5000); + console.log( + `awake ${awakeMs.toFixed(3)} ms/step vs sleeping ${sleepingMs.toFixed(3)} ms/step · ${sleptCount}/5000 asleep (${(awakeMs / sleepingMs).toFixed(1)}× faster)`, + ); + + // The vast majority of a settled field naps, and skipping their integration + // and constraint solve sharply cuts the per-step cost (measured ~3.4× faster + // on the reference machine - the remainder is detection, which still runs). + // The ratio removes the machine, not the load: both arms run here, so a + // slower machine cancels out, but concurrent work does not - it costs the + // light sleeping arm proportionally more than the heavy awake one. Hence + // the dedicated project; the 2x gate against a measured ~3.4x leaves + // headroom for inlining variance across machines, not for a busy one. + expect(sleptCount).toBeGreaterThan(4500); + expect(sleepingMs).toBeLessThan(awakeMs * 0.5); + }, 60_000); +}); diff --git a/scripts/artifacts.ts b/scripts/artifacts.ts new file mode 100644 index 000000000..1f263cca9 --- /dev/null +++ b/scripts/artifacts.ts @@ -0,0 +1,49 @@ +/** + * Directories a local run leaves behind, and which of them are safe to delete. + * + * Everything listed here is gitignored and regenerated by the step that wrote + * it. Private scratch directories are deliberately absent: by repository + * convention they may hold results someone means to keep. + */ +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +/** Outputs of a test, benchmark or release run. Deleting one loses nothing a rerun cannot produce. */ +export const RUN_ARTIFACTS: readonly string[] = [ + 'test-results', + '.vitest-attachments', + 'coverage', + '.release', + 'test/perf/results', + 'test/rendering/browser/__screenshots__', + 'test/rendering/parity/__screenshots__', + 'site/.api-staging', + 'site/dist', +]; + +/** Caches and synced inputs a build recreates; deleting one only costs the next build its warm start. */ +export const BUILD_CACHES: readonly string[] = ['.cache', 'site/.astro', 'site/public/vendor', 'site/public/assets', 'site/public/examples']; + +const directorySize = (dir: string): number => { + let total = 0; + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + + if (entry.isDirectory()) total += directorySize(full); + else if (entry.isFile()) total += statSync(full).size; + } + + return total; +}; + +export interface ArtifactPresence { + readonly path: string; + readonly bytes: number; +} + +/** The listed paths that currently exist under `root`, with their size on disk. */ +export const presentArtifacts = (root: string, paths: readonly string[]): ArtifactPresence[] => + paths.filter(path => existsSync(join(root, path))).map(path => ({ path, bytes: directorySize(join(root, path)) })); + +export const formatBytes = (bytes: number): string => (bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(0)} MB` : `${(bytes / 1024).toFixed(0)} KB`); diff --git a/scripts/check-dist-fresh.ts b/scripts/check-dist-fresh.ts index eb2f59faa..2a162d6d0 100644 --- a/scripts/check-dist-fresh.ts +++ b/scripts/check-dist-fresh.ts @@ -4,84 +4,32 @@ * The site build and the full-bundle export check read `dist/` (Core) and * `packages/exojs-*\/dist/` (extensions) rather than the sources. After a * pull or a local edit those artifacts silently lag behind: the site bundles - * an engine without the new export and the example smoke then reports a - * black canvas with no error; the export check names a symbol the package - * "does not export". Each of those wasted a diagnosis before this check - * existed. The smoke itself is not gated: it consumes the site build, which - * is checked here, and in CI it runs from a downloaded site artifact with no - * engine dist beside it. + * an engine without the new export and the example smoke then reports a black + * canvas with no error; the export check names a symbol the package "does not + * export". Each of those wasted a diagnosis before this check existed. The + * API docs generator is not gated: it converts from the sources. * - * Every build records a content hash of its source tree in its dist (see - * `source-hash.ts`); a unit is stale when the hash of the sources on disk no - * longer matches. A Core dist without a stamp is stale; a package that was - * never built is left to the consuming step's own error. Set - * `EXOJS_SKIP_DIST_CHECK=1` to bypass. + * A package that was never built is left to the consuming step's own error. + * Set `EXOJS_SKIP_DIST_CHECK=1` to bypass. */ - -import { existsSync, readdirSync } from 'node:fs'; -import { join, relative, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { hashSourceTree, readSourceStamp } from './source-hash.ts'; - -const root = resolve(fileURLToPath(new URL('..', import.meta.url))); - -interface BuildUnit { - readonly name: string; - readonly sourceDir: string; - readonly distDir: string; -} - -const TOOLING_PACKAGES = new Set(['exojs-build', 'exojs-config', 'exojs-bench', 'exojs-examples']); - -const units: BuildUnit[] = [{ name: '@codexo/exojs', sourceDir: join(root, 'src'), distDir: join(root, 'dist') }]; - -for (const entry of readdirSync(join(root, 'packages'), { withFileTypes: true })) { - if (!entry.isDirectory() || !entry.name.startsWith('exojs-')) continue; - - const dir = join(root, 'packages', entry.name); - const sourceDir = join(dir, 'src'); - const distDir = join(dir, 'dist'); - - // Only runtime extension packages build through the shared library pipeline; - // tooling, config, the bench harness and the site own their outputs and are - // never bundled by the checks. - if ( - TOOLING_PACKAGES.has(entry.name) || - !existsSync(sourceDir) || - !existsSync(join(dir, 'tsconfig.build.json')) || - !existsSync(join(distDir, 'esm', 'index.js')) - ) - continue; - - units.push({ name: `@codexo/${entry.name}`, sourceDir, distDir }); -} +import { checkFreshness, REBUILD_COMMAND } from './dist-freshness.ts'; if (process.env['EXOJS_SKIP_DIST_CHECK'] === '1') { console.log('check-dist-fresh: skipped (EXOJS_SKIP_DIST_CHECK=1).'); process.exit(0); } -const stale: string[] = []; - -for (const unit of units) { - const recorded = readSourceStamp(unit.distDir); - - if (recorded === null) { - stale.push(`${unit.name}: ${relative(root, unit.distDir)} carries no source stamp (built before stamps existed, or not at all)`); - } else if (recorded !== hashSourceTree(unit.sourceDir)) { - stale.push(`${unit.name}: ${relative(root, unit.sourceDir)} changed since ${relative(root, unit.distDir)} was built`); - } -} +const report = checkFreshness(); +const checked = report.units.length - report.unbuilt.length; -if (stale.length === 0) { - console.log(`check-dist-fresh: ${units.length} build unit(s) up to date.`); +if (report.stale.length === 0) { + console.log(`check-dist-fresh: ${checked} build unit(s) up to date.`); process.exit(0); } console.error('check-dist-fresh: dist is older than its sources; the step you are about to run would use a stale build.\n'); -for (const line of stale) console.error(` - ${line}`); +for (const line of report.stale) console.error(` - ${line}`); -console.error('\nRebuild with: pnpm build && pnpm -r --filter "@codexo/exojs-*" --filter "!@codexo/exojs-examples" build'); +console.error(`\nRebuild with: ${REBUILD_COMMAND}`); process.exit(1); diff --git a/scripts/ci/lanes.ts b/scripts/ci/lanes.ts index 05c4e0940..93f83f51c 100644 --- a/scripts/ci/lanes.ts +++ b/scripts/ci/lanes.ts @@ -58,11 +58,11 @@ export const LANES: readonly Lane[] = [ id: 'unit', stage: 'test', when: 'unit', - run: 'pnpm test && pnpm test:alloc', + run: 'pnpm test && pnpm test:alloc && pnpm test:physics-perf', // The WGSL tests validate through Naga when it is on PATH and skip // otherwise; CI installs it and refuses the skip. - ciRun: `EXOJS_REQUIRE_NAGA=1 pnpm test ${junit('unit')} && pnpm test:alloc`, - coverageRun: `EXOJS_REQUIRE_NAGA=1 pnpm test:coverage ${junit('unit')} && pnpm test:alloc`, + ciRun: `EXOJS_REQUIRE_NAGA=1 pnpm test ${junit('unit')} && pnpm test:alloc && pnpm test:physics-perf`, + coverageRun: `EXOJS_REQUIRE_NAGA=1 pnpm test:coverage ${junit('unit')} && pnpm test:alloc && pnpm test:physics-perf`, naga: true, junit: true, }, @@ -133,7 +133,12 @@ export const LANES: readonly Lane[] = [ id: 'bench', stage: 'test', when: 'benchStructural', - run: 'pnpm gate:bench:structural', + // The harness typecheck needs the competitor libraries (the adapters are + // typed against them, which is what catches an upstream API change on a + // version bump), so it lives here, path-gated, rather than in + // `typecheck:packages`. The harness's unit tests need none of them and run + // in the ordinary `test` project list. + run: 'pnpm typecheck:bench && pnpm gate:bench:structural', browser: 'chromium', local: 'browser', timeoutMinutes: 30, diff --git a/scripts/clean-artifacts.ts b/scripts/clean-artifacts.ts new file mode 100644 index 000000000..a284a9e18 --- /dev/null +++ b/scripts/clean-artifacts.ts @@ -0,0 +1,41 @@ +/** + * Delete what a local test, benchmark or release run left behind. + * + * By default only run artifacts go; build outputs and caches stay, since the + * next build needs them. `--all` removes those too - every dist, the site's + * cache and its synced inputs - which is the state of a fresh clone before + * `bootstrap:dev`. Prints what it removed so a surprising deletion is at + * least a visible one. + * + * Deletion goes through `git clean -X`, which removes only files the + * repository ignores: a tracked placeholder inside an artifact directory + * (`test/perf/results/.gitkeep`) survives, and so does anything a person put + * there that git does not know to be disposable. + */ +import { execFileSync } from 'node:child_process'; +import { join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { BUILD_CACHES, formatBytes, presentArtifacts, RUN_ARTIFACTS } from './artifacts.ts'; +import { collectBuildUnits } from './dist-freshness.ts'; + +const root = resolve(fileURLToPath(new URL('..', import.meta.url))); +const all = process.argv.includes('--all'); +const buildOutputs = all ? collectBuildUnits().map(unit => relative(root, unit.distDir).replace(/\\/g, '/')) : []; +const present = presentArtifacts(root, all ? [...RUN_ARTIFACTS, ...BUILD_CACHES, ...buildOutputs] : RUN_ARTIFACTS); +const label = all ? 'clean:all' : 'clean:artifacts'; + +if (present.length === 0) { + console.log(`${label}: nothing to remove.`); + process.exit(0); +} + +let freed = 0; + +for (const artifact of present) { + execFileSync('git', ['clean', '-fdXq', '--', join(root, artifact.path)], { cwd: root, stdio: 'inherit' }); + freed += artifact.bytes; + console.log(` removed ${artifact.path} (${formatBytes(artifact.bytes)})`); +} + +console.log(`${label}: ${present.length} director${present.length === 1 ? 'y' : 'ies'} removed, ${formatBytes(freed)} freed.`); diff --git a/scripts/dist-freshness.ts b/scripts/dist-freshness.ts new file mode 100644 index 000000000..f53b2e10a --- /dev/null +++ b/scripts/dist-freshness.ts @@ -0,0 +1,89 @@ +/** + * Which build units the dist-consuming steps depend on, and whether each is + * current. + * + * Shared by the freshness gate (which refuses to run a step against a stale + * build) and the doctor (which reports it as one line among the other + * prerequisites). A unit is stale when the content hash of its sources no + * longer matches the stamp its dist recorded at build time; a unit that was + * never built is reported separately, because the consuming step's own error + * for that case is usually a wall of unresolved imports rather than the cause. + */ +import { existsSync, readdirSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { hashSourceTree, readSourceStamp } from './source-hash.ts'; + +export const repoRoot = resolve(fileURLToPath(new URL('..', import.meta.url))); + +export interface BuildUnit { + readonly name: string; + readonly sourceDir: string; + readonly distDir: string; + /** Whether the dist directory holds an emitted entry point. */ + readonly built: boolean; +} + +/** Packages that own their outputs and are never bundled by a dist-consuming step. */ +const TOOLING_PACKAGES = new Set(['exojs-build', 'exojs-config', 'exojs-bench', 'exojs-examples']); + +/** Core plus every runtime extension package, whether or not it has been built. */ +export const collectBuildUnits = (): BuildUnit[] => { + const units: BuildUnit[] = [ + { + name: '@codexo/exojs', + sourceDir: join(repoRoot, 'src'), + distDir: join(repoRoot, 'dist'), + built: existsSync(join(repoRoot, 'dist', 'esm', 'index.js')), + }, + ]; + + for (const entry of readdirSync(join(repoRoot, 'packages'), { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('exojs-') || TOOLING_PACKAGES.has(entry.name)) continue; + + const dir = join(repoRoot, 'packages', entry.name); + const sourceDir = join(dir, 'src'); + const distDir = join(dir, 'dist'); + + // Only packages built through the shared library pipeline carry a stamp. + if (!existsSync(sourceDir) || !existsSync(join(dir, 'tsconfig.build.json'))) continue; + + units.push({ name: `@codexo/${entry.name}`, sourceDir, distDir, built: existsSync(join(distDir, 'esm', 'index.js')) }); + } + + return units; +}; + +export interface FreshnessReport { + readonly units: readonly BuildUnit[]; + /** One reason per unit whose dist lags its sources. */ + readonly stale: readonly string[]; + /** Units with no emitted dist at all. */ + readonly unbuilt: readonly BuildUnit[]; +} + +export const checkFreshness = (units: readonly BuildUnit[] = collectBuildUnits()): FreshnessReport => { + const stale: string[] = []; + const unbuilt: BuildUnit[] = []; + + for (const unit of units) { + if (!unit.built) { + unbuilt.push(unit); + continue; + } + + const recorded = readSourceStamp(unit.distDir); + + if (recorded === null) { + stale.push(`${unit.name}: ${relative(repoRoot, unit.distDir)} carries no source stamp (built before stamps existed, or not at all)`); + } else if (recorded !== hashSourceTree(unit.sourceDir)) { + stale.push(`${unit.name}: ${relative(repoRoot, unit.sourceDir)} changed since ${relative(repoRoot, unit.distDir)} was built`); + } + } + + return { units, stale, unbuilt }; +}; + +/** The one command that brings every unit up to date. */ +export const REBUILD_COMMAND = 'pnpm build:all'; diff --git a/scripts/doctor.ts b/scripts/doctor.ts new file mode 100644 index 000000000..558028b9c --- /dev/null +++ b/scripts/doctor.ts @@ -0,0 +1,201 @@ +/** + * One screen that says whether this clone can run what the repository asks of + * it, and names the command for whatever cannot. + * + * Every check here corresponds to a failure that has cost a diagnosis before: + * a Node version the tooling silently tolerated, git hooks that never fired + * because the install skipped `prepare`, a stale dist that made the site + * render a black canvas, a benchmark that could not find its competitor + * libraries. Run it after `pnpm bootstrap:dev`, and whenever + * something fails in a way that reads like a broken change but smells like a + * missing prerequisite. + */ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; + +import { BUILD_CACHES, formatBytes, presentArtifacts, RUN_ARTIFACTS } from './artifacts.ts'; +import { checkFreshness, REBUILD_COMMAND, repoRoot } from './dist-freshness.ts'; + +interface Outcome { + readonly ok: boolean; + readonly detail: string; + /** The command that turns a failed check green. */ + readonly fix?: string; +} + +interface Check { + readonly name: string; + /** A failed required check exits non-zero; an optional one is reported and moves on. */ + readonly required: boolean; + run(): Outcome; +} + +const read = (relativePath: string): string => readFileSync(join(repoRoot, relativePath), 'utf8'); + +const command = (file: string, args: readonly string[]): string | null => { + try { + return execFileSync(file, args, { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + shell: process.platform === 'win32', + }).trim(); + } catch { + return null; + } +}; + +const packageJson = JSON.parse(read('package.json')) as { packageManager?: string }; + +const checks: Check[] = [ + { + name: 'node', + required: true, + run: () => { + // `.nvmrc` is the single source of the required major; `devEngines` in + // package.json mirrors it for pnpm and a parity test keeps them equal. + const wanted = read('.nvmrc').trim(); + const actual = process.versions.node; + const ok = actual.split('.')[0] === wanted; + + return { + ok, + detail: `v${actual} (required: ${wanted}.x)`, + fix: ok ? undefined : `install Node ${wanted} - nvm use, fnm use or volta pin node@${wanted}`, + }; + }, + }, + { + name: 'pnpm', + required: true, + run: () => { + const pinned = packageJson.packageManager?.split('@')[1] ?? '?'; + const actual = command('pnpm', ['--version']); + + if (actual === null) { + return { ok: false, detail: 'not found on PATH', fix: 'corepack enable, or install pnpm - it switches itself to the pinned version' }; + } + + return { ok: actual === pinned, detail: `${actual} (pinned: ${pinned})`, fix: actual === pinned ? undefined : 'corepack enable' }; + }, + }, + { + name: 'git hooks', + required: true, + run: () => { + // Whether a clone has hooks depends on how it was set up: `bootstrap` + // installs with --ignore-scripts, which skips the `prepare` that + // installs them, so they appear only when pnpm's own install ran first. + // `bootstrap:dev` installs them outright; this reports what is there. + const hooksPath = command('git', ['config', 'core.hooksPath']) ?? ''; + const installed = hooksPath.replace(/\\/g, '/').endsWith('.husky/_') && existsSync(join(repoRoot, '.husky', '_', 'h')); + + return { + ok: installed, + detail: installed ? 'installed' : 'not installed - the pre-commit and pre-push guards are not running', + fix: installed ? undefined : 'pnpm exec husky', + }; + }, + }, + { + name: 'git pull.ff', + required: false, + run: () => { + const value = command('git', ['config', 'pull.ff']); + const ok = value === 'only'; + + return { + ok, + detail: ok ? 'only' : `${value ?? 'unset'} - a pull on a diverged main/next would build a merge commit`, + fix: ok ? undefined : 'git config pull.ff only', + }; + }, + }, + { + name: 'dist', + required: true, + run: () => { + const report = checkFreshness(); + const problems = [...report.unbuilt.map(unit => `${unit.name} not built`), ...report.stale]; + + if (problems.length === 0) return { ok: true, detail: `${report.units.length} build unit(s) current` }; + + return { ok: false, detail: problems.join('; '), fix: REBUILD_COMMAND }; + }, + }, + { + name: 'playwright chromium', + required: false, + run: () => { + // The browser lanes, the example smoke and the benchmarks all launch it. + // Resolved through the repository's own playwright dependency, so the + // answer is about the build the tests will use, not a global install. + const { chromium } = createRequire(import.meta.url)('playwright') as { chromium: { executablePath(): string } }; + const ok = existsSync(chromium.executablePath()); + + return { + ok, + detail: ok ? 'installed' : 'not installed - browser tests, the example smoke and the benchmarks cannot run', + fix: ok ? undefined : 'pnpm exec playwright install chromium', + }; + }, + }, + { + name: 'bench competitors', + required: false, + run: () => { + const linked = existsSync(join(repoRoot, 'packages', 'exojs-bench', 'node_modules', 'pixi.js', 'package.json')); + + return { + ok: linked, + detail: linked ? 'linked' : 'not linked - the benchmark competitor arms and the bench typecheck cannot run', + fix: linked ? undefined : 'pnpm bench:setup', + }; + }, + }, + { + name: 'naga', + required: false, + run: () => { + const version = command('naga', ['--version']); + + return { ok: version !== null, detail: version ?? 'not on PATH - WGSL validation tests skip locally (CI installs it)' }; + }, + }, +]; + +let failed = false; + +console.log('doctor: prerequisites for this clone\n'); + +for (const check of checks) { + const outcome = check.run(); + const mark = outcome.ok ? 'ok ' : check.required ? 'FAIL' : 'warn'; + + console.log(` ${mark} ${check.name.padEnd(20)} ${outcome.detail}`); + + if (!outcome.ok && outcome.fix !== undefined) console.log(` fix: ${outcome.fix}`); + if (!outcome.ok && check.required) failed = true; +} + +const artifacts = presentArtifacts(repoRoot, RUN_ARTIFACTS); +const caches = presentArtifacts(repoRoot, BUILD_CACHES); +const sum = (items: readonly { bytes: number }[]): number => items.reduce((total, item) => total + item.bytes, 0); + +if (artifacts.length > 0 || caches.length > 0) { + console.log(''); + + if (artifacts.length > 0) { + console.log(` info run artifacts ${formatBytes(sum(artifacts))} in ${artifacts.map(a => a.path).join(', ')} - pnpm clean:artifacts removes them`); + } + + if (caches.length > 0) { + console.log(` info build caches ${formatBytes(sum(caches))} in ${caches.map(c => c.path).join(', ')} - pnpm clean:all removes these too`); + } +} + +console.log(''); +console.log(failed ? 'doctor: a required prerequisite is missing; see the fix lines above.' : 'doctor: this clone is ready.'); +process.exit(failed ? 1 : 0); diff --git a/scripts/skipped-tests-baseline.json b/scripts/skipped-tests-baseline.json index 286d813a3..f02dc5329 100644 --- a/scripts/skipped-tests-baseline.json +++ b/scripts/skipped-tests-baseline.json @@ -1,6 +1,8 @@ { "note": "Per-file budget of skipped tests, measured as the SUM across every CI test lane (unit + browser-webgl + browser-webgpu + browser-webgl-firefox + browser-audio + browser-tilemap-worker), aggregated from each lane's own JUnit report — one global budget, not one per lane. Skipping more than the budget fails `pnpm test:skips`, and so does any skip in a file with no budget — a conditional test (including a runtime `ctx.skip(...)`) must be a reviewed entry, not a quiet default. Skipping fewer only prints a note, because the count depends on which lanes actually ran (a local partial run, or a local `dist/` that unskips the production-stripping checks). Run `pnpm test:skips:update-baseline` against a full set of lane reports to record a change.", "files": { + "packages/exojs-bench/test/smoke.test.ts": 1, + "packages/exojs-bench/test/split-screen.test.ts": 2, "packages/exojs-pathfinding/test/allocation.test.ts": 3, "test/build-defines/production-stripping.test.ts": 12, "test/perf/rendering/sweep.test.ts": 1 diff --git a/site/scripts/build-api.ts b/site/scripts/build-api.ts index 3bc22fb34..e1dd37d1f 100644 --- a/site/scripts/build-api.ts +++ b/site/scripts/build-api.ts @@ -13,7 +13,11 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const siteRoot = path.resolve(__dirname, '..'); const repoRoot = path.resolve(siteRoot, '..'); -const outputDir = path.resolve(siteRoot, 'src', 'content', 'api'); +const finalOutputDir = path.resolve(siteRoot, 'src', 'content', 'api'); +// Pages are generated here and moved into place only once every package has +// produced its own. Writing straight into the content directory meant a failed +// extension conversion left the site with the core pages and nothing else. +const outputDir = path.resolve(siteRoot, '.api-staging'); const toPosix = (value: string): string => value.replaceAll('\\', '/'); // The subsystem set is owned by the site, not by the generator: the pages order @@ -551,14 +555,21 @@ const entryPointTitle = (reflection: DeclarationReflection): string => { return '@codexo/exojs'; }; +// maxRetries covers the transient Windows EPERM/EBUSY that hits recursive +// rmSync when a file indexer or a parallel build step (examples:sync) has +// the directory momentarily open. Node retries these error codes. +const RM_OPTIONS = { recursive: true, force: true, maxRetries: 5, retryDelay: 100 } as const; + const ensureCleanOutput = (): void => { - // maxRetries covers the transient Windows EPERM/EBUSY that hits recursive - // rmSync when a file indexer or a parallel build step (examples:sync) has - // the output directory momentarily open. Node retries these error codes. - fs.rmSync(outputDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.rmSync(outputDir, RM_OPTIONS); fs.mkdirSync(outputDir, { recursive: true }); }; +const publishOutput = (): void => { + fs.rmSync(finalOutputDir, RM_OPTIONS); + fs.renameSync(outputDir, finalOutputDir); +}; + const MODIFIER_TAGS: `@${string}`[] = [ '@stable', '@advanced', @@ -901,8 +912,10 @@ const build = async (): Promise => { packageCounts.push({ importPath: pkg.importPath, count }); } + publishOutput(); + const summary = [`${coreCount} core`, ...packageCounts.map(p => `${p.count} ${p.importPath}`)].join(', '); - console.log(`[build:api] Generated ${usedSlugs.size} API page(s) (${summary}) in ${outputDir}`); + console.log(`[build:api] Generated ${usedSlugs.size} API page(s) (${summary}) in ${finalOutputDir}`); }; void build(); diff --git a/test/ci/node-version-parity.test.ts b/test/ci/node-version-parity.test.ts new file mode 100644 index 000000000..71e8d439f --- /dev/null +++ b/test/ci/node-version-parity.test.ts @@ -0,0 +1,39 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +/** + * One Node version, declared once. `.nvmrc` is the source; `devEngines` in + * package.json enforces it for pnpm, and every workflow reads the file rather + * than naming a version of its own. A second declaration anywhere is a second + * place for the two to drift apart. + */ + +const repoRoot = resolve(import.meta.dirname!, '../..'); +const nvmrc = readFileSync(resolve(repoRoot, '.nvmrc'), 'utf8').trim(); +const packageJson = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8')) as { + devEngines?: { runtime?: { name?: string; version?: string; onFail?: string } }; +}; +const workflowFiles = ['.github/workflows/ci.yml', '.github/workflows/release.yml', '.github/actions/setup/action.yml']; + +describe('the Node version is declared once', () => { + it('.nvmrc names a bare major', () => { + expect(nvmrc).toMatch(/^\d+$/); + }); + + it('devEngines enforces the same major and fails hard', () => { + const runtime = packageJson.devEngines?.runtime; + + expect(runtime?.name).toBe('node'); + expect(runtime?.onFail).toBe('error'); + expect(runtime?.version).toBe(`^${nvmrc}`); + }); + + it.each(workflowFiles)('%s reads .nvmrc instead of naming a version', file => { + const text = readFileSync(resolve(repoRoot, file), 'utf8'); + + expect(text).not.toMatch(/node-version:\s*['"]?\d/); + expect(text).toContain('node-version-file: .nvmrc'); + }); +}); diff --git a/test/ci/vitest-project-parity.test.ts b/test/ci/vitest-project-parity.test.ts new file mode 100644 index 000000000..c7a0affb9 --- /dev/null +++ b/test/ci/vitest-project-parity.test.ts @@ -0,0 +1,52 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { LANES } from '../../scripts/ci/lanes'; + +/** + * Every vitest project must be run by some package.json script, and that script + * by some lane. + * + * A project nobody invokes is worse than a missing test: the file still exists, + * still typechecks and still reads like a gate, so nothing signals that it + * stopped running. That is the failure mode this guards - the split that keeps + * a load-sensitive measurement out of the parallel suite (`physics-perf`, + * `rendering-alloc`) is exactly the kind of change that can drop a project on + * the floor. + * + * Browser projects are exempt: they run through their own lanes with their own + * commands rather than through a `--project` list. + */ + +const repoRoot = resolve(import.meta.dirname!, '../..'); +const config = readFileSync(resolve(repoRoot, 'vitest.config.ts'), 'utf8'); +const packageJson = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8')) as { + scripts: Record; +}; + +const declaredProjects = [...config.matchAll(/name: '([\w-]+)'/g)].map(match => match[1]!).filter(name => !name.startsWith('browser-')); + +const scriptText = Object.values(packageJson.scripts).join(' '); +const laneText = LANES.map(lane => [lane.run, lane.ciRun ?? '', lane.coverageRun ?? ''].join(' ')).join(' '); + +describe('every vitest project is actually run', () => { + it('declares at least the projects this guard knows about', () => { + expect(declaredProjects).toContain('physics-perf'); + expect(declaredProjects).toContain('rendering-alloc'); + }); + + it.each(declaredProjects)('project `%s` is named by a package.json script', project => { + expect(scriptText).toContain(`--project=${project}`); + }); + + it.each(declaredProjects)('project `%s` reaches a lane through its script', project => { + const owning = Object.entries(packageJson.scripts) + .filter(([, command]) => command.includes(`--project=${project}`)) + .map(([name]) => name); + + expect(owning.length).toBeGreaterThan(0); + expect(owning.some(name => laneText.includes(`pnpm ${name}`))).toBe(true); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 9ef2b10b3..e1f7822a2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -206,6 +206,22 @@ export default defineConfig({ name: 'exojs-physics', alias: aliasConfig, include: ['packages/exojs-physics/test/**/*.test.ts'], + // The sleeping gate is its own project - see `physics-perf`. + exclude: ['packages/exojs-physics/test/sleeping-perf.test.ts'], + }), + + // -- physics-perf - the sleeping-vs-awake step-time gate --------------- + // Separate project for exactly one reason: this is the only physics + // assertion that reads wall-clock time, and wall-clock is load-dependent. + // Run inside the parallel suite, the light sleeping arm loses more to + // scheduling gaps than the heavy awake arm and the ratio collapses (3.4x + // measured alone, 1.8x under the suite) - a failing push with nothing + // regressed. Kept out of `test` and run by `test:physics-perf` after it, + // so the measurement has the machine to itself. + createJsdomTestProject({ + name: 'physics-perf', + alias: aliasConfig, + include: ['packages/exojs-physics/test/sleeping-perf.test.ts'], }), createJsdomTestProject({ name: 'exojs-tilemap-physics',