diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c8ed9b954..a244170c3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -550,13 +550,23 @@ jobs: # a second full suite to a job that is already red and already inside a # 30-minute wall — turning an informative red into a killed job with no # attestation at all, which is the #16173 failure mode itself. + # + # `test test:repo` (#16466): six heavy packages split their suite into + # two turbo tasks -- `test` (package-local inputs) and `test:repo` + # (the repo-scanning tests, hashed on the wide `$TURBO_ROOT$` inputs + # that used to sit on `test`). Naming both runs a split package whole + # and is a no-op for every package without a `test:repo` script. The + # per-shard Turbo cache restored above (main-seeded) is what carries + # an unmoved `PKG#test` across a change outside the package; the + # completeness guard below reads both tasks' summaries. The cli slice + # leg stays `test`-only: cli is not split. STATUS=0 LOGS="" for LEG in __whole__ $SLICES; do if [ "$LEG" = __whole__ ]; then [ -n "$FILTERS" ] || continue LOG="$RUNNER_TEMP/test-core-packages.log" - set -- pnpm turbo run test $FILTERS --concurrency=4 --summarize --log-order=stream + set -- pnpm turbo run test test:repo $FILTERS --concurrency=4 --summarize --log-order=stream else PKG="${LEG%%=*}" SLICE="${LEG#*=}" diff --git a/package.json b/package.json index 3125bedf76..b84bdca13c 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dev:crm": "node scripts/check-dev-prereqs.mjs && pnpm check:console-sha && pnpm --filter @objectstack/example-crm dev", "dev:todo": "node scripts/check-dev-prereqs.mjs && pnpm check:console-sha && pnpm --filter @objectstack/example-todo dev", "spec:rebuild": "turbo run build --filter=...@objectstack/spec", - "test": "VITEST_MAX_WORKERS=$(node scripts/vitest-worker-cap.mjs) turbo run test --concurrency=50%", + "test": "VITEST_MAX_WORKERS=$(node scripts/vitest-worker-cap.mjs) turbo run test test:repo --concurrency=50%", "test:smoke": "turbo run test:smoke", "typecheck": "turbo run typecheck", "clean": "turbo run clean && rm -rf dist", diff --git a/packages/core/package.json b/packages/core/package.json index aabb64291d..bb40c5c65e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,7 +23,8 @@ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.examples.json && pnpm check:test-typecheck", "check:test-typecheck": "tsx ../../scripts/check-test-typecheck.mts --self-test && tsx ../../scripts/check-test-typecheck.mts --package packages/core --project tsconfig.test.json", "gen:test-typecheck-debt": "tsx ../../scripts/check-test-typecheck.mts --update --package packages/core --project tsconfig.test.json", - "test": "vitest run", + "test": "vitest run --project local", + "test:repo": "vitest run --project repo", "test:watch": "vitest" }, "devDependencies": { diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 6311316bf7..cbe17ecde7 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -1,10 +1,49 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; +import { readFileSync } from 'node:fs'; import path from 'path'; +// #16466 -- two vitest projects, two turbo tasks. `repo` owns the tests that read +// outside this package (the list is vitest.repo-tests.json, which +// check:cross-package-test-inputs holds equal to its own scan); `local` owns +// every other test file. turbo hashes the wide `$TURBO_ROOT$` inputs on +// `test:repo` only, so `test` stays cacheable across changes elsewhere in the +// repo. `extends: true` keeps the root options (aliases included) on both. +const REPO_TESTS: string[] = JSON.parse(readFileSync(path.join(__dirname, 'vitest.repo-tests.json'), 'utf8')); + export default defineConfig({ test: { + // Each project re-declares the root block's test options: a ROOT-level + // value is inert for a project run (measured on vitest 4.1.10, see + // check-registry-log-declared / check-console-intercept-disarm). + projects: [ + { + extends: true, + test: { + name: 'local', + include: configDefaults.include, + exclude: [...configDefaults.exclude, ...REPO_TESTS], + globals: true, environment: 'node', + // A late console.* must not redden a green suite (#10374); see the root + // block. A ROOT-level value is inert for a project run, so it is + // declared here as well (scripts/check-console-intercept-disarm.mjs). + disableConsoleIntercept: true, + }, + }, + { + extends: true, + test: { + name: 'repo', + include: REPO_TESTS, + globals: true, environment: 'node', + // A late console.* must not redden a green suite (#10374); see the root + // block. A ROOT-level value is inert for a project run, so it is + // declared here as well (scripts/check-console-intercept-disarm.mjs). + disableConsoleIntercept: true, + }, + }, + ], // A late console.* must not redden a green suite (#10374): vitest's worker // forwards console output over RPC and discards the promise, and a write // landing after teardown's rpcDone() snapshot is rejected into an unhandled diff --git a/packages/core/vitest.repo-tests.json b/packages/core/vitest.repo-tests.json new file mode 100644 index 0000000000..d50d2c99fe --- /dev/null +++ b/packages/core/vitest.repo-tests.json @@ -0,0 +1,4 @@ +[ + "src/security/authz-store-unavailable.test.ts", + "src/security/operation-private-keys.pin.test.ts" +] diff --git a/packages/objectql/package.json b/packages/objectql/package.json index c3d6b44252..2f10bb0fd1 100644 --- a/packages/objectql/package.json +++ b/packages/objectql/package.json @@ -19,7 +19,8 @@ }, "scripts": { "build": "tsup && node ../../scripts/check-dts-emitted.mjs", - "test": "vitest run", + "test": "vitest run --project local", + "test:repo": "vitest run --project repo", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json && pnpm check:test-typecheck", "check:test-typecheck": "tsx ../../scripts/check-test-typecheck.mts --self-test && tsx ../../scripts/check-test-typecheck.mts --package packages/objectql --project tsconfig.test.json", "gen:test-typecheck-debt": "tsx ../../scripts/check-test-typecheck.mts --update --package packages/objectql --project tsconfig.test.json" diff --git a/packages/objectql/vitest.config.ts b/packages/objectql/vitest.config.ts index f79c823d87..6113c8ad2c 100644 --- a/packages/objectql/vitest.config.ts +++ b/packages/objectql/vitest.config.ts @@ -40,10 +40,56 @@ // cannot silently re-point that file's premise. Without those two lines the // file's first case stays GREEN while testing `'warn'` under a name that says // `info` — measured red before they were added. -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +// #16466 -- two vitest projects, two turbo tasks. `repo` owns the tests that read +// outside this package (the list is vitest.repo-tests.json, which +// check:cross-package-test-inputs holds equal to its own scan); `local` owns +// every other test file. turbo hashes the wide `$TURBO_ROOT$` inputs on +// `test:repo` only, so `test` stays cacheable across changes elsewhere in the +// repo. `extends: true` keeps the root options (aliases included) on both. +const REPO_TESTS: string[] = JSON.parse(readFileSync(path.join(__dirname, 'vitest.repo-tests.json'), 'utf8')); export default defineConfig({ test: { + // Each project re-declares the root block's test options: a ROOT-level + // value is inert for a project run (measured on vitest 4.1.10, see + // check-registry-log-declared / check-console-intercept-disarm). + projects: [ + { + extends: true, + test: { + name: 'local', + include: configDefaults.include, + exclude: [...configDefaults.exclude, ...REPO_TESTS], + // #13517: quiet the registry's per-item registration chatter — the + // engine's own `OS_REGISTRY_LOG` seam, not a change to its shipped + // default. Enforced by scripts/check-registry-log-declared.mjs. + env: { OS_REGISTRY_LOG: 'warn' }, + // A late console.* must not redden a green suite (#10374); see the root + // block. A ROOT-level value is inert for a project run, so it is + // declared here as well (scripts/check-console-intercept-disarm.mjs). + disableConsoleIntercept: true, + }, + }, + { + extends: true, + test: { + name: 'repo', + include: REPO_TESTS, + // #13517: quiet the registry's per-item registration chatter — the + // engine's own `OS_REGISTRY_LOG` seam, not a change to its shipped + // default. Enforced by scripts/check-registry-log-declared.mjs. + env: { OS_REGISTRY_LOG: 'warn' }, + // A late console.* must not redden a green suite (#10374); see the root + // block. A ROOT-level value is inert for a project run, so it is + // declared here as well (scripts/check-console-intercept-disarm.mjs). + disableConsoleIntercept: true, + }, + }, + ], // A late console.* must not redden a green suite (#10374): vitest's worker // forwards console output over RPC and discards the promise, and a write // landing after teardown's rpcDone() snapshot is rejected into an unhandled diff --git a/packages/objectql/vitest.repo-tests.json b/packages/objectql/vitest.repo-tests.json new file mode 100644 index 0000000000..d86e10a882 --- /dev/null +++ b/packages/objectql/vitest.repo-tests.json @@ -0,0 +1,3 @@ +[ + "src/action-owner-key-single-source.test.ts" +] diff --git a/packages/rest/package.json b/packages/rest/package.json index 8c70d43272..3bfe41d842 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -21,7 +21,8 @@ "scripts": { "build": "tsup --config ../../tsup.config.ts && node ../../scripts/check-dts-emitted.mjs", "dev": "tsc -w", - "test": "vitest run", + "test": "vitest run --project local", + "test:repo": "vitest run --project repo", "check:test-typecheck": "tsx ../../scripts/check-test-typecheck.mts --self-test && tsx ../../scripts/check-test-typecheck.mts --package packages/rest --project tsconfig.test.json", "gen:test-typecheck-debt": "tsx ../../scripts/check-test-typecheck.mts --update --package packages/rest --project tsconfig.test.json", "typecheck": "tsc --noEmit && pnpm check:test-typecheck" diff --git a/packages/rest/vitest.config.ts b/packages/rest/vitest.config.ts index 7a5ec6b5d5..634421747c 100644 --- a/packages/rest/vitest.config.ts +++ b/packages/rest/vitest.config.ts @@ -1,10 +1,57 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; +import { readFileSync } from 'node:fs'; import path from 'path'; +// #16466 -- two vitest projects, two turbo tasks. `repo` owns the tests that read +// outside this package (the list is vitest.repo-tests.json, which +// check:cross-package-test-inputs holds equal to its own scan); `local` owns +// every other test file. turbo hashes the wide `$TURBO_ROOT$` inputs on +// `test:repo` only, so `test` stays cacheable across changes elsewhere in the +// repo. `extends: true` keeps the root options (aliases included) on both. +const REPO_TESTS: string[] = JSON.parse(readFileSync(path.join(__dirname, 'vitest.repo-tests.json'), 'utf8')); + export default defineConfig({ test: { + // Each project re-declares the root block's test options: a ROOT-level + // value is inert for a project run (measured on vitest 4.1.10, see + // check-registry-log-declared / check-console-intercept-disarm). + projects: [ + { + extends: true, + test: { + name: 'local', + include: configDefaults.include, + exclude: [...configDefaults.exclude, ...REPO_TESTS], + globals: true, environment: 'node', + // #13517: quiet the registry's per-item registration chatter — the + // engine's own `OS_REGISTRY_LOG` seam, not a change to its shipped + // default. Enforced by scripts/check-registry-log-declared.mjs. + env: { OS_REGISTRY_LOG: 'warn' }, + // A late console.* must not redden a green suite (#10374); see the root + // block. A ROOT-level value is inert for a project run, so it is + // declared here as well (scripts/check-console-intercept-disarm.mjs). + disableConsoleIntercept: true, + }, + }, + { + extends: true, + test: { + name: 'repo', + include: REPO_TESTS, + globals: true, environment: 'node', + // #13517: quiet the registry's per-item registration chatter — the + // engine's own `OS_REGISTRY_LOG` seam, not a change to its shipped + // default. Enforced by scripts/check-registry-log-declared.mjs. + env: { OS_REGISTRY_LOG: 'warn' }, + // A late console.* must not redden a green suite (#10374); see the root + // block. A ROOT-level value is inert for a project run, so it is + // declared here as well (scripts/check-console-intercept-disarm.mjs). + disableConsoleIntercept: true, + }, + }, + ], // A late console.* must not redden a green suite (#10374): vitest's worker // forwards console output over RPC and discards the promise, and a write // landing after teardown's rpcDone() snapshot is rejected into an unhandled diff --git a/packages/rest/vitest.repo-tests.json b/packages/rest/vitest.repo-tests.json new file mode 100644 index 0000000000..fa2835b720 --- /dev/null +++ b/packages/rest/vitest.repo-tests.json @@ -0,0 +1,3 @@ +[ + "src/meta-state-route-doc-spelling.test.ts" +] diff --git a/packages/runtime/package.json b/packages/runtime/package.json index b34067c451..ca06288792 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -24,7 +24,8 @@ "typecheck": "tsc --noEmit && pnpm check:test-typecheck", "check:test-typecheck": "tsx ../../scripts/check-test-typecheck.mts --self-test && tsx ../../scripts/check-test-typecheck.mts --package packages/runtime --project tsconfig.test.json", "gen:test-typecheck-debt": "tsx ../../scripts/check-test-typecheck.mts --update --package packages/runtime --project tsconfig.test.json", - "test": "vitest run" + "test": "vitest run --project local", + "test:repo": "vitest run --project repo" }, "dependencies": { "@objectstack/core": "workspace:*", diff --git a/packages/runtime/vitest.config.ts b/packages/runtime/vitest.config.ts index 63648b24cd..c3462ff780 100644 --- a/packages/runtime/vitest.config.ts +++ b/packages/runtime/vitest.config.ts @@ -33,9 +33,18 @@ // than production. The request lives HERE, in the harness, where the test // author can see it. -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; +import { readFileSync } from 'node:fs'; import path from 'node:path'; +// #16466 -- two vitest projects, two turbo tasks. `repo` owns the tests that read +// outside this package (the list is vitest.repo-tests.json, which +// check:cross-package-test-inputs holds equal to its own scan); `local` owns +// every other test file. turbo hashes the wide `$TURBO_ROOT$` inputs on +// `test:repo` only, so `test` stays cacheable across changes elsewhere in the +// repo. `extends: true` keeps the root options (aliases included) on both. +const REPO_TESTS: string[] = JSON.parse(readFileSync(path.join(__dirname, 'vitest.repo-tests.json'), 'utf8')); + export default defineConfig({ resolve: { // ARRAY form, not the object form: only the array form accepts a RegExp @@ -146,6 +155,44 @@ export default defineConfig({ ], }, test: { + // Each project re-declares the root block's test options: a ROOT-level + // value is inert for a project run (measured on vitest 4.1.10, see + // check-registry-log-declared / check-console-intercept-disarm). + projects: [ + { + extends: true, + test: { + name: 'local', + include: ['src/**/*.test.ts'], + exclude: [...configDefaults.exclude, ...REPO_TESTS], + globals: true, environment: 'node', + // #13517: quiet the registry's per-item registration chatter — the + // engine's own `OS_REGISTRY_LOG` seam, not a change to its shipped + // default. Enforced by scripts/check-registry-log-declared.mjs. + env: { OS_REGISTRY_LOG: 'warn' }, + // A late console.* must not redden a green suite (#10374); see the root + // block. A ROOT-level value is inert for a project run, so it is + // declared here as well (scripts/check-console-intercept-disarm.mjs). + disableConsoleIntercept: true, + }, + }, + { + extends: true, + test: { + name: 'repo', + include: REPO_TESTS, + globals: true, environment: 'node', + // #13517: quiet the registry's per-item registration chatter — the + // engine's own `OS_REGISTRY_LOG` seam, not a change to its shipped + // default. Enforced by scripts/check-registry-log-declared.mjs. + env: { OS_REGISTRY_LOG: 'warn' }, + // A late console.* must not redden a green suite (#10374); see the root + // block. A ROOT-level value is inert for a project run, so it is + // declared here as well (scripts/check-console-intercept-disarm.mjs). + disableConsoleIntercept: true, + }, + }, + ], // A late console.* must not redden a green suite (#10374): vitest's worker // forwards console output over RPC and discards the promise, and a write // landing after teardown's rpcDone() snapshot is rejected into an unhandled @@ -160,6 +207,5 @@ export default defineConfig({ env: { OS_REGISTRY_LOG: 'warn' }, globals: true, environment: 'node', - include: ['src/**/*.test.ts'], }, }); diff --git a/packages/runtime/vitest.repo-tests.json b/packages/runtime/vitest.repo-tests.json new file mode 100644 index 0000000000..5924bb313c --- /dev/null +++ b/packages/runtime/vitest.repo-tests.json @@ -0,0 +1,4 @@ +[ + "src/action-owner-key-single-source.test.ts", + "src/error-envelope.conformance.test.ts" +] diff --git a/packages/spec/package.json b/packages/spec/package.json index f4eb307a51..ca4b0991ba 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -279,7 +279,8 @@ "check:spec-changes": "tsx scripts/build-spec-changes.ts --check", "gen:upgrade-guide": "tsx scripts/build-upgrade-guide.ts", "check:upgrade-guide": "tsx scripts/build-upgrade-guide.ts --check", - "test": "vitest run", + "test": "vitest run --project local", + "test:repo": "vitest run --project repo", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "check:liveness": "tsx scripts/liveness/check-liveness.mts", diff --git a/packages/spec/vitest.config.ts b/packages/spec/vitest.config.ts index c3fa3a7a02..02138a90f1 100644 --- a/packages/spec/vitest.config.ts +++ b/packages/spec/vitest.config.ts @@ -1,9 +1,49 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +// #16466 -- two vitest projects, two turbo tasks. `repo` owns the tests that read +// outside this package (the list is vitest.repo-tests.json, which +// check:cross-package-test-inputs holds equal to its own scan); `local` owns +// every other test file. turbo hashes the wide `$TURBO_ROOT$` inputs on +// `test:repo` only, so `test` stays cacheable across changes elsewhere in the +// repo. `extends: true` keeps the root options (aliases included) on both. +const REPO_TESTS: string[] = JSON.parse(readFileSync(path.join(__dirname, 'vitest.repo-tests.json'), 'utf8')); export default defineConfig({ test: { + // Each project re-declares the root block's test options: a ROOT-level + // value is inert for a project run (measured on vitest 4.1.10, see + // check-registry-log-declared / check-console-intercept-disarm). + projects: [ + { + extends: true, + test: { + name: 'local', + include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'], + exclude: [...configDefaults.exclude, ...REPO_TESTS], + globals: true, environment: 'node', testTimeout: 60_000, + // A late console.* must not redden a green suite (#10374); see the root + // block. A ROOT-level value is inert for a project run, so it is + // declared here as well (scripts/check-console-intercept-disarm.mjs). + disableConsoleIntercept: true, + }, + }, + { + extends: true, + test: { + name: 'repo', + include: REPO_TESTS, + globals: true, environment: 'node', testTimeout: 60_000, + // A late console.* must not redden a green suite (#10374); see the root + // block. A ROOT-level value is inert for a project run, so it is + // declared here as well (scripts/check-console-intercept-disarm.mjs). + disableConsoleIntercept: true, + }, + }, + ], // A late console.* must not redden a green suite (#10374): vitest's worker // forwards console output over RPC and discards the promise, and a write // landing after teardown's rpcDone() snapshot is rejected into an unhandled @@ -13,7 +53,6 @@ export default defineConfig({ disableConsoleIntercept: true, globals: true, environment: 'node', - include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'], // The 17 export-surface pins that used to load the TypeScript compiler and // type-resolve the whole export surface inside a test case no longer do: // #4796 lifted that resolution into the `export-origins/` build-time diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json new file mode 100644 index 0000000000..a9e5e0aef7 --- /dev/null +++ b/packages/spec/vitest.repo-tests.json @@ -0,0 +1,28 @@ +[ + "scripts/category-title.test.ts", + "scripts/check-generated-ledger.test.ts", + "scripts/dist-freshness-adoption.test.ts", + "scripts/dist-freshness.test.ts", + "scripts/escape-mdx.test.ts", + "scripts/export-list.test.ts", + "scripts/file-description.test.ts", + "scripts/gen-sdui-manifest-cleanup.test.ts", + "scripts/gen-sdui-manifest-collision.test.ts", + "scripts/gen-sdui-manifest-write-target.test.ts", + "scripts/liveness/evidence.test.ts", + "scripts/liveness/proof-registry.test.ts", + "scripts/publish-smoke-port-collision.test.ts", + "scripts/query-pointer-row.test.ts", + "scripts/references-banner.test.ts", + "scripts/root-index.test.ts", + "scripts/schema-tree-freshness.test.ts", + "scripts/strictness-ledger-doc.test.ts", + "scripts/strictness-ledger.test.ts", + "src/api/error-catalog-docs.test.ts", + "src/data/api-methods-batch-conformance.test.ts", + "src/identity/position-delegatable-enforcer.pin.test.ts", + "src/shared/retired-key-migrate-sentence.test.ts", + "src/system/compliance-families-retirement.test.ts", + "src/system/constants/platform-object-names.test.ts", + "src/ui/action-requires-confirmation-docblock.pin.test.ts" +] diff --git a/packages/types/package.json b/packages/types/package.json index daccf968b9..5b376cbdc5 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -20,7 +20,8 @@ "scripts": { "build": "tsup && node ../../scripts/check-dts-emitted.mjs", "typecheck": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run --project local", + "test:repo": "vitest run --project repo" }, "dependencies": { "@objectstack/spec": "workspace:*" diff --git a/packages/types/vitest.config.ts b/packages/types/vitest.config.ts index 5e0591efc4..fa434d9ee7 100644 --- a/packages/types/vitest.config.ts +++ b/packages/types/vitest.config.ts @@ -4,10 +4,48 @@ // vitest's defaults, deliberately — a key added here re-specifies behaviour // for every test file in the package (packages/cli/vitest.config.ts's header // records the incident that taught that). -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +// #16466 -- two vitest projects, two turbo tasks. `repo` owns the tests that read +// outside this package (the list is vitest.repo-tests.json, which +// check:cross-package-test-inputs holds equal to its own scan); `local` owns +// every other test file. turbo hashes the wide `$TURBO_ROOT$` inputs on +// `test:repo` only, so `test` stays cacheable across changes elsewhere in the +// repo. `extends: true` keeps the root options (aliases included) on both. +const REPO_TESTS: string[] = JSON.parse(readFileSync(path.join(__dirname, 'vitest.repo-tests.json'), 'utf8')); export default defineConfig({ test: { + // Each project re-declares the root block's test options: a ROOT-level + // value is inert for a project run (measured on vitest 4.1.10, see + // check-registry-log-declared / check-console-intercept-disarm). + projects: [ + { + extends: true, + test: { + name: 'local', + include: configDefaults.include, + exclude: [...configDefaults.exclude, ...REPO_TESTS], + // A late console.* must not redden a green suite (#10374); see the root + // block. A ROOT-level value is inert for a project run, so it is + // declared here as well (scripts/check-console-intercept-disarm.mjs). + disableConsoleIntercept: true, + }, + }, + { + extends: true, + test: { + name: 'repo', + include: REPO_TESTS, + // A late console.* must not redden a green suite (#10374); see the root + // block. A ROOT-level value is inert for a project run, so it is + // declared here as well (scripts/check-console-intercept-disarm.mjs). + disableConsoleIntercept: true, + }, + }, + ], // A late console.* must not redden a green suite (#10374): vitest's worker // forwards console output over RPC and discards the promise, and a write // landing after teardown's rpcDone() snapshot is rejected into an unhandled diff --git a/packages/types/vitest.repo-tests.json b/packages/types/vitest.repo-tests.json new file mode 100644 index 0000000000..4fa1fed2ec --- /dev/null +++ b/packages/types/vitest.repo-tests.json @@ -0,0 +1,3 @@ +[ + "src/driver-error-classification.callers.test.ts" +] diff --git a/scripts/check-cross-package-test-inputs.mjs b/scripts/check-cross-package-test-inputs.mjs index ea7da9c6bb..b76e0e1900 100644 --- a/scripts/check-cross-package-test-inputs.mjs +++ b/scripts/check-cross-package-test-inputs.mjs @@ -220,7 +220,7 @@ // node scripts/check-cross-package-test-inputs.mjs --list-escapes // node scripts/check-cross-package-test-inputs.mjs --self-test -import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdtempSync } from 'node:fs'; +import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { join, resolve, relative, dirname, sep, isAbsolute } from 'node:path'; @@ -263,11 +263,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the INTERPOLATING TEMPLATE argument, `NEW_URL_LITERAL` sibling (#12085) ─': 7, 'the RESOLVER half (#10452)': 43, 'the entry guard, driven for real': 2, + 'the SPLIT test:repo task (#16466)': 16, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 7; +const SELF_TEST_BATTERY_FLOOR = 8; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -1455,23 +1456,17 @@ function verify() { console.error(`FAIL: cannot read turbo.json: ${e.message}`); process.exit(1); } + // A split package (#16466, the header above `readSplit`) hashes the radius on + // `#test:repo` and keeps `#test` package-local; its `repo` project's + // include list is held equal to the scan here, in both directions. + let splitCount = 0; for (const [name, { globs }] of Object.entries(CROSS_PACKAGE_TEST_INPUTS)) { - const task = turbo.tasks?.[`${name}#test`]; - if (!task) { - problems.push( - `turbo.json has no "${name}#test" task. Without it the package's test cache is\n` + - ` keyed on package-local files only, so a change to its declared globs replays\n` + - ` a stale green instead of re-running. Add it with inputs:\n` + - ` ${JSON.stringify(expectedInputs(globs))}`, - ); - continue; - } - const missing = globs.filter((g) => !(task.inputs ?? []).includes(`$TURBO_ROOT$/${g}`)); - if (missing.length) { - problems.push( - `turbo.json "${name}#test" inputs are missing the declared glob(s):\n` + - missing.map((g) => ` $TURBO_ROOT$/${g}`).join('\n'), - ); + const info = escaping.get(name); + const { split, listed } = info ? readSplit(join(REPO_ROOT, info.dir)) : { split: false, listed: null }; + if (split) splitCount++; + problems.push(...turboInputProblems(name, globs, turbo.tasks, split)); + if (split && info) { + problems.push(...repoProjectProblems(name, info.tests.map((t) => relative(info.dir, t)), listed)); } } @@ -1502,10 +1497,140 @@ function verify() { } console.log( `OK: ${escaping.size} package(s) read outside themselves, all declared, ` + - `and turbo.json hashes every declared glob.`, + `and turbo.json hashes every declared glob` + + (splitCount ? ` (${splitCount} of them on a split "${REPO_TASK}" task).` : '.'), ); } +// ── The SPLIT (#16466): `#test` and `#test:repo` ─────────────────── +// +// A heavy package that also carries repo-scanning tests paid its declared +// radius on every change anywhere inside it: `packages/**/*.ts` on +// `@objectstack/spec#test` re-ran 13,000 cases for a leaf `.ts` edit, and +// `content/**` re-ran spec and rest (~640s of CI-median suite wall) for a +// docs-only merge group. Moving the scanning files out was measured and +// refused -- 20 of the 33 import private internals of their home package -- +// so such a package splits its TASK, never its files: two vitest projects in +// one config, +// +// `local` every other test file -> `#test` package-local inputs +// `repo` the escaping tests -> `#test:repo` the declared radius +// +// The split is declared by the manifest's `test:repo` script. The `repo` +// project's include list is REPO_TESTS_FILE beside the config, and this gate +// holds that list EQUAL to its own scan in both directions: an escaping test +// missing from the list runs under `test`, whose hash never moves with its +// reads -- #7802 again, one task over -- and a listed file that no longer +// escapes is refused too, so the list cannot rot into "everything". Layer B +// follows the split: the declared globs must hash on `#test:repo`, and +// `#test` must carry NO `$TURBO_ROOT$` input at all, or the split gains +// nothing while reading as done. A package without a `test:repo` script keeps +// the radius on `#test` exactly as before. +export const REPO_TESTS_FILE = 'vitest.repo-tests.json'; +export const REPO_TASK = 'test:repo'; + +/** `{ split, listed }` for a package directory: `listed` is the JSON array, or null when unreadable. */ +export function readSplit(pkgDir) { + let manifest; + try { + manifest = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')); + } catch { + return { split: false, listed: null }; + } + if (typeof manifest?.scripts?.[REPO_TASK] !== 'string') return { split: false, listed: null }; + let listed = null; + try { + const parsed = JSON.parse(readFileSync(join(pkgDir, REPO_TESTS_FILE), 'utf8')); + if (Array.isArray(parsed) && parsed.every((p) => typeof p === 'string')) listed = parsed; + } catch { + listed = null; + } + return { split: true, listed }; +} + +/** + * Layer B for one package: the task that must hash the declared globs, and -- + * for a split package -- the task that must not. `tasks` is turbo.json's map. + */ +export function turboInputProblems(name, globs, tasks, split) { + const problems = []; + const owner = split ? `${name}#${REPO_TASK}` : `${name}#test`; + const task = tasks?.[owner]; + if (!task) { + problems.push( + `turbo.json has no "${owner}" task. Without it the package's test cache is\n` + + ` keyed on package-local files only, so a change to its declared globs replays\n` + + ` a stale green instead of re-running. Add it with inputs:\n` + + ` ${JSON.stringify(expectedInputs(globs))}`, + ); + } else { + const missing = globs.filter((g) => !(task.inputs ?? []).includes(`$TURBO_ROOT$/${g}`)); + if (missing.length) { + problems.push( + `turbo.json "${owner}" inputs are missing the declared glob(s):\n` + + missing.map((g) => ` $TURBO_ROOT$/${g}`).join('\n'), + ); + } + } + if (split) { + const wide = (tasks?.[`${name}#test`]?.inputs ?? []).filter((i) => i.startsWith('$TURBO_ROOT$/')); + if (wide.length) { + problems.push( + `turbo.json "${name}#test" still hashes repo-wide input(s) although the package splits its\n` + + ` escaping tests into "${owner}":\n` + + wide.map((i) => ` ${i}`).join('\n') + + `\n Move them to "${owner}" -- left here, every change inside the radius re-runs the\n` + + ` whole suite and the split gains nothing while reading as done.`, + ); + } + } + return problems; +} + +/** + * The `repo` project's include list against this gate's scan, both package-relative. + * `listed` null means the file could not be read as a JSON array of strings. + */ +export function repoProjectProblems(name, escapingTests, listed) { + if (listed === null) { + return [ + `${name} declares a "${REPO_TASK}" script but has no readable ${REPO_TESTS_FILE} beside its\n` + + ` vitest config (a JSON array of package-relative test paths). Write it with the\n` + + ` escaping tests:\n` + + ` ${JSON.stringify([...escapingTests].sort())}`, + ]; + } + const problems = []; + const listedSet = new Set(listed); + const escSet = new Set(escapingTests); + const missing = escapingTests.filter((t) => !listedSet.has(t)); + if (missing.length) { + problems.push( + `${name} has escaping test(s) that are NOT in ${REPO_TESTS_FILE}, so they run under\n` + + ` "${name}#test", whose hash never moves with what they read:\n` + + missing.map((t) => ` ${t}`).join('\n') + + `\n Add them to the list (it is the \`repo\` project's include).`, + ); + } + const extra = listed.filter((t) => !escSet.has(t)); + if (extra.length) { + problems.push( + `${name}'s ${REPO_TESTS_FILE} lists file(s) that do not read outside the package:\n` + + extra.map((t) => ` ${t}`).join('\n') + + `\n Remove them -- they belong to "${name}#test", and a list wider than the scan is\n` + + ` one that rots into "everything".`, + ); + } + const duplicated = listed.filter((t, i) => listed.indexOf(t) !== i); + if (duplicated.length) { + problems.push( + `${name}'s ${REPO_TESTS_FILE} has duplicated entr${duplicated.length === 1 ? 'y' : 'ies'}:\n` + + [...new Set(duplicated)].map((t) => ` ${t}`).join('\n'), + ); + } + return problems; +} + function expectedInputs(globs) { return ['$TURBO_DEFAULT$', '!dist/**', '!coverage/**', '!.turbo/**', ...globs.map((g) => `$TURBO_ROOT$/${g}`)]; } @@ -2428,6 +2553,42 @@ function selfTest() { // A spawned child is the only honest witness: the guard's answer depends on // what node puts in `process.argv[1]`, which cannot be modelled in-process. // Without this case the guard can be deleted as quietly as it was missing. + battery('the SPLIT test:repo task (#16466)'); + { + const T = (inputs) => ({ inputs }); + const G = ['content/**']; + const local = T(['$TURBO_DEFAULT$', '!dist/**']); + const wideOnTest = T(['$TURBO_DEFAULT$', '$TURBO_ROOT$/content/**']); + const repoTask = T(['$TURBO_DEFAULT$', '$TURBO_ROOT$/content/**']); + // Layer B, both shapes + ok('unsplit: the radius on #test is green', turboInputProblems('p', G, { 'p#test': wideOnTest }, false).length === 0); + ok('unsplit: a glob missing from #test reds', turboInputProblems('p', G, { 'p#test': local }, false).some((m) => m.includes('missing the declared glob'))); + ok('split: the radius on #test:repo and none on #test is green', turboInputProblems('p', G, { 'p#test': local, 'p#test:repo': repoTask }, true).length === 0); + ok('split: a #test:repo task without its wide input reds', turboInputProblems('p', G, { 'p#test': local, 'p#test:repo': local }, true).some((m) => m.includes('"p#test:repo" inputs are missing'))); + ok('split: no #test:repo task at all reds naming it', turboInputProblems('p', G, { 'p#test': local }, true).some((m) => m.includes('has no "p#test:repo" task'))); + ok('split: a wide input LEFT on #test reds even when test:repo is complete', turboInputProblems('p', G, { 'p#test': wideOnTest, 'p#test:repo': repoTask }, true).some((m) => m.includes('still hashes repo-wide input'))); + ok('split: the pre-split shape (radius on #test alone) reds twice', turboInputProblems('p', G, { 'p#test': wideOnTest }, true).length === 2); + // the include list against the scan, both directions + ok('split: an escaping file left out of the list (running under `test`) reds naming it', repoProjectProblems('p', ['src/a.test.ts', 'src/b.test.ts'], ['src/a.test.ts']).some((m) => m.includes('NOT in') && m.includes('src/b.test.ts'))); + ok('split: a listed file that does not escape reds naming it', repoProjectProblems('p', ['src/a.test.ts'], ['src/a.test.ts', 'src/c.test.ts']).some((m) => m.includes('do not read outside') && m.includes('src/c.test.ts'))); + ok('split: a list equal to the scan is green', repoProjectProblems('p', ['src/a.test.ts'], ['src/a.test.ts']).length === 0); + ok('split: order does not matter', repoProjectProblems('p', ['src/b.test.ts', 'src/a.test.ts'], ['src/a.test.ts', 'src/b.test.ts']).length === 0); + ok('split: an unreadable list reds once, prescribing the scan', repoProjectProblems('p', ['src/a.test.ts'], null).length === 1 && repoProjectProblems('p', ['src/a.test.ts'], null)[0].includes('src/a.test.ts')); + ok('split: a duplicated entry reds', repoProjectProblems('p', ['src/a.test.ts'], ['src/a.test.ts', 'src/a.test.ts']).some((m) => m.includes('duplicated'))); + // readSplit, driven on disk + const dir = mkdtempSync(join(tmpdir(), 'crosspkg-split-')); + try { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'p', scripts: { test: 'vitest run --project local' } })); + ok('readSplit: no test:repo script is not a split', readSplit(dir).split === false); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'p', scripts: { test: 'vitest run --project local', 'test:repo': 'vitest run --project repo' } })); + ok('readSplit: a test:repo script with no list file is split with listed=null', readSplit(dir).split === true && readSplit(dir).listed === null); + writeFileSync(join(dir, REPO_TESTS_FILE), JSON.stringify(['src/a.test.ts'])); + ok('readSplit: the list is read back as written', JSON.stringify(readSplit(dir).listed) === JSON.stringify(['src/a.test.ts'])); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + battery('the entry guard, driven for real'); const importProbe = spawnSync( process.execPath, diff --git a/scripts/check-test-completeness.mjs b/scripts/check-test-completeness.mjs index 73306b503b..21ce6e740d 100644 --- a/scripts/check-test-completeness.mjs +++ b/scripts/check-test-completeness.mjs @@ -161,7 +161,14 @@ export const EXIT_PREREQUISITE_NOT_MET = 3; // `@objectstack/cli:test: Tests 381 passed | 3 skipped (384)` // ^ turbo prefix (absent when vitest runs directly) ^ tallies ^ declared -const SUMMARY = /^(?:(\S+?):test:)?\s*(Test Files|Tests)\s+(.+?)\s+\((\d+)\)\s*$/; +// #16466: a package may split its suite into two turbo tasks, `test` and +// `test:repo` (the escaping tests, hashed on the repo-wide inputs). Both are +// vitest runs of the SAME package and each prints its own summary, so both are +// read and attributed to the package -- two rows, neither lost -- while a +// `test:smoke` or `build` line still attributes nothing. Every reader below +// that names the task consults this set; there is no second spelling. +const TEST_TASKS = new Set(['test', 'test:repo']); +const SUMMARY = /^(?:(\S+?):test(?::repo)?:)?\s*(Test Files|Tests)\s+(.+?)\s+\((\d+)\)\s*$/; // turbo's end-of-run roster, both measured on turbo 2.10.10: // `Failed: @objectstack/embedder-openai#test, @objectstack/sdui-parser#test` @@ -235,7 +242,7 @@ const GROUP_OPEN = /^(?:::group::|##\[group\])(.+?)\s*$/; const GROUP_CLOSE = /^(?:::endgroup::|##\[endgroup\])\s*$/; // A line that is nothing but `:test`. Reached only after the two markers // above have had their turn, so a `::group::` header can never land here. -const BARE_TASK_HEADER = /^(\S+):test$/; +const BARE_TASK_HEADER = /^(\S+):test(?::repo)?$/; export function parseSummaries(text) { const rows = []; @@ -249,10 +256,11 @@ export function parseSummaries(text) { if (open) { // `@objectstack/spec:test` -> spec, but `@objectstack/spec:build` and // GitHub's own `Run