Skip to content

Commit f48f3f1

Browse files
claude[bot]claude
andauthored
ci(test): e2e and live filename tiers move to a nightly run on main (#16481)
* ci(test): e2e and live filename tiers move to a nightly run on main One switch, OS_TEST_TIERS (queue | nightly, unset = queue), read once in scripts/nightly-tiers.mjs and applied in packages/cli's tier walk — the one config that owns tier files (60 *.e2e.test.ts; the tree owns no *.live.test.*). turbo.json hashes the variable into every test task so a nightly can never replay a queue-mode cache entry. ci.yml's shard step spells queue; test-nightly-tiers.yml runs the two tiers on main, two shards through the existing partitioner, and files or refreshes one deduplicated card on red. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox * ci(nightly-tiers): pull_request paths trigger and a path-less population declaration dispatch-gates refuses a family reachable only from a scheduled workflow, so the nightly carries the patrol posture: a pull_request trigger paths-filtered to its own file and the switch reader. The reader declares no-path-population (its self-test drives a temp fixture) and resolves vitest's CLI entry by walking up from require.resolve('vitest') instead of spelling a subpath the derivation read as a population. Concurrency keyed per ref so a PR run never queues behind main's. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox * test(cli): the secret-orphans driver contract declares the key its production-posture boot needs The test sets NODE_ENV=production and boots a real kernel; LocalCryptoProvider then needs a stable key, which this test never supplied. It was green only when an earlier dev-mode boot in the same job had persisted one to the runner's home directory (the ordering coupling serve-process.ts documents) — and with the e2e tier no longer in the queue run, that sibling is gone. Set OS_SECRET_KEY to a fresh random value in beforeAll, restored in afterAll beside NODE_ENV; nothing is written to disk and nothing is depended on from a sibling. Ruled by the seat for exactly this one file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 32c917d commit f48f3f1

8 files changed

Lines changed: 1309 additions & 21 deletions

File tree

.github/workflows/ci.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,14 @@ jobs:
497497
- name: Run this shard's tests
498498
env:
499499
NODE_OPTIONS: --report-on-signal --report-signal=SIGUSR2 --report-directory=${{ runner.temp }}/stall-reports
500+
# The `e2e` and `live` filename tiers run NIGHTLY on main
501+
# (test-nightly-tiers.yml), not here: `queue` is the per-PR and
502+
# merge-queue setting, read once in scripts/nightly-tiers.mjs. Spelled
503+
# explicitly even though unset reads the same, so the setting this
504+
# required check verifies is written where the check runs.
505+
# turbo.json hashes it in the `test` task's `env`, which is what lets
506+
# it reach vitest under strict env mode at all.
507+
OS_TEST_TIERS: queue
500508
run: |
501509
if [ ! -s "$RUNNER_TEMP/shard-packages.txt" ]; then
502510
echo "No packages on this shard — nothing to test."

.github/workflows/test-nightly-tiers.yml

Lines changed: 549 additions & 0 deletions
Large diffs are not rendered by default.

packages/cli/src/commands/secret/orphans.driver-contract.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
*/
4545

4646
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
47+
import { randomBytes } from 'node:crypto';
4748
import { mkdtempSync, rmSync } from 'node:fs';
4849
import { tmpdir } from 'node:os';
4950
import { dirname, join, resolve } from 'node:path';
@@ -117,10 +118,15 @@ describe('os secret orphans — the concrete driver behind both reads (#14843)',
117118
}
118119
savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH;
119120
savedEnv.NODE_ENV = process.env.NODE_ENV;
121+
savedEnv.OS_SECRET_KEY = process.env.OS_SECRET_KEY;
120122
// Deliberately absent: no compiled artifact, so the boot is the bare data
121123
// stack plus the two plugins the command passes.
122124
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');
123125
process.env.NODE_ENV = 'production';
126+
// The key this production-posture boot needs, declared here rather than
127+
// inherited from a sibling's persisted `$HOME/.objectstack/dev-crypto-key`
128+
// (#16491): a fresh value per run, never written to disk.
129+
process.env.OS_SECRET_KEY = randomBytes(32).toString('hex');
124130
// The command does not pass `projectRoot`, so its boot takes `process.cwd()`
125131
// for its state directory. Stand in the tempdir so the run under test keeps
126132
// its state there instead of in whatever directory vitest started in.
@@ -154,7 +160,7 @@ describe('os secret orphans — the concrete driver behind both reads (#14843)',
154160
if (savedEnv[key] === undefined) delete process.env[key];
155161
else process.env[key] = savedEnv[key];
156162
}
157-
for (const key of ['OS_ARTIFACT_PATH', 'NODE_ENV'] as const) {
163+
for (const key of ['OS_ARTIFACT_PATH', 'NODE_ENV', 'OS_SECRET_KEY'] as const) {
158164
if (savedEnv[key] === undefined) delete process.env[key];
159165
else process.env[key] = savedEnv[key];
160166
}

packages/cli/vitest-tiers.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,42 @@
8585
* tokens that must track the predicate, which is the exact class of copy this
8686
* change exists to delete. Revisit only with a measurement that says it costs
8787
* something real.
88+
*
89+
* ## The NIGHTLY tiers are a second, orthogonal cut — by NAME (#16455)
90+
*
91+
* The two tiers above answer "what does this file DO" and decide which
92+
* project collects it. `OS_TEST_TIERS` answers a different question — "which
93+
* RUN is this" — and is decided by the file's NAME alone: `*.e2e.test.*` and
94+
* `*.live.test.*` are the nightly tiers, everything else is the queue's. The
95+
* switch is read ONCE, in `scripts/nightly-tiers.mjs` (the values, the default
96+
* and the refusal of anything else live there), and applied here in
97+
* `testFilesOnDisk()` — the one walk both `integrationTestFiles()` and
98+
* `unitTestFiles()` derive from — so the setting narrows the POPULATION and
99+
* the behavioural predicate then partitions whatever is left:
100+
*
101+
* OS_TEST_TIERS unset / queue population = every test file that is NOT nightly-tier
102+
* OS_TEST_TIERS=nightly population = exactly the nightly-tier files
103+
*
104+
* Both projects stay a partition of that population BY CONSTRUCTION
105+
* (`unit` = population − predicate, `integration` = population ∩ predicate),
106+
* and `test/vitest-tiers-partition.test.ts` still measures it under whichever
107+
* setting it runs in: its `vitest list` child inherits the switch through
108+
* `childEnv()` and its filesystem walk is this function, so the two sides of
109+
* every equality it asserts are read under the same setting.
110+
*
111+
* ⛔ The two cuts deliberately disagree on 5 files and that is not a defect:
112+
* a file that carries the `.e2e` name and spawns plain node is nightly-tier
113+
* (name) AND `unit` (behaviour); a file that spawns the CLI without the name
114+
* is queue (name) AND `integration` (behaviour). The ruling that landed the
115+
* nightly selects by the existing filename tiers only and renames nothing, so
116+
* the name decides the run and the behaviour decides the project, and no file
117+
* is renamed to make the two agree.
88118
*/
89119

90120
import { readdirSync, readFileSync } from 'node:fs';
91121
import { join, relative, sep } from 'node:path';
92122
import { maskComments } from '../../scripts/js-comment-mask.mjs';
123+
import { readTierMode, selectTierFiles } from '../../scripts/nightly-tiers.mjs';
93124

94125
// ---------------------------------------------------------------------------
95126
// The predicate
@@ -164,7 +195,13 @@ export function tierOfFile(pkgRoot: string, relPath: string): TierSignals {
164195
const TEST_FILE_RE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
165196
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', 'coverage']);
166197

167-
/** `pkgRoot`-relative, POSIX-separated paths of every test file on disk, sorted. */
198+
/**
199+
* `pkgRoot`-relative, POSIX-separated paths of every test file on disk that
200+
* the current `OS_TEST_TIERS` setting selects, sorted — the nightly-tier files
201+
* under `nightly`, everything else under `queue` (the default). See "The
202+
* NIGHTLY tiers" in the header: this is the ONE place the switch narrows this
203+
* package's population, and both tier derivations below read from it.
204+
*/
168205
export function testFilesOnDisk(pkgRoot: string): string[] {
169206
const out: string[] = [];
170207
const walk = (dir: string): void => {
@@ -176,16 +213,33 @@ export function testFilesOnDisk(pkgRoot: string): string[] {
176213
}
177214
};
178215
walk(pkgRoot);
179-
return out.sort();
216+
return selectTierFiles(out.sort(), readTierMode());
180217
}
181218

182219
/**
183-
* The integration tier: every test file on disk the predicate calls integration.
220+
* The integration tier: every selected test file the predicate calls integration.
184221
*
185222
* This is what `vitest.config.ts` feeds to the `integration` project's
186-
* `include` and the `unit` project's `exclude`, so the two projects stay a
223+
* `include`; `unitTestFiles()` is its complement, so the two projects stay a
187224
* partition of the population by CONSTRUCTION rather than by maintenance.
188225
*/
189226
export function integrationTestFiles(pkgRoot: string): string[] {
190227
return testFilesOnDisk(pkgRoot).filter((file) => isIntegration(tierOfFile(pkgRoot, file)));
191228
}
229+
230+
/**
231+
* The unit tier: the selected population MINUS the integration tier — the exact
232+
* complement of `integrationTestFiles()` over the same walk, handed to the
233+
* `unit` project's `include`. Spelled as an include list rather than as
234+
* `exclude: INTEGRATION_FILES` so that BOTH projects read the switched
235+
* population above; an exclude-shaped unit tier would fall back to vitest's
236+
* default `include` and collect nightly-tier files the queue must not run.
237+
*
238+
* `integration` defaults to a fresh derivation; the config passes the list it
239+
* already derived so the second tier costs one walk (~10ms) and no second
240+
* classification pass (~250ms measured on the box this landed on).
241+
*/
242+
export function unitTestFiles(pkgRoot: string, integration: readonly string[] = integrationTestFiles(pkgRoot)): string[] {
243+
const integrationSet = new Set(integration);
244+
return testFilesOnDisk(pkgRoot).filter((file) => !integrationSet.has(file));
245+
}

packages/cli/vitest.config.ts

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -563,27 +563,66 @@
563563
// the PR that landed this section; re-measure it when the population moves,
564564
// and print the commit here.
565565
//
566+
// ## THE NIGHTLY TIERS (#16455) — a second cut, by NAME, read from `OS_TEST_TIERS`
567+
//
568+
// Maintainer direction (2026-09-07, verbatim): 「我想的是测试会不会太多,是否都是
569+
// 必要的,是不是应该砍,每次修改都要完整的测试吗」. The `e2e` and `live` tiers
570+
// leave the per-PR and merge-queue runs and run nightly on `main`; selection is
571+
// by the EXISTING filename tiers only (`*.e2e.test.*`, `*.live.test.*`) and no
572+
// file is renamed, deleted or edited to move it. Measured on 6eba38f5a3: this
573+
// package owns every one of the tree's 60 `*.e2e.test.ts` files and the tree
574+
// owns no `*.live.test.*` at all, so this is the one config that reads the
575+
// switch today — read through `scripts/nightly-tiers.mjs`, the single reader of
576+
// the variable, so a package that adopts a tier tomorrow imports rather than
577+
// re-spells it.
578+
//
579+
// OS_TEST_TIERS unset / queue this package's population = the 212 non-tier files (180 unit + 32 integration)
580+
// OS_TEST_TIERS=nightly this package's population = exactly the 60 tier files (1 unit + 59 integration)
581+
//
582+
// (272 test files on disk at 6eba38f5a3, read from `vitest list --filesOnly`
583+
// under each setting and each `--project`; 212 + 60 = 272, no file in both.)
584+
//
585+
// The cut is applied in ONE place — `testFilesOnDisk()` in `vitest-tiers.ts`,
586+
// the walk both derivations below read from — so the behavioural partition
587+
// into `unit` / `integration` operates on whatever population the setting
588+
// selected, and the two projects remain a partition of it by construction.
589+
// That is also why the unit tier is now an INCLUDE list rather than
590+
// `exclude: INTEGRATION_FILES`: an exclude-shaped unit project falls back to
591+
// vitest's default `include`, which would collect the tier files the queue
592+
// must not run. `test/vitest-tiers-partition.test.ts` measures all of this
593+
// under whichever setting it runs in (its `vitest list` child inherits the
594+
// switch); under `nightly` it is not itself collected — it carries no tier
595+
// name — which is the ruled behaviour, not a gap.
596+
//
597+
// ⛔ The switch reaches vitest only because `turbo.json` names `OS_TEST_TIERS`
598+
// in the `test` task's `env`: turbo 2.10 runs in STRICT env mode and strips
599+
// every undeclared variable before the task's shell sees it. It sits in `env`
600+
// (hashed) rather than `passThroughEnv` on purpose — a `test` task's cached
601+
// outcome depends on the setting, so the setting is in the hash and a nightly
602+
// can never replay a queue-mode cache entry as `>>> FULL TURBO`.
603+
//
566604
// ⚠️ INLINE PROJECTS INHERIT NOTHING BY DEFAULT — `extends: true` is what
567605
// carries this file's `resolve.alias` table and `test.server.deps.external`
568606
// into each project (vitest 4.1.10: an inline project without it gets a fresh
569607
// Vite config, so the source aliases the gate above guards would be declared
570608
// here and enforced nowhere). `disableConsoleIntercept: true` is repeated
571609
// inside every project because `check:console-intercept-disarm` measured the
572-
// root-level setting inert under projects. `exclude` for the unit tier spreads
573-
// `configDefaults.exclude` first: an `exclude` that names only the integration
574-
// files would drop the `node_modules` exclusion and start collecting
575-
// dependencies' own test files.
576-
import { configDefaults, defineConfig } from 'vitest/config';
610+
// root-level setting inert under projects. Both projects carry an explicit
611+
// `include` of exact paths, so neither reaches vitest's default `include`
612+
// (which would collect the whole tree) and neither needs its own
613+
// `node_modules` exclusion: an exact-path list matches nothing it does not name.
614+
import { defineConfig } from 'vitest/config';
577615
import path from 'path';
578-
import { integrationTestFiles } from './vitest-tiers.js';
616+
import { integrationTestFiles, unitTestFiles } from './vitest-tiers.js';
579617

580-
// The integration tier, DERIVED from what the files DO — never written down.
581-
// `vitest-tiers.ts` holds the predicate, the walk and the argument for both;
582-
// `test/vitest-tiers-partition.test.ts` pins what a derivation cannot pin
583-
// about itself. Package-root-relative, POSIX-separated, sorted; each entry is
584-
// an exact path, which is what lets the same array serve as the integration
585-
// project's `include` and the unit project's `exclude`.
618+
// The two tiers, DERIVED from what the files DO — never written down — over
619+
// the population `OS_TEST_TIERS` selects. `vitest-tiers.ts` holds the
620+
// predicate, the walk and the argument for both; `test/vitest-tiers-partition.test.ts`
621+
// pins what a derivation cannot pin about itself. Package-root-relative,
622+
// POSIX-separated, sorted; each entry is an exact path, which is what lets
623+
// each array serve as its project's `include`.
586624
export const INTEGRATION_FILES = integrationTestFiles(__dirname);
625+
export const UNIT_FILES = unitTestFiles(__dirname, INTEGRATION_FILES);
587626

588627
export default defineConfig({
589628
resolve: {
@@ -662,17 +701,18 @@ export default defineConfig({
662701
external: [/packages[\/]types[\/]dist/],
663702
},
664703
},
665-
// The two tiers (#13504) — see the header section of the same name. Both
666-
// `extends: true` so each project inherits the `resolve.alias` table and
667-
// the `server.deps.external` entry above; each repeats the console-intercept
704+
// The two tiers (#13504) — see the header section of the same name, and
705+
// "THE NIGHTLY TIERS" for the population both read. Both `extends: true`
706+
// so each project inherits the `resolve.alias` table and the
707+
// `server.deps.external` entry above; each repeats the console-intercept
668708
// disarm because the root-level one is inert under projects.
669709
projects: [
670710
{
671711
extends: true,
672712
test: {
673713
name: 'unit',
674714
disableConsoleIntercept: true,
675-
exclude: [...configDefaults.exclude, ...INTEGRATION_FILES],
715+
include: UNIT_FILES,
676716
},
677717
},
678718
{

scripts/nightly-tiers.d.mts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Types for the `nightly-tiers.mjs` exports a TypeScript consumer reads -- the
2+
// same problem, and the same fix, as `js-comment-mask.d.mts` next door (#5475).
3+
//
4+
// The module itself stays `.mjs`: it carries a `--self-test` / `--packages` /
5+
// `--check` / `--failing-files` CLI run with bare `node` from the nightly
6+
// workflow, and every root script here is authored that way. What needs the
7+
// declaration is the other direction -- `packages/cli/vitest-tiers.ts` imports
8+
// the switch from inside a tsc program (`tsconfig.test.json`), where an untyped
9+
// `.mjs` import is TS7016 and `readTierMode` silently becomes `any`.
10+
//
11+
// PARTIAL BY DESIGN, the `check-regen-pending.d.mts` shape: the module exports
12+
// more names than this declares, and importing an undeclared one is `TS2305`
13+
// -- loud, red and immediate -- never a silent `any`. Keep this file in step
14+
// with the module by hand; `check:declaration-mirrors` holds the names, kinds
15+
// and required arities.
16+
17+
/** The two tiers the switch moves off the per-PR and merge-queue runs. */
18+
export const NIGHTLY_TIERS: readonly string[];
19+
20+
/** The two legal spellings of `OS_TEST_TIERS`. */
21+
export const TIER_MODES: readonly string[];
22+
23+
/** The environment variable the switch is read from. */
24+
export const TIER_ENV: string;
25+
26+
/** A test file in one of the nightly tiers, judged on its basename. */
27+
export const NIGHTLY_TIER_FILE_RE: RegExp;
28+
29+
export function isNightlyTierFile(relPath: string): boolean;
30+
31+
/**
32+
* The switch's value: `queue` when unset or empty, else exactly `queue` or
33+
* `nightly`; any other spelling throws.
34+
*/
35+
export function readTierMode(env?: Record<string, string | undefined>): 'queue' | 'nightly';
36+
37+
/**
38+
* The files `mode` selects out of `files`: under `queue` everything NOT in a
39+
* nightly tier, under `nightly` exactly what is. Order preserved.
40+
*/
41+
export function selectTierFiles(files: string[], mode: 'queue' | 'nightly'): string[];

0 commit comments

Comments
 (0)