diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09929d572..8a7ac1e70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -194,10 +194,7 @@ jobs: - name: Build extension packages env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - run: >- - pnpm --filter "@codexo/exojs-particles" --filter "@codexo/exojs-tilemap" --filter "@codexo/exojs-tiled" - --filter "@codexo/exojs-physics" --filter "@codexo/exojs-tilemap-physics" --filter "@codexo/exojs-lighting" --filter "@codexo/exojs-pathfinding" --filter "@codexo/exojs-audio-fx" - --filter "@codexo/exojs-aseprite" --filter "@codexo/exojs-ldtk" --filter "@codexo/exojs-react" build + run: pnpm build:packages - name: Verify production stripping against the built dist env: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a530e55d..e110345cc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -173,6 +173,12 @@ pnpm verify:lockstep pnpm typecheck:examples pnpm typecheck:guides pnpm site:build pnpm test:examples:smoke ``` +Scripts forward their arguments, so a variant is an argument rather than a +script of its own: `pnpm lint --fix`, `pnpm test --coverage`, +`pnpm test:browser:webgl --reporter=verbose`. The measurement harnesses run +through one runner, `pnpm perf ` (`pnpm perf` lists them); the +cross-library benchmarks through `pnpm bench` (see `packages/exojs-bench`). + ## Shared configuration `@codexo/exojs-config` (private, unpublished, never a runtime dependency) centralizes diff --git a/package.json b/package.json index 4d1619433..41bb70e63 100644 --- a/package.json +++ b/package.json @@ -83,69 +83,35 @@ "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", + "prepare": "husky", "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", - "verify:declaration-imports": "tsx ./scripts/verify-declaration-imports.ts", - "verify:package-policy": "tsx ./scripts/verify-package-policy.ts", - "verify:publint": "pnpm dlx publint@0.3.21 --strict . && pnpm --filter \"@codexo/exojs-build\" --filter \"@codexo/exojs-particles\" --filter \"@codexo/exojs-tilemap\" --filter \"@codexo/exojs-tiled\" --filter \"@codexo/exojs-physics\" --filter \"@codexo/exojs-tilemap-physics\" --filter \"@codexo/exojs-lighting\" --filter \"@codexo/exojs-pathfinding\" --filter \"@codexo/exojs-audio-fx\" exec pnpm dlx publint@0.3.21 --strict .", - "verify:package": "pnpm build && pnpm verify:exports && pnpm verify:declaration-imports && pnpm pack", - "verify:bench-results": "tsx ./scripts/verify-bench-results.ts", - "verify:lockstep": "tsx ./scripts/verify-lockstep-versions.ts", - "verify:release-matrix": "tsx ./scripts/verify-release-matrix.ts", - "verify:create-exo-app": "tsx ./scripts/verify-create-exo-app.ts", - "sync:example-capabilities": "tsx ./scripts/sync-example-capabilities.ts", - "create:package": "tsx scripts/create-package.ts", - "gate:bench:structural": "pnpm --filter @codexo/exojs-bench gate:structural", - "gates": "tsx ./scripts/ci/gates.ts", - "lanes": "tsx ./scripts/lanes.ts", - "verify:quick": "pnpm gates all", - "verify:ci": "pnpm verify:quick && pnpm test", - "release": "tsx ./scripts/release.ts", - "release:cut": "tsx ./scripts/release/cut.ts", - "release:prepare": "tsx ./scripts/release/run.ts prepare", - "release:full-zip": "tsx ./scripts/release/run.ts full-zip", - "release:publish": "tsx ./scripts/release/run.ts publish", - "release:bootstrap": "tsx ./scripts/release/run.ts bootstrap", - "release:publish-independent": "tsx ./scripts/release/run.ts publish-independent", - "verify:external-consumers": "tsx ./scripts/release/external-consumers.ts", - "release:notes": "tsx ./scripts/generate-release-notes.ts", - "release:changelog": "tsx ./scripts/release/generate-changelog.ts", - "docs:api:generate": "pnpm site:build:api", - "docs:api:check": "tsx scripts/check-api-docs-sync.ts", - "examples:sync:check": "tsx scripts/check-examples-sync.ts", - "full-bundle:exports:check": "tsx scripts/check-dist-fresh.ts && tsx scripts/check-full-bundle-exports.ts", + "build:tooling": "pnpm --filter @codexo/exojs-build build", + "build:packages": "pnpm -r --filter \"./packages/**\" --filter \"!@codexo/exojs-build\" --filter \"!@codexo/exojs-bench\" --filter \"!create-exo-app\" build", + "build:all": "pnpm build && pnpm build:packages", "typecheck": "tsc --noEmit", "typecheck:examples": "tsc --noEmit -p tsconfig.examples.json", "typecheck:workers": "tsc --noEmit -p tsconfig.workers.json", + "typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json", "typecheck:type-tests": "tsx scripts/typecheck-type-tests.ts", + "typecheck:test": "tsx scripts/typecheck-tests.ts", + "typecheck:test:update-baseline": "tsx scripts/typecheck-tests.ts --update-baseline", "typecheck:guides": "tsx scripts/check-guide-no-check-reasons.ts && tsx scripts/extract-guide-snippets.ts && tsx scripts/typecheck-guides.ts", "typecheck:guides:update-baseline": "tsx scripts/extract-guide-snippets.ts --update-baseline", "typecheck:guides:no-check": "tsx scripts/check-guide-no-check-reasons.ts", "typecheck:guides:no-check:update-baseline": "tsx scripts/check-guide-no-check-reasons.ts --update-baseline", - "typecheck:packages": "pnpm --filter \"@codexo/exojs-build\" --filter \"@codexo/exojs-particles\" --filter \"@codexo/exojs-tilemap\" --filter \"@codexo/exojs-tiled\" --filter \"@codexo/exojs-physics\" --filter \"@codexo/exojs-tilemap-physics\" --filter \"@codexo/exojs-lighting\" --filter \"@codexo/exojs-pathfinding\" --filter \"@codexo/exojs-audio-fx\" --filter \"@codexo/exojs-aseprite\" --filter \"@codexo/exojs-ldtk\" --filter \"@codexo/exojs-react\" typecheck", - "typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json", + "typecheck:packages": "pnpm -r --filter \"./packages/**\" --filter \"!@codexo/exojs-bench\" typecheck", + "typecheck:bench": "pnpm bench:setup && pnpm --filter @codexo/exojs-bench typecheck", "typecheck:site": "pnpm --filter @codexo/exojs-examples check-ts", "typecheck:site-scripts": "pnpm --filter @codexo/exojs-examples check-ts:scripts", - "typecheck:test": "tsx scripts/typecheck-tests.ts", - "typecheck:test:update-baseline": "tsx scripts/typecheck-tests.ts --update-baseline", - "typecheck:bench": "pnpm --filter @codexo/exojs-bench bench:setup && pnpm --filter @codexo/exojs-bench typecheck", - "lint": "eslint --max-warnings=0 \"src/**/*.ts\" \"test/**/*.ts\" \"examples/**/*.ts\" \"scripts/**/*.{ts,mts,cts,mjs}\" \"packages/exojs-bench/competitors/*.ts\" \"*.config.ts\"", - "lint:fix": "pnpm lint --fix", + "lint": "eslint --max-warnings=0 \"src/**/*.ts\" \"test/**/*.ts\" \"examples/**/*.ts\" \"scripts/**/*.{ts,mts,cts,mjs}\" \"*.config.ts\" \"packages/exojs-*/src/**/*.{ts,tsx}\" \"packages/exojs-*/test/**/*.{ts,tsx}\" \"packages/exojs-bench/competitors/*.ts\" \"packages/create-exo-app/src/**/*.ts\"", "lint:fast": "pnpm lint --cache --cache-location .cache/eslintcache", - "lint:fast:fix": "pnpm lint:fast --fix", - "lint:packages": "eslint --max-warnings=0 \"packages/exojs-*/src/**/*.{ts,tsx}\" \"packages/exojs-*/test/**/*.{ts,tsx}\" \"packages/create-exo-app/src/**/*.ts\"", "lint:site": "pnpm --filter @codexo/exojs-examples lint", - "lint:packages:fix": "pnpm lint:packages --fix", - "lint:all": "pnpm lint && pnpm lint:packages && pnpm lint:site", - "lint:all:fix": "pnpm lint:fix && pnpm lint:packages:fix && pnpm lint:site --fix", + "lint:all": "pnpm lint && pnpm lint:site", "lint:source-hygiene": "tsx scripts/check-source-hygiene.ts", "lint:inline-source": "tsx scripts/check-inline-source.ts", "lint:changelog": "tsx scripts/check-changelog-untouched.ts", @@ -156,22 +122,20 @@ "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=exojs-bench --project=rendering-perf --project=rendering-alloc", + "test": "vitest run --project=\"exojs*\" --project=rendering-perf", "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:watch": "vitest --project=exojs", + "test:coverage": "pnpm test --coverage", "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", + "test:skips": "pnpm test --reporter=default --reporter=junit --outputFile.junit=./test-results/unit.junit.xml && tsx scripts/check-skipped-tests.ts", "test:skips:check": "tsx scripts/check-skipped-tests.ts", "test:skips:update-baseline": "tsx scripts/check-skipped-tests.ts --update-baseline", - "test:browser": "pnpm run test:browser:webgl && pnpm run test:browser:webgpu && pnpm run test:browser:webgl:firefox && pnpm run test:browser:webgpu:firefox && pnpm run test:browser:build", + "test:browser": "pnpm test:browser:webgl && pnpm test:browser:webgpu && pnpm test:browser:webgl:firefox && pnpm test:browser:webgpu:firefox && pnpm test:browser:build", "test:browser:webgl": "vitest run --project=browser-webgl-chromium", - "test:browser:webgl:chromium": "pnpm run test:browser:webgl", "test:browser:webgl:firefox": "vitest run --project=browser-webgl-firefox", "test:browser:webgpu": "vitest run --project=browser-webgpu", - "test:browser:webgpu:chromium": "pnpm run test:browser:webgpu", "test:browser:webgpu:firefox": "vitest run --project=browser-webgpu-firefox", "test:browser:assets": "vitest run --project=browser-assets-chromium", "test:browser:audio": "vitest run --project=browser-audio-chromium", @@ -181,38 +145,44 @@ "test:parity:firefox": "tsx ./scripts/run-parity.ts --project=browser-webgpu-firefox test/rendering/parity", "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: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", - "perf:renderers": "tsx test/perf/rendering/run-sweep.ts full", - "perf:renderers:quick": "tsx test/perf/rendering/run-sweep.ts quick", - "perf:renderers:browser": "vitest run --project=browser-webgl-chromium webgl2-renderer-perf", - "perf:renderers:alloc": "node --conditions=@codexo/exojs-source --import ./scripts/glsl-register.ts --import tsx/esm test/perf/rendering/run-allocation.ts", - "perf:renderers:alloc:cell": "node --conditions=@codexo/exojs-source --import ./scripts/glsl-register.ts --import tsx/esm test/perf/rendering/run-allocation-cell.ts", - "perf:renderers:cull-margin": "node --conditions=@codexo/exojs-source --import ./scripts/glsl-register.ts --import tsx/esm test/perf/rendering/run-cull-margin.ts", - "perf:renderers:cull-margin:cell": "node --expose-gc --max-old-space-size=8192 --conditions=@codexo/exojs-source --import ./scripts/glsl-register.ts --import tsx/esm test/perf/rendering/run-cull-margin-cell.ts", - "perf:renderers:bootstrap": "node --conditions=@codexo/exojs-source --import ./scripts/glsl-register.ts --import tsx/esm test/perf/rendering/run-bootstrap-allocation.ts", - "perf:renderers:bootstrap:cell": "node --expose-gc --max-old-space-size=8192 --conditions=@codexo/exojs-source --import ./scripts/glsl-register.ts --import tsx/esm test/perf/rendering/run-bootstrap-cell.ts", - "perf:bench:rendering": "node --conditions=@codexo/exojs-source --import ./scripts/glsl-register.ts --import tsx/esm test/perf/rendering-benchmark.ts", - "perf:bench:audio": "node --conditions=@codexo/exojs-source --import ./scripts/glsl-register.ts --import tsx/esm test/perf/audio-benchmark.ts", - "perf:bench:collision": "node --conditions=@codexo/exojs-source --import ./scripts/glsl-register.ts --import tsx/esm test/perf/collision-benchmark.ts", - "perf:bench:scene-graph": "node --conditions=@codexo/exojs-source --import ./scripts/glsl-register.ts --import tsx/esm test/perf/scene-graph-benchmark.ts", - "perf:bench:interaction": "node --conditions=@codexo/exojs-source --import ./scripts/glsl-register.ts --import tsx/esm test/perf/interaction-benchmark.ts", - "perf:bench:collect-phase": "node --conditions=@codexo/exojs-source --import ./scripts/glsl-register.ts --import tsx/esm test/perf/collect-phase-benchmark.ts", - "perf:bench:all": "pnpm perf:bench:rendering && pnpm perf:bench:audio && pnpm perf:bench:collision && pnpm perf:bench:scene-graph && pnpm perf:bench:interaction && pnpm perf:bench:collect-phase", - "perf:smoke": "pnpm perf:bench:all && pnpm perf:profile", - "bench:setup": "pnpm --filter @codexo/exojs-bench bench:setup", - "perf:baseline": "pnpm --filter @codexo/exojs-bench bench", - "perf:profile": "node --conditions=@codexo/exojs-source --import ./scripts/glsl-register.ts --import tsx/esm test/perf/profile-benchmark.ts", - "perf:profile:gc": "node --expose-gc --conditions=@codexo/exojs-source --import ./scripts/glsl-register.ts --import tsx/esm test/perf/profile-benchmark.ts", + "gates": "tsx ./scripts/ci/gates.ts", + "lanes": "tsx ./scripts/lanes.ts", + "verify:quick": "pnpm gates all", + "verify:exports": "tsx ./scripts/verify-exports.ts", + "verify:declaration-imports": "tsx ./scripts/verify-declaration-imports.ts", + "verify:package-policy": "tsx ./scripts/verify-package-policy.ts", + "verify:package": "pnpm build && pnpm verify:exports && pnpm verify:declaration-imports && pnpm pack", + "verify:publish": "tsx ./scripts/verify-publish.ts", + "verify:bench-results": "tsx ./scripts/verify-bench-results.ts", + "verify:lockstep": "tsx ./scripts/verify-lockstep-versions.ts", + "verify:release-matrix": "tsx ./scripts/verify-release-matrix.ts", + "verify:create-exo-app": "tsx ./scripts/verify-create-exo-app.ts", + "verify:external-consumers": "tsx ./scripts/release/external-consumers.ts", + "docs:api:generate": "pnpm --filter @codexo/exojs-examples build:api", + "docs:api:check": "tsx scripts/check-api-docs-sync.ts", + "examples:sync:check": "tsx scripts/check-examples-sync.ts", + "full-bundle:exports:check": "tsx scripts/check-dist-fresh.ts && tsx scripts/check-full-bundle-exports.ts", + "sync:example-capabilities": "tsx ./scripts/sync-example-capabilities.ts", + "site:build": "tsx scripts/check-dist-fresh.ts && pnpm --filter @codexo/exojs-examples build", "size": "size-limit", "size:summary": "tsx scripts/ci/bundle-size-summary.ts", - "prepare": "husky", - "perf:renderers:instance-cost": "node --expose-gc --max-old-space-size=8192 --conditions=@codexo/exojs-source --import ./scripts/glsl-register.ts --import tsx/esm test/perf/rendering/run-instance-cost.ts", - "perf:webgpu:alloc": "tsx test/perf/webgpu/run-webgpu-allocation.ts", - "perf:webgpu:timer": "pnpm --filter @codexo/exojs-bench timer" + "perf": "tsx ./scripts/perf.ts", + "perf:smoke": "pnpm perf smoke", + "bench": "pnpm --filter @codexo/exojs-bench bench", + "bench:setup": "pnpm --filter @codexo/exojs-bench bench:setup", + "bench:micro": "vitest bench --run", + "gate:bench:structural": "pnpm --filter @codexo/exojs-bench gate:structural", + "webgpu:probe": "tsx scripts/webgpu-probe.ts", + "create:package": "tsx scripts/create-package.ts", + "release:cut": "tsx ./scripts/release/cut.ts", + "release:prepare": "tsx ./scripts/release/run.ts prepare", + "release:full-zip": "tsx ./scripts/release/run.ts full-zip", + "release:publish": "tsx ./scripts/release/run.ts publish", + "release:publish-independent": "tsx ./scripts/release/run.ts publish-independent", + "release:bootstrap": "tsx ./scripts/release/run.ts bootstrap", + "release:notes": "tsx ./scripts/generate-release-notes.ts", + "release:changelog": "tsx ./scripts/release/generate-changelog.ts" }, "license": "MIT", "publishConfig": { diff --git a/packages/exojs-bench/README.md b/packages/exojs-bench/README.md index b4fb68b0d..11a170556 100644 --- a/packages/exojs-bench/README.md +++ b/packages/exojs-bench/README.md @@ -203,8 +203,8 @@ matrix cell; both share the harness's page, server and launch flags so their numbers are produced under the same conditions. ```sh -pnpm perf:webgpu:stall # per-frame selection/capacity/upload/allocation counters -pnpm perf:webgpu:timer # per-frame timer methodology: raw vs attributed queue +pnpm perf webgpu:alloc # per-frame allocation, wall-clock or work-unit counters, one browser per cell +pnpm perf webgpu:timer # per-frame timer methodology: raw vs attributed queue # latency vs hardware timestamps, plus serialized / # canvas-clear / offscreen-clear control arms and the # clock + scheduler controls behind the floor above @@ -405,8 +405,8 @@ pnpm --filter @codexo/exojs-bench bench \ No `--` separator is needed with `pnpm --filter …`; pnpm forwards these straight to the script. Running the same script from inside `packages/exojs-bench` (`pnpm bench -- --out=…`) works too, and `--out` is then relative to the package -directory either way. The root `pnpm bench` is a different thing entirely — the -engine's own `vitest bench` micro-benchmarks — and there is no root +directory either way. The root `pnpm bench` forwards here as well; the engine's +own `vitest bench` micro-benchmarks are `pnpm bench:micro`, and there is no root `bench:compare`. The run writes `results.json`, `results.csv` and `results.md` into `--out` diff --git a/packages/exojs-bench/results/README.md b/packages/exojs-bench/results/README.md index 68ba9a216..94f6628cf 100644 --- a/packages/exojs-bench/results/README.md +++ b/packages/exojs-bench/results/README.md @@ -142,10 +142,10 @@ pnpm bench:compare -- \ --profile ``` -The harness scripts live in `packages/exojs-bench` and are run from there: the -root `pnpm bench` is the engine's own micro-benchmark suite, not this harness. -The `--` separates the harness's flags from pnpm's own, and output directories -are relative to the package. +The harness scripts live in `packages/exojs-bench`; the root `pnpm bench` forwards +to the same harness, while `bench:compare` is run from the package. The `--` +separates the harness's flags from pnpm's own, and output directories are +relative to the package either way. `--rendering` and `--physics` are repeatable, once per run, in run order. Repeat the same three-run pattern for `--domain=physics` and pass both sets to one diff --git a/packages/exojs-bench/src/runTimerProbe.ts b/packages/exojs-bench/src/runTimerProbe.ts index 6061fc8a8..d47626834 100644 --- a/packages/exojs-bench/src/runTimerProbe.ts +++ b/packages/exojs-bench/src/runTimerProbe.ts @@ -12,9 +12,9 @@ * D hardware sum of render-pass timestamp deltas (timestamp-query ground truth) * ``` * - * pnpm perf:webgpu:timer -- --nodes 1000000 --config retained --frames 30 --warmup 40 - * pnpm perf:webgpu:timer -- --mode serialized --frames 30 - * pnpm perf:webgpu:timer -- --controls-only --repeats 12 + * pnpm perf webgpu:timer --nodes 1000000 --config retained --frames 30 --warmup 40 + * pnpm perf webgpu:timer --mode serialized --frames 30 + * pnpm perf webgpu:timer --controls-only --repeats 12 * * One browser process per cell: the timer question includes device bootstrap, so * a cell that inherited another cell's warm device would not be the same diff --git a/scripts/ci/lanes.ts b/scripts/ci/lanes.ts index 93f83f51c..727c10da6 100644 --- a/scripts/ci/lanes.ts +++ b/scripts/ci/lanes.ts @@ -148,11 +148,7 @@ export const LANES: readonly Lane[] = [ id: 'package', stage: 'verify', when: 'packageVerify', - run: - 'pnpm size && pnpm size:summary && pnpm verify:exports && pnpm verify:declaration-imports && pnpm verify:lockstep && pnpm verify:release-matrix ' + - '&& pnpm pack --dry-run && pnpm --filter "@codexo/exojs-build" --filter "@codexo/exojs-particles" --filter "@codexo/exojs-tilemap" ' + - '--filter "@codexo/exojs-tiled" --filter "@codexo/exojs-physics" --filter "@codexo/exojs-tilemap-physics" --filter "@codexo/exojs-lighting" --filter "@codexo/exojs-pathfinding" --filter "@codexo/exojs-audio-fx" ' + - '--filter "@codexo/exojs-aseprite" --filter "@codexo/exojs-ldtk" --filter "@codexo/exojs-react" pack --dry-run && pnpm verify:publint', + run: 'pnpm size && pnpm size:summary && pnpm verify:exports && pnpm verify:declaration-imports && pnpm verify:lockstep && pnpm verify:release-matrix && pnpm verify:publish', dist: true, }, { diff --git a/scripts/perf.ts b/scripts/perf.ts new file mode 100644 index 000000000..fa9059877 --- /dev/null +++ b/scripts/perf.ts @@ -0,0 +1,141 @@ +/** + * Runner for the measurement harnesses under `test/perf/` and the bench + * package's probes. + * + * pnpm perf # list the runs + * pnpm perf [args...] # run one, forwarding the arguments + * pnpm perf smoke # what the `sync` gate group runs + * + * Every in-process harness needs the same Node invocation: the + * `@codexo/exojs-source` condition so package imports resolve to `src/`, the + * GLSL loader so shader imports resolve at all, and `tsx` for TypeScript. + * Spelling that prefix out once per harness in package.json is what let the + * scripts drift apart (some with `--expose-gc`, some without, one with a heap + * limit), so the prefix lives here and a run declares only what differs. + */ +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; + +interface PerfRun { + /** What the run measures, one line. */ + readonly summary: string; + /** A `test/perf` module executed in-process against engine source. */ + readonly module?: string; + /** Extra Node flags for `module` runs. */ + readonly nodeFlags?: readonly string[]; + /** A shell command instead of an in-process module. */ + readonly command?: string; + /** Other runs to execute in order. */ + readonly runs?: readonly string[]; +} + +const SOURCE_NODE_FLAGS = ['--conditions=@codexo/exojs-source', '--import', './scripts/glsl-register.ts', '--import', 'tsx/esm'] as const; +/** Cell probes take one large scene per process and read `gc()` for exact deltas. */ +const CELL_NODE_FLAGS = ['--expose-gc', '--max-old-space-size=8192'] as const; + +export const PERF_RUNS = { + rendering: { summary: 'renderer CPU submission benchmark', module: 'test/perf/rendering-benchmark.ts' }, + audio: { summary: 'audio graph benchmark', module: 'test/perf/audio-benchmark.ts' }, + collision: { summary: 'collision benchmark', module: 'test/perf/collision-benchmark.ts' }, + 'scene-graph': { summary: 'scene graph benchmark', module: 'test/perf/scene-graph-benchmark.ts' }, + interaction: { summary: 'interaction benchmark', module: 'test/perf/interaction-benchmark.ts' }, + 'collect-phase': { summary: 'collect phase benchmark', module: 'test/perf/collect-phase-benchmark.ts' }, + profile: { summary: 'profile benchmark', module: 'test/perf/profile-benchmark.ts' }, + 'profile:gc': { summary: 'profile benchmark with forced GC for exact memory deltas', module: 'test/perf/profile-benchmark.ts', nodeFlags: ['--expose-gc'] }, + smoke: { + summary: 'every in-process benchmark once, for its exit code (the `sync` gate)', + runs: ['rendering', 'audio', 'collision', 'scene-graph', 'interaction', 'collect-phase', 'profile'], + }, + + renderers: { summary: 'structural renderer sweep, full matrix', command: 'tsx test/perf/rendering/run-sweep.ts full' }, + 'renderers:quick': { summary: 'structural renderer sweep, small matrix', command: 'tsx test/perf/rendering/run-sweep.ts quick' }, + 'renderers:browser': { + summary: 'structural renderer metrics against a real WebGL2 context', + command: 'vitest run --project=browser-webgl-chromium webgl2-renderer-perf', + }, + 'renderers:alloc': { summary: 'steady-state allocation per renderer scene', module: 'test/perf/rendering/run-allocation.ts' }, + 'renderers:alloc:cell': { summary: 'one allocation scene in a fresh process', module: 'test/perf/rendering/run-allocation-cell.ts' }, + 'renderers:cull-margin': { summary: 'culling margin sweep', module: 'test/perf/rendering/run-cull-margin.ts' }, + 'renderers:cull-margin:cell': { + summary: 'one culling margin cell in a fresh process', + module: 'test/perf/rendering/run-cull-margin-cell.ts', + nodeFlags: CELL_NODE_FLAGS, + }, + 'renderers:bootstrap': { summary: 'scene bootstrap allocation', module: 'test/perf/rendering/run-bootstrap-allocation.ts' }, + 'renderers:bootstrap:cell': { + summary: 'one bootstrap cell in a fresh process', + module: 'test/perf/rendering/run-bootstrap-cell.ts', + nodeFlags: CELL_NODE_FLAGS, + }, + 'renderers:instance-cost': { + summary: 'per-instance cost by node type', + module: 'test/perf/rendering/run-instance-cost.ts', + nodeFlags: CELL_NODE_FLAGS, + }, + + 'webgpu:alloc': { summary: 'WebGPU allocation per scene, one browser per cell', command: 'tsx test/perf/webgpu/run-webgpu-allocation.ts' }, + 'webgpu:timer': { summary: 'WebGPU frame timer methodology probe', command: 'pnpm --filter @codexo/exojs-bench timer' }, +} as const satisfies Record; + +export type PerfRunName = keyof typeof PERF_RUNS; + +const runNames = Object.keys(PERF_RUNS) as PerfRunName[]; + +const printUsage = (): void => { + const width = Math.max(...runNames.map(name => name.length)); + console.log('Usage: pnpm perf [args...]\n'); + for (const name of runNames) { + console.log(` ${name.padEnd(width)} ${PERF_RUNS[name].summary}`); + } +}; + +const execute = (name: PerfRunName, args: readonly string[]): number => { + const run: PerfRun = PERF_RUNS[name]; + + if (run.runs) { + for (const child of run.runs) { + console.log(`\n=== pnpm perf ${child} ===\n`); + const status = execute(child as PerfRunName, args); + if (status !== 0) return status; + } + return 0; + } + + if (run.module) { + const result = spawnSync(process.execPath, [...(run.nodeFlags ?? []), ...SOURCE_NODE_FLAGS, run.module, ...args], { stdio: 'inherit' }); + return result.status ?? 1; + } + + // A shell so the pnpm/tsx/vitest shims resolve on Windows as well; arguments + // are re-quoted because the shell splits the joined line again. + const quoted = args.map(arg => (/\s/.test(arg) ? JSON.stringify(arg) : arg)); + const result = spawnSync([run.command, ...quoted].join(' '), { stdio: 'inherit', shell: true }); + return result.status ?? 1; +}; + +const main = (): void => { + const [requested, ...rest] = process.argv.slice(2); + // `pnpm perf -- ` forwards the separator too; the harnesses do not expect it. + const args = rest[0] === '--' ? rest.slice(1) : rest; + + if (!requested) { + printUsage(); + process.exit(2); + } + + if (!runNames.includes(requested as PerfRunName)) { + console.error(`Unknown perf run '${requested}'.\n`); + printUsage(); + process.exit(2); + } + + const status = execute(requested as PerfRunName, args); + if (status !== 0) { + console.error(`\nperf ${requested} failed (exit code ${status}).`); + } + process.exit(status); +}; + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/scripts/release.ts b/scripts/release.ts deleted file mode 100644 index 200a5e3ae..000000000 --- a/scripts/release.ts +++ /dev/null @@ -1,215 +0,0 @@ -#!/usr/bin/env node -/** - * Safe release flow for ExoJS. - * - * Atomic pre-flight: runs every check CI's `verify` job runs locally, then - * creates the annotated tag for the current `package.json` version. The - * actual push is left manual so the user reviews the diff one last time. - * - * 1. Working tree is clean. - * 2. Active branch is `main`. - * 3. Local `main` is up to date with `origin/main`. - * 4. The `vX.Y.Z` tag for `package.json#version` does not already exist. - * 5. `pnpm verify:release` (typecheck + lint + test + verify:package) - * passes. - * 6. Release notes can be generated from `CHANGELOG.md`. - * 7. Annotated tag is created at HEAD. - * 8. The next-step push instructions are printed; the script exits. - * - * Aborts on any failure. Idempotent - safe to re-run after fixing issues - * (the failed run never reaches the tag-creation step). To undo a created - * tag before pushing: `git tag -d vX.Y.Z`. - * - * Usage: `pnpm release` - */ -import { execSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { generateReleaseNotes } from './generate-release-notes.ts'; - -interface PackageJson { - homepage?: string; - repository?: { - type?: string; - url?: string; - }; - version: string; -} - -const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); - -const sh = (command: string): void => { - execSync(command, { cwd: rootDir, stdio: 'inherit' }); -}; - -const out = (command: string): string => { - return execSync(command, { cwd: rootDir, encoding: 'utf8' }).trim(); -}; - -// A `never` return only ends control flow for the caller when the callee is a -// function declaration or a constant with an explicit type annotation. -type Abort = (message: string) => never; - -const fail: Abort = message => { - process.stderr.write(`\n ✗ ${message}\n\n`); - process.exit(1); -}; - -const step = (message: string): void => { - process.stdout.write(`\n→ ${message}\n`); -}; - -const parsePackageJson = (): PackageJson => { - const packageJsonPath = resolve(rootDir, 'package.json'); - const raw = readFileSync(packageJsonPath, 'utf8'); - - const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== 'object') { - fail('package.json is malformed.'); - } - - const candidate = parsed as Partial; - if (typeof candidate.version !== 'string' || candidate.version.length === 0) { - fail('package.json#version is missing or invalid.'); - } - - return { - homepage: candidate.homepage, - repository: candidate.repository, - version: candidate.version, - }; -}; - -const extractGithubRepoFromUrl = (value: string): string | null => { - const match = /github\.com[:/](?[^/]+)\/(?[^/]+)$/i.exec(value.trim()); - if (!match || !match.groups) { - return null; - } - - const cleanedRepo = match.groups.repo.replace(/\.git$/i, ''); - if (!cleanedRepo) { - return null; - } - - return `${match.groups.owner}/${cleanedRepo}`; -}; - -const resolveGithubRepoSlug = (pkg: PackageJson): string => { - try { - const remoteOrigin = out('git config --get remote.origin.url'); - const fromRemote = extractGithubRepoFromUrl(remoteOrigin); - if (fromRemote) { - return fromRemote; - } - } catch { - // fall through to package metadata - } - - const repositoryUrl = pkg.repository?.url; - if (typeof repositoryUrl === 'string') { - const fromRepository = extractGithubRepoFromUrl(repositoryUrl); - if (fromRepository) { - return fromRepository; - } - } - - if (typeof pkg.homepage === 'string') { - const fromHomepage = extractGithubRepoFromUrl(pkg.homepage); - if (fromHomepage) { - return fromHomepage; - } - } - - fail('Unable to infer GitHub repository slug for release-notes preflight.'); -}; - -const pkg = parsePackageJson(); -const tag = `v${pkg.version}`; - -step(`Preparing release ${tag}`); - -// 1. Working tree clean. -const dirty = out('git status --porcelain'); - -if (dirty) { - fail(`Working tree is dirty:\n${dirty}\n Commit or stash changes first.`); -} - -// 2. On main. -const branch = out('git rev-parse --abbrev-ref HEAD'); - -if (branch !== 'main') { - fail(`Not on main (current: ${branch}). Releases must originate from main.`); -} - -// 3. Up to date with origin/main. -step('Fetching origin/main'); -sh('git fetch origin main --quiet'); - -const local = out('git rev-parse HEAD'); -const remote = out('git rev-parse origin/main'); - -if (local !== remote) { - const base = out('git merge-base HEAD origin/main'); - - if (base !== remote) { - fail('main is not up to date with origin/main. Pull or rebase first.'); - } -} - -// 4. Tag does not already exist (locally OR on origin). -let localTagExists = false; - -try { - out(`git rev-parse ${tag}`); - localTagExists = true; -} catch { - // tag does not exist locally - expected -} - -if (localTagExists) { - fail(`Tag ${tag} already exists locally. Bump the version in package.json first, or run "git tag -d ${tag}" to remove it.`); -} - -const remoteTag = out(`git ls-remote --tags origin ${tag}`); - -if (remoteTag) { - fail(`Tag ${tag} already exists on origin. Bump the version in package.json first.`); -} - -// 5. Run verify:release (full CI-equivalent suite). -step(`Running verify:release (this mirrors CI's verify job)`); - -try { - sh('pnpm verify:release'); -} catch { - fail('verify:release failed. Fix the issues above and re-run.'); -} - -// 6. Ensure changelog-driven release notes are valid before tagging. -step(`Validating changelog entry for ${tag}`); - -try { - generateReleaseNotes({ - repo: resolveGithubRepoSlug(pkg), - tag, - }); -} catch (error: unknown) { - const reason = error instanceof Error ? error.message : String(error); - fail(`release-notes preflight failed: ${reason}`); -} - -// 7. Create annotated tag at HEAD. -step(`Tagging ${tag} at ${local.slice(0, 7)}`); -sh(`git tag -a ${tag} -m "${tag}"`); - -// 8. Next steps. -process.stdout.write(`\n✓ ${tag} tagged locally at ${local.slice(0, 7)}.\n\n`); -process.stdout.write('Push to publish:\n'); -process.stdout.write(' git push origin main\n'); -process.stdout.write(` git push origin ${tag}\n\n`); -process.stdout.write('The tag push triggers the release workflow on GitHub Actions, which\n'); -process.stdout.write('re-runs the verify suite and publishes to npm via Trusted Publisher.\n\n'); -process.stdout.write(`To undo before pushing: git tag -d ${tag}\n\n`); diff --git a/scripts/release/lockstep-packages.ts b/scripts/release/lockstep-packages.ts index 087f14633..f395cb315 100644 --- a/scripts/release/lockstep-packages.ts +++ b/scripts/release/lockstep-packages.ts @@ -9,9 +9,10 @@ * * NOT derivable from here (different runtimes - kept in sync manually, guarded * by `verify:release-matrix` where possible): - * - `.github/workflows/release.yml` / `ci.yml` build/typecheck/pack - * steps (YAML, enumerated `--filter`s; release.yml build lines are asserted - * by `verify:release-matrix`). + * - `.github/workflows/release.yml` build steps (YAML, enumerated + * `--filter`s, asserted by `verify:release-matrix`). The root + * `build:packages` / `typecheck:packages` scripts select by workspace + * directory instead and need no edit. * - `scripts/ci/select-lanes.ts` RUNTIME_PACKAGES (dependency-free ESM that * runs before any install, so it cannot import this TS module). * - `site/scripts/sync-exo-vendor.ts` / `full-zip.ts` vendor tree - a smaller, diff --git a/scripts/verify-publish.ts b/scripts/verify-publish.ts new file mode 100644 index 000000000..94b7127c2 --- /dev/null +++ b/scripts/verify-publish.ts @@ -0,0 +1,42 @@ +/** + * Every published package packs and passes publint. + * + * The package set comes from `release/lockstep-packages.ts`, so a package that + * joins the release line is checked here without a second edit. That is the + * point of deriving it: the hand-written list this replaces had fallen three + * packages behind the release matrix, and a package publint never saw could + * have shipped with a broken `exports` map. + */ +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; + +import { INDEPENDENT_PACKAGES, LOCKSTEP_PACKAGES } from './release/lockstep-packages.ts'; + +const PUBLINT = 'publint@0.3.21'; + +const rootDir = resolve(import.meta.dirname, '..'); +const packages = [...LOCKSTEP_PACKAGES, ...INDEPENDENT_PACKAGES]; + +const run = (dir: string, command: string): boolean => { + console.log(`\n=== ${dir}: ${command} ===\n`); + // A shell so the pnpm shim resolves on Windows as well. + const result = spawnSync(command, { cwd: resolve(rootDir, dir), stdio: 'inherit', shell: true }); + return result.status === 0; +}; + +let failed = 0; + +for (const pkg of packages) { + const ok = run(pkg.dir, 'pnpm pack --dry-run') && run(pkg.dir, `pnpm dlx ${PUBLINT} --strict .`); + if (!ok) { + failed += 1; + console.error(`\n${pkg.name} failed the publish check.`); + } +} + +if (failed > 0) { + console.error(`\n${failed} of ${packages.length} published package(s) failed.`); + process.exit(1); +} + +console.log(`\nAll ${packages.length} published packages pack and pass publint.`); diff --git a/test/bench/render-plan-play.bench.ts b/test/bench/render-plan-play.bench.ts index 99e307850..a2c641ff1 100644 --- a/test/bench/render-plan-play.bench.ts +++ b/test/bench/render-plan-play.bench.ts @@ -1,6 +1,6 @@ // CPU-STUB backend, no GPU submission - MEASUREMENT ONLY, never a CI gate. // Not comparable to the real WebGL2/WebGPU backend, nor to the GPU-baseline -// suite (`test/perf/baseline/`, `pnpm perf:baseline`). +// suite (`packages/exojs-bench`, `pnpm bench`). // Sub-30% deltas between runs are noise, not signal. import { bench, describe } from 'vitest'; diff --git a/test/bench/rendering.bench.ts b/test/bench/rendering.bench.ts index 7ea727b1d..e7e73cdd6 100644 --- a/test/bench/rendering.bench.ts +++ b/test/bench/rendering.bench.ts @@ -1,6 +1,6 @@ // CPU-STUB backend, no GPU submission - MEASUREMENT ONLY, never a CI gate. // Not comparable to the real WebGL2/WebGPU backend, nor to the GPU-baseline -// suite (`test/perf/baseline/`, `pnpm perf:baseline`). +// suite (`packages/exojs-bench`, `pnpm bench`). // Sub-30% deltas between runs are noise, not signal. import { bench, describe } from 'vitest'; diff --git a/test/bench/transform-upload.bench.ts b/test/bench/transform-upload.bench.ts index 45c04f3aa..90ca2dc89 100644 --- a/test/bench/transform-upload.bench.ts +++ b/test/bench/transform-upload.bench.ts @@ -1,6 +1,6 @@ // CPU-STUB backend, no GPU submission - MEASUREMENT ONLY, never a CI gate. // Not comparable to the real WebGL2/WebGPU backend, nor to the GPU-baseline -// suite (`test/perf/baseline/`, `pnpm perf:baseline`). +// suite (`packages/exojs-bench`, `pnpm bench`). // Sub-30% deltas between runs are noise, not signal. import { bench, describe } from 'vitest'; diff --git a/test/ci/lane-commands.test.ts b/test/ci/lane-commands.test.ts index 1cd84a84b..eccaca754 100644 --- a/test/ci/lane-commands.test.ts +++ b/test/ci/lane-commands.test.ts @@ -31,8 +31,7 @@ const allLaneKeys = Object.keys( }), ); -const scriptsIn = (command: string): string[] => - [...command.matchAll(/\bpnpm (?:--filter "[^"]+" )*([\w:-]+)/g)].map(match => match[1]!).filter(script => script !== 'pack'); +const scriptsIn = (command: string): string[] => [...command.matchAll(/\bpnpm ([\w:-]+)/g)].map(match => match[1]!); const packageScripts = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8')) as { scripts: Record }; diff --git a/test/ci/perf-runs.test.ts b/test/ci/perf-runs.test.ts new file mode 100644 index 000000000..cc06af4e8 --- /dev/null +++ b/test/ci/perf-runs.test.ts @@ -0,0 +1,55 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { GATE_GROUPS } from '../../scripts/ci/gate-groups'; +import { PERF_RUNS, type PerfRunName } from '../../scripts/perf'; + +/** + * The perf runner's table against the tree: every in-process run names a + * module that exists, every composite names runs that exist, and the `smoke` + * run the sync gate depends on covers each in-process benchmark - it exists to + * catch those rotting against an API change, so one it skips is one that rots. + */ + +const repoRoot = resolve(import.meta.dirname!, '../..'); +const packageJson = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8')) as { scripts: Record }; +const runNames = Object.keys(PERF_RUNS) as PerfRunName[]; + +describe('perf runs', () => { + it.each(runNames)('`%s` is a module, a command or a composite', name => { + const run = PERF_RUNS[name]; + const kinds = ['module' in run, 'command' in run, 'runs' in run].filter(Boolean); + expect(kinds).toHaveLength(1); + }); + + it.each(runNames.filter(name => 'module' in PERF_RUNS[name]))('`%s` names a module that exists', name => { + const run = PERF_RUNS[name] as { module: string }; + expect(existsSync(resolve(repoRoot, run.module))).toBe(true); + }); + + it.each(runNames.filter(name => 'runs' in PERF_RUNS[name]))('`%s` composes runs that exist', name => { + const run = PERF_RUNS[name] as { runs: readonly string[] }; + for (const child of run.runs) { + expect(runNames).toContain(child); + } + }); + + it('smokes every in-process benchmark under test/perf/*-benchmark.ts', () => { + const benchmarks = runNames.filter(name => { + const run = PERF_RUNS[name] as { module?: string }; + return run.module?.endsWith('-benchmark.ts') && !run.module.includes('profile'); + }); + for (const benchmark of benchmarks) { + expect(PERF_RUNS.smoke.runs).toContain(benchmark); + } + expect(PERF_RUNS.smoke.runs).toContain('profile'); + }); + + it('is what the sync gate runs as `perf:smoke`', () => { + expect(GATE_GROUPS.sync).toContain('perf:smoke'); + expect(packageJson.scripts['perf:smoke']).toBe('pnpm perf smoke'); + expect(packageJson.scripts['perf']).toBe('tsx ./scripts/perf.ts'); + }); +}); diff --git a/test/ci/vitest-project-parity.test.ts b/test/ci/vitest-project-parity.test.ts index c7a0affb9..8b641bee5 100644 --- a/test/ci/vitest-project-parity.test.ts +++ b/test/ci/vitest-project-parity.test.ts @@ -16,6 +16,10 @@ import { LANES } from '../../scripts/ci/lanes'; * `rendering-alloc`) is exactly the kind of change that can drop a project on * the floor. * + * The `test` script selects by wildcard (`exojs*`) so a new package's project + * joins it by name alone; the projects deliberately kept out of the parallel + * suite are the ones named otherwise, and each needs a script of its own. + * * Browser projects are exempt: they run through their own lanes with their own * commands rather than through a `--project` list. */ @@ -28,8 +32,29 @@ const packageJson = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), ' const declaredProjects = [...config.matchAll(/name: '([\w-]+)'/g)].map(match => match[1]!).filter(name => !name.startsWith('browser-')); -const scriptText = Object.values(packageJson.scripts).join(' '); +/** The `--project` patterns a script passes to vitest. */ +const projectPatterns = (command: string): string[] => [...command.matchAll(/--project="?([^\s"]+)"?/g)].map(match => match[1]!); + +/** Vitest's own matching: `*` is a wildcard, a leading `!` negates, case-insensitive. */ +const matchesPattern = (pattern: string, name: string): boolean => { + const negated = pattern.startsWith('!'); + const body = negated ? pattern.slice(1) : pattern; + const regexp = new RegExp(`^${body.split('*').map(escapeRegExp).join('.*')}$`, 'i'); + return negated ? !regexp.test(name) : regexp.test(name); +}; + +const escapeRegExp = (text: string): string => text.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); + +const scriptsRunning = (project: string): string[] => + Object.entries(packageJson.scripts) + .filter(([, command]) => command.startsWith('vitest ') && projectPatterns(command).some(pattern => matchesPattern(pattern, project))) + .map(([name]) => name); + +/** Scripts reachable from a lane, following one level of `pnpm