mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Revert iteration checks and custom Vitest selection (#34834)
* revert: remove iteration checks and custom Vitest selection * chore: use upstream affected-test selection --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
+3
-78
@@ -93,51 +93,6 @@ pnpm dev:fresh [--keep] [--seed-from[=<profile dir>]] # temporary profile, rem
|
||||
|
||||
### Targeting Local Tests
|
||||
|
||||
During iteration, use `pnpm check:iteration --maxWorkers=2` to select checks for staged,
|
||||
unstaged, and untracked edits against HEAD. Before handoff, run
|
||||
`pnpm check:changed --maxWorkers=2` for the whole branch against its merge base with
|
||||
`origin/main` (override with `--base=<ref>`). `check:changed --scope=worktree` is the
|
||||
explicit form of `check:iteration`. A clean worktree has nothing to check in iteration
|
||||
mode, even if the branch has committed changes. Both commands check the current
|
||||
working-tree bytes; they do not validate a different version stored only in the index.
|
||||
|
||||
Both gates print each step's command, deadline, start, and completion, emit a heartbeat
|
||||
every 15 seconds, and stream Vitest progress. Each step has a five-minute deadline;
|
||||
pass `--stepTimeoutMs=<ms>` to increase it for a larger check. A timeout fails the gate
|
||||
and requests termination of the step's process group/tree. Process shutdown can take
|
||||
additional time. `--maxWorkers` also caps concurrent check tasks, while typecheck and
|
||||
Vitest retain their own bounded worker pools. The deadline applies to each step,
|
||||
not to the total command, and does not apply to direct `pnpm test` commands.
|
||||
Passing results are cached separately for branch and worktree scope; a timeout, failure,
|
||||
or cancellation never records a pass. Starting a real recheck invalidates the earlier
|
||||
pass for that scope, including with `--force`. The cache includes runtime and effective
|
||||
test-selection context. Inputs are rehashed before saving a pass; an edit detected
|
||||
during checks fails the gate and requires a retry. Keep edits paused during checks:
|
||||
this is not an immutable filesystem snapshot.
|
||||
For gates with unit tests, reuse also requires matching dependency-record evidence.
|
||||
New incomplete records arriving during checks prevent a pass; a successful run can
|
||||
cache its own passing record updates immediately. Unreadable or oversized evidence
|
||||
disables caching without discarding otherwise successful checks.
|
||||
|
||||
Stream the command's output to observe progress. If you redirect it to a log, tail that
|
||||
log while the same command runs. A tool's output-yield interval can be short, but its
|
||||
execution deadline must allow the selected steps and process shutdown to finish; poll
|
||||
the existing process/session instead of launching a replacement on an observation timeout.
|
||||
For example, start `pnpm check:iteration --maxWorkers=2` with a short tool-output yield,
|
||||
then read that same running session until it exits and preserve its exit status.
|
||||
Uncatchable termination of the gate, or descendants that escape its POSIX process group,
|
||||
can require separate cleanup; these are not proof of a completed or passing check.
|
||||
Closed output consumers cancel active checks. Slow consumers can omit live progress
|
||||
with a notice; a final output drain that cannot finish fails the command.
|
||||
|
||||
Iteration checks use the CI unit inventory, leaving shell, Electron, and cross-version
|
||||
tests to their dedicated CI jobs. Ordinary `pnpm test` and the branch handoff gate retain
|
||||
their broader local discovery, and explicit test commands can still run those suites.
|
||||
Both gates use the installed incremental dependency recording and its cold-cache static
|
||||
fallback described below. Deleting a related source runs the full inventory for that
|
||||
gate's scope, because a cold graph cannot reliably resolve a removed import. Neither
|
||||
gate replaces CI's full coverage of global inputs.
|
||||
|
||||
Use a test file or directory when you know the relevant suites. For dependency-based
|
||||
feedback, Vitest can select tests affected by your edits:
|
||||
|
||||
@@ -152,39 +107,9 @@ also includes committed changes since the branch's merge base. Update that ref w
|
||||
you need a newer baseline. Both accept the same path filters and options as `pnpm test`.
|
||||
When Git reports no changed files at all, the launcher exits before starting Vitest.
|
||||
|
||||
Local runs enable Vitest's [dependency recording](https://vitest.dev/config/experimental#experimental-recorddependencies).
|
||||
Ordinary test runs populate the cache; later changed-file runs read it instead of
|
||||
transforming every test's import graph. Records are separate for the Bun and Node
|
||||
projects and live in each checkout's `node_modules/.vite`. A test without a record
|
||||
runs when at most 100 such tests are in scope, which records them. Beyond that, as in
|
||||
a new worktree, unrecorded tests are selected with Vitest's static import graph
|
||||
instead of running all of them. Keep Vite's local test cache between runs. Use
|
||||
`--experimental.recordDependencies=false` to use the static import graph for every test.
|
||||
The cold fallback still scans test import graphs; it limits test execution, not all
|
||||
transformation work.
|
||||
Recorded source closures, configuration, and runtime context are checked for freshness;
|
||||
stale records use the same cold-cache fallback. Failed or partial suites retain the
|
||||
union of known dependencies and run again until they complete successfully. Sources
|
||||
modified during a run cannot receive a fresh record for that run.
|
||||
|
||||
This is local feedback, not a complete regression check. Vitest records loaded
|
||||
imports, including executed dynamic imports, but cannot infer arbitrary filesystem
|
||||
reads, subprocess inputs, or externalized module dependencies. Explicitly run tests
|
||||
for changed fixtures, generated files, CLI entry points, and other indirect inputs.
|
||||
Configuration, lockfile, patch, and workspace-package changes passed directly to
|
||||
Vitest's affected-test command rerun every test within the requested scope. The check
|
||||
gates instead select colocated tests for these global inputs and rely on CI for full
|
||||
coverage. CI does not enable recording;
|
||||
the existing CI selection policy is unchanged. The static graph has the same blind spots
|
||||
and also misses dynamic imports with computed specifiers, so the first changed-file run
|
||||
in a new worktree is a little less complete than later ones.
|
||||
|
||||
`pnpm build:packages` (which `pnpm test` runs first) skips compiling workspace packages
|
||||
when their sources, build script, and compiler are unchanged and `dist` is intact.
|
||||
|
||||
Vitest also supports [`related --run <source-files>`](https://vitest.dev/guide/cli#vitest-related)
|
||||
and dependency-aware watch mode. `pnpm test --changed` uses Git to provide the source
|
||||
inputs automatically and exits after one run.
|
||||
Affected tests use Vitest's static import graph. Run suites explicitly for computed
|
||||
dynamic imports, fixture files, subprocess inputs, or other dependencies outside that
|
||||
graph. Local affected tests do not replace CI.
|
||||
|
||||
To reduce main-process rebuild CPU and memory when you do not need its source maps,
|
||||
set `ORCA_DEV_MAIN_SOURCEMAPS=0` before `pnpm dev`. In Bash/zsh:
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# Useful Commands
|
||||
|
||||
- **While editing**: `pnpm check:iteration --maxWorkers=2` for repeated feedback on uncommitted edits against HEAD (CI unit inventory).
|
||||
- **Before handoff**: `pnpm check:changed --maxWorkers=2` once for the whole branch (format, lint, typecheck, affected tests, ratchets; cached when nothing changed). An iteration pass does not replace this gate.
|
||||
- Stream check output and poll the same running process/session for progress. Redirecting to a file hides progress unless you tail it; keep any outer execution deadline longer than the check's step budgets plus shutdown time.
|
||||
- **Before handoff**: `pnpm check:changed --maxWorkers=2` (format, lint, typecheck, affected tests, ratchets; cached when nothing changed)
|
||||
- **Typecheck**: `pnpm tc` (or `tc:node` / `tc:cli` / `tc:web`)
|
||||
- **Test**: `pnpm test [path/to/file.test.ts]`; affected only: `pnpm test:changed [path] --maxWorkers=2` ([details](.github/CONTRIBUTING.md#targeting-local-tests))
|
||||
- **Lint**: `oxlint`; format: `pnpm format`
|
||||
|
||||
@@ -1,270 +1,7 @@
|
||||
diff --git a/dist/chunks/cli-api.De07GUeT.js b/dist/chunks/cli-api.De07GUeT.js
|
||||
index ff6e63043ff8fa0b821d4b0b382d1fd998d85dbb..3b75a028bee91dd4e354224cec9918829edbc169 100644
|
||||
--- a/dist/chunks/cli-api.De07GUeT.js
|
||||
+++ b/dist/chunks/cli-api.De07GUeT.js
|
||||
@@ -6108,7 +6108,70 @@ async function getFileStamp(path) {
|
||||
}
|
||||
|
||||
const debug = createDebugger("vitest:cache:dependencies");
|
||||
-const DEPENDENCIES_VERSION = 1;
|
||||
+const DEPENDENCIES_VERSION = 2;
|
||||
+const MAX_RECORDED_SOURCE_FILES = 50_000;
|
||||
+const MAX_RECORDED_SOURCE_BYTES = 8 * 1024 * 1024;
|
||||
+class RecordedSourceFreshness {
|
||||
+ files = /* @__PURE__ */ new Map();
|
||||
+ contexts = /* @__PURE__ */ new Map();
|
||||
+ constructor(vitest, startedAt = Infinity) {
|
||||
+ this.vitest = vitest;
|
||||
+ this.startedAt = startedAt;
|
||||
+ }
|
||||
+ source(file) {
|
||||
+ if (this.files.has(file)) return this.files.get(file);
|
||||
+ if (this.files.size >= MAX_RECORDED_SOURCE_FILES) return null;
|
||||
+ let digest = null;
|
||||
+ try {
|
||||
+ const before = statSync(file, { throwIfNoEntry: false });
|
||||
+ if (!before) digest = "missing";
|
||||
+ else if (before.isFile() && before.mtimeMs < this.startedAt && before.ctimeMs < this.startedAt && before.size <= MAX_RECORDED_SOURCE_BYTES) {
|
||||
+ const bytes = readFileSync(file);
|
||||
+ const after = statSync(file);
|
||||
+ if (before.ino === after.ino && before.size === bytes.length && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs) digest = createHash("sha256").update(bytes).digest("hex");
|
||||
+ }
|
||||
+ } catch {}
|
||||
+ this.files.set(file, digest);
|
||||
+ return digest;
|
||||
+ }
|
||||
+ context(project) {
|
||||
+ if (this.contexts.has(project)) return this.contexts.get(project);
|
||||
+ const root = this.vitest.config.root;
|
||||
+ const inputs = [...new Set([
|
||||
+ ...this.vitest.vite.config.configFileDependencies || [],
|
||||
+ ...project.vite.config.configFileDependencies || [],
|
||||
+ ...getEnvFiles(project),
|
||||
+ ...['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'config/.bun-version'].map((file) => resolve(root, file))
|
||||
+ ])].sort();
|
||||
+ const sources = inputs.map((file) => [file, this.source(file)]);
|
||||
+ const context = sources.some(([, digest]) => digest === null) ? null : createHash("sha256").update(JSON.stringify({
|
||||
+ runtime: [process.platform, process.arch, process.version, process.versions.bun, process.env.ORCA_TEST_NODE_EXECUTABLE, process.env.ORCA_TEST_NODE_VERSION],
|
||||
+ project: [project.name, project.config.pool, project.config.environment, project.config.env, project.config.execArgv, project.config.setupFiles, project.config.globalSetup, project.config.experimental.viteModuleRunner],
|
||||
+ mode: project.vite.config.mode,
|
||||
+ define: [project.vite.config.define, project.config.defines, project.config._scriptDefines],
|
||||
+ resolve: { conditions: project.vite.config.resolve.conditions, mainFields: project.vite.config.resolve.mainFields, extensions: project.vite.config.resolve.extensions, preserveSymlinks: project.vite.config.resolve.preserveSymlinks, alias: project.vite.config.resolve.alias.map(({ find, replacement }) => [String(find), replacement]) },
|
||||
+ env: project.vite.config.env,
|
||||
+ moduleCache: [project.config.fsModuleCache, project.config.fsModuleCachePath],
|
||||
+ inventory: process.env.ORCA_UNIT_INVENTORY,
|
||||
+ balanced: process.env.ORCA_BALANCE_UNIT_SHARDS,
|
||||
+ sources
|
||||
+ })).digest("hex");
|
||||
+ this.contexts.set(project, context);
|
||||
+ return context;
|
||||
+ }
|
||||
+ signature(project, files) {
|
||||
+ const context = this.context(project);
|
||||
+ if (context === null) return null;
|
||||
+ const hash = createHash("sha256").update(context);
|
||||
+ for (const file of [...new Set(files)].sort()) {
|
||||
+ const digest = this.source(file);
|
||||
+ if (digest === null || digest === "missing") return null;
|
||||
+ hash.update(JSON.stringify([file, digest]));
|
||||
+ }
|
||||
+ return hash.digest("hex");
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
/**
|
||||
* Stores the modules that every test file loaded during its last run.
|
||||
*/
|
||||
@@ -6118,6 +6181,7 @@ class DependenciesCache {
|
||||
// recorded during the current run, merged into `projects` by `update`
|
||||
recordedFiles = /* @__PURE__ */ new Map();
|
||||
recordedGlobalSetup = /* @__PURE__ */ new Map();
|
||||
+ recordingFreshness;
|
||||
path;
|
||||
fileStamp;
|
||||
constructor(vitest) {
|
||||
@@ -6127,6 +6191,11 @@ class DependenciesCache {
|
||||
get(project) {
|
||||
return this.projects.get(project.name);
|
||||
}
|
||||
+ start(specifications, startTime) {
|
||||
+ if (!this.path) return;
|
||||
+ this.recordingFreshness = new RecordedSourceFreshness(this.vitest, startTime);
|
||||
+ for (const project of new Set(specifications.map((spec) => spec.project))) this.recordingFreshness.context(project);
|
||||
+ }
|
||||
record(project, filepath, dependencies) {
|
||||
if (!this.path) return;
|
||||
let files = this.recordedFiles.get(project.name);
|
||||
@@ -6150,13 +6219,16 @@ class DependenciesCache {
|
||||
const root = this.vitest.config.root;
|
||||
const result = /* @__PURE__ */ new Map();
|
||||
for (const name in projects) {
|
||||
- const { modules, globalSetup, files } = projects[name];
|
||||
+ const { modules, globalSetup, files, signatures, contexts, incomplete } = projects[name];
|
||||
const absoluteModules = modules.map((file) => resolve(root, file));
|
||||
result.set(name, {
|
||||
modules: absoluteModules,
|
||||
moduleIndex: new Map(absoluteModules.map((file, index) => [file, index])),
|
||||
globalSetup,
|
||||
- files: new Map(Object.entries(files))
|
||||
+ files: new Map(Object.entries(files)),
|
||||
+ signatures: new Map(Object.entries(signatures || {})),
|
||||
+ contexts: new Map(Object.entries(contexts || {})),
|
||||
+ incomplete: new Set(incomplete || [])
|
||||
});
|
||||
}
|
||||
this.projects = result;
|
||||
@@ -6167,9 +6239,10 @@ class DependenciesCache {
|
||||
async update(specifications) {
|
||||
if (!this.recordedFiles.size && !this.recordedGlobalSetup.size) return;
|
||||
await this.read();
|
||||
+ const freshness = this.recordingFreshness || new RecordedSourceFreshness(this.vitest, 0);
|
||||
for (const [name, files] of this.recordedGlobalSetup) {
|
||||
const project = this.getProjectDependencies(name);
|
||||
- project.globalSetup = this.intern(project, files);
|
||||
+ project.globalSetup = Array.from(new Set([...project.globalSetup || [], ...this.intern(project, files)]));
|
||||
}
|
||||
for (const specification of specifications) {
|
||||
const files = this.recordedFiles.get(specification.project.name)?.get(specification.moduleId);
|
||||
@@ -6178,10 +6251,20 @@ class DependenciesCache {
|
||||
const project = this.getProjectDependencies(specification.project.name);
|
||||
const file = getRootRelativePath(this.vitest.config.root, specification.moduleId);
|
||||
const previous = project.files.get(file);
|
||||
- // a partial run skips tests, so it can miss their dynamic imports,
|
||||
- // without a previous record the file keeps having none and always runs
|
||||
- if (!isPartialRun(this.vitest, specification, task)) project.files.set(file, this.intern(project, files));
|
||||
- else if (previous) project.files.set(file, Array.from(/* @__PURE__ */ new Set([...previous, ...this.intern(project, files)])));
|
||||
+ const current = this.intern(project, files);
|
||||
+ // Nonpassing or partial suites cannot prove that later dynamic imports disappeared.
|
||||
+ if (task.result?.state !== "pass" || isPartialRun(this.vitest, specification, task)) {
|
||||
+ project.files.set(file, Array.from(new Set([...previous || [], ...current])));
|
||||
+ project.incomplete.add(file);
|
||||
+ project.signatures.delete(file);
|
||||
+ project.contexts.delete(file);
|
||||
+ } else {
|
||||
+ project.files.set(file, current);
|
||||
+ project.incomplete.delete(file);
|
||||
+ const closure = [specification.moduleId, ...files, ...(project.globalSetup || []).map((index) => project.modules[index])];
|
||||
+ project.signatures.set(file, freshness.signature(specification.project, closure));
|
||||
+ project.contexts.set(file, freshness.context(specification.project));
|
||||
+ }
|
||||
}
|
||||
this.recordedFiles.clear();
|
||||
this.recordedGlobalSetup.clear();
|
||||
@@ -6235,7 +6318,10 @@ class DependenciesCache {
|
||||
project = {
|
||||
modules: [],
|
||||
moduleIndex: /* @__PURE__ */ new Map(),
|
||||
- files: /* @__PURE__ */ new Map()
|
||||
+ files: /* @__PURE__ */ new Map(),
|
||||
+ signatures: /* @__PURE__ */ new Map(),
|
||||
+ contexts: /* @__PURE__ */ new Map(),
|
||||
+ incomplete: /* @__PURE__ */ new Set()
|
||||
};
|
||||
this.projects.set(name, project);
|
||||
}
|
||||
@@ -6245,10 +6331,13 @@ class DependenciesCache {
|
||||
if (!this.path) return;
|
||||
const root = this.vitest.config.root;
|
||||
const projects = Object.create(null);
|
||||
- for (const [name, { modules, globalSetup, files }] of this.projects) projects[name] = {
|
||||
+ for (const [name, { modules, globalSetup, files, signatures, contexts, incomplete }] of this.projects) projects[name] = {
|
||||
modules: modules.map((file) => getRootRelativePath(root, file)),
|
||||
globalSetup,
|
||||
- files: Object.fromEntries(files)
|
||||
+ files: Object.fromEntries(files),
|
||||
+ signatures: Object.fromEntries(signatures),
|
||||
+ contexts: Object.fromEntries(contexts),
|
||||
+ incomplete: Array.from(incomplete)
|
||||
};
|
||||
const dependencies = {
|
||||
version: DEPENDENCIES_VERSION,
|
||||
@@ -10250,12 +10339,15 @@ function groupFilters(filters) {
|
||||
* Finds test specifications that loaded any of the changed files during their last recorded run.
|
||||
* A specification without a record is always affected.
|
||||
*/
|
||||
+const UNRECORDED_RUN_LIMIT = 100;
|
||||
class RecordedModulesResolver {
|
||||
vitest;
|
||||
related;
|
||||
+ freshness;
|
||||
constructor(vitest, related) {
|
||||
this.vitest = vitest;
|
||||
this.related = related;
|
||||
+ this.freshness = new RecordedSourceFreshness(vitest);
|
||||
}
|
||||
async resolve(specs) {
|
||||
if (this.dependsOnConfig(this.vitest.vite.config.configFileDependencies)) return specs;
|
||||
@@ -10267,13 +10359,30 @@ class RecordedModulesResolver {
|
||||
projectSpecs.push(spec);
|
||||
}
|
||||
const affected = /* @__PURE__ */ new Set();
|
||||
- for (const [project, projectSpecs] of specsByProject) this.findAffectedInProject(project, projectSpecs).forEach((spec) => affected.add(spec));
|
||||
+ const unrecorded = [];
|
||||
+ for (const [project, projectSpecs] of specsByProject) {
|
||||
+ const result = this.findAffectedInProject(project, projectSpecs);
|
||||
+ result.affected.forEach((spec) => affected.add(spec));
|
||||
+ unrecorded.push(...result.unrecorded);
|
||||
+ }
|
||||
+ // Orca: running a few unrecorded files records them; many (a fresh checkout) use the static graph instead of running everything.
|
||||
+ const unrecordedAffected = unrecorded.length > UNRECORDED_RUN_LIMIT ? await new AffectedModulesResolver(this.vitest, this.related).resolve(unrecorded) : unrecorded;
|
||||
+ unrecordedAffected.forEach((spec) => affected.add(spec));
|
||||
return specs.filter((spec) => affected.has(spec));
|
||||
}
|
||||
findAffectedInProject(project, specs) {
|
||||
- if (this.dependsOnConfig(project.vite.config.configFileDependencies) || this.dependsOnConfig(getEnvFiles(project))) return specs;
|
||||
+ if (this.dependsOnConfig(project.vite.config.configFileDependencies) || this.dependsOnConfig(getEnvFiles(project))) return {
|
||||
+ affected: specs,
|
||||
+ unrecorded: []
|
||||
+ };
|
||||
const dependencies = this.vitest.cache._dependencies.get(project);
|
||||
- if (!dependencies) return specs;
|
||||
+ if (!dependencies) return project.config.globalSetup.length ? {
|
||||
+ affected: specs,
|
||||
+ unrecorded: []
|
||||
+ } : {
|
||||
+ affected: [],
|
||||
+ unrecorded: specs
|
||||
+ };
|
||||
const changed = /* @__PURE__ */ new Set();
|
||||
for (const file of this.related) {
|
||||
const index = dependencies.moduleIndex.get(file);
|
||||
@@ -10281,13 +10390,30 @@ class RecordedModulesResolver {
|
||||
}
|
||||
if (project.config.globalSetup.length) {
|
||||
const globalSetup = dependencies.globalSetup;
|
||||
- if (!globalSetup || globalSetup.some((index) => changed.has(index))) return specs;
|
||||
+ if (!globalSetup || globalSetup.some((index) => changed.has(index))) return {
|
||||
+ affected: specs,
|
||||
+ unrecorded: []
|
||||
+ };
|
||||
}
|
||||
const root = this.vitest.config.root;
|
||||
- return specs.filter((spec) => {
|
||||
- const loaded = dependencies.files.get(getRootRelativePath(root, spec.moduleId));
|
||||
- return !loaded || loaded.some((index) => changed.has(index));
|
||||
- });
|
||||
+ const affected = [];
|
||||
+ const unrecorded = [];
|
||||
+ for (const spec of specs) {
|
||||
+ const file = getRootRelativePath(root, spec.moduleId);
|
||||
+ const loaded = dependencies.files.get(file);
|
||||
+ if (loaded?.some((index) => changed.has(index))) affected.push(spec);
|
||||
+ else if (loaded && (dependencies.incomplete.has(file) || this.freshness.context(project) !== dependencies.contexts.get(file))) affected.push(spec);
|
||||
+ else if (!loaded) unrecorded.push(spec);
|
||||
+ else {
|
||||
+ const closure = [spec.moduleId, ...loaded, ...dependencies.globalSetup || []].map((value) => typeof value === "number" ? dependencies.modules[value] : value);
|
||||
+ const signature = this.freshness.signature(project, closure);
|
||||
+ if (signature === null || signature !== dependencies.signatures.get(file)) unrecorded.push(spec);
|
||||
+ }
|
||||
+ }
|
||||
+ return {
|
||||
+ affected,
|
||||
+ unrecorded
|
||||
+ };
|
||||
}
|
||||
dependsOnConfig(configDependencies) {
|
||||
return !!configDependencies?.some((file) => this.related.includes(file));
|
||||
@@ -11817,7 +11943,8 @@ class TestRun {
|
||||
@@ -11817,7 +11817,8 @@ class TestRun {
|
||||
else error.stacks = parseErrorStacktrace(error, {
|
||||
frameFilter: project.config.onStackTrace,
|
||||
getSourceMap(file) {
|
||||
@@ -274,11 +11,3 @@ index ff6e63043ff8fa0b821d4b0b382d1fd998d85dbb..3b75a028bee91dd4e354224cec991882
|
||||
const code = readFileSync(file, "utf-8");
|
||||
return extractSourcemapFromFile(code, file)?.map;
|
||||
}
|
||||
@@ -12860,6 +12987,7 @@ class Vitest {
|
||||
this.runningPromise = (async () => {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
+ this.cache._dependencies.start(specs, startTime);
|
||||
if (!this.pool) this.pool = createPool(this);
|
||||
const invalidates = Array.from(this.watcher.invalidates);
|
||||
this.watcher.invalidates.clear();
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { closeSync, constants, fstatSync, openSync, readSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
export const DEPENDENCY_PATH_PREFIX = 'check:changed dependency path: '
|
||||
const MAX_EVIDENCE_BYTES = 32 * 1024 * 1024
|
||||
|
||||
const digest = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
||||
const dictionary = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
function projectEvidence(name, project, incomplete) {
|
||||
if (
|
||||
!dictionary(project) ||
|
||||
!Array.isArray(project.modules) ||
|
||||
!project.modules.every((file) => typeof file === 'string') ||
|
||||
!dictionary(project.files) ||
|
||||
!dictionary(project.signatures) ||
|
||||
!dictionary(project.contexts) ||
|
||||
!Array.isArray(project.incomplete) ||
|
||||
!project.incomplete.every(
|
||||
(file) => typeof file === 'string' && Object.hasOwn(project.files, file)
|
||||
)
|
||||
) {
|
||||
throw new Error('Invalid dependency project')
|
||||
}
|
||||
const modules = (indices) => {
|
||||
if (
|
||||
!Array.isArray(indices) ||
|
||||
!indices.every(
|
||||
(index) => Number.isInteger(index) && index >= 0 && index < project.modules.length
|
||||
)
|
||||
) {
|
||||
throw new Error('Invalid dependency indices')
|
||||
}
|
||||
return [...new Set(indices.map((index) => project.modules[index]))].sort()
|
||||
}
|
||||
const globalSetup = project.globalSetup === undefined ? null : modules(project.globalSetup)
|
||||
const partial = new Set(project.incomplete)
|
||||
const files = Object.keys(project.files)
|
||||
.sort()
|
||||
.map((file) => {
|
||||
const signature = project.signatures[file] ?? null
|
||||
const context = project.contexts[file] ?? null
|
||||
if ([signature, context].some((value) => value !== null && typeof value !== 'string')) {
|
||||
throw new Error('Invalid dependency signature')
|
||||
}
|
||||
const record = [file, modules(project.files[file]), signature, context, partial.has(file)]
|
||||
if (partial.has(file)) {
|
||||
incomplete.push([JSON.stringify([name, file]), digest([globalSetup, record])])
|
||||
}
|
||||
return record
|
||||
})
|
||||
return [name, globalSetup, files]
|
||||
}
|
||||
|
||||
// Attest selection evidence, not module numbering, unused interned modules, or file timestamps.
|
||||
export function readDependencyEvidence(file) {
|
||||
if (file === null) {
|
||||
return { path: null, hash: 'disabled', incomplete: [] }
|
||||
}
|
||||
if (typeof file !== 'string' || !path.isAbsolute(file)) {
|
||||
return null
|
||||
}
|
||||
let descriptor
|
||||
try {
|
||||
// A FIFO must not block before fstat can reject it.
|
||||
descriptor = openSync(
|
||||
file,
|
||||
constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NONBLOCK)
|
||||
)
|
||||
const before = fstatSync(descriptor)
|
||||
if (!before.isFile() || before.size > MAX_EVIDENCE_BYTES) {
|
||||
return null
|
||||
}
|
||||
const bytes = Buffer.alloc(before.size)
|
||||
let offset = 0
|
||||
while (offset < bytes.length) {
|
||||
const count = readSync(descriptor, bytes, offset, bytes.length - offset, offset)
|
||||
if (!count) {
|
||||
return null
|
||||
}
|
||||
offset += count
|
||||
}
|
||||
const after = fstatSync(descriptor)
|
||||
if (
|
||||
before.size !== after.size ||
|
||||
before.mtimeMs !== after.mtimeMs ||
|
||||
before.ctimeMs !== after.ctimeMs
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const cache = JSON.parse(bytes.toString('utf8'))
|
||||
if (cache.version !== 2 || !dictionary(cache.projects)) {
|
||||
return null
|
||||
}
|
||||
const incomplete = []
|
||||
const projects = Object.keys(cache.projects)
|
||||
.sort()
|
||||
.map((name) => projectEvidence(name, cache.projects[name], incomplete))
|
||||
return { path: file, hash: digest(projects), incomplete }
|
||||
} catch (error) {
|
||||
return descriptor === undefined && error.code === 'ENOENT'
|
||||
? { path: file, hash: digest([]), incomplete: [] }
|
||||
: null
|
||||
} finally {
|
||||
if (descriptor !== undefined) {
|
||||
closeSync(descriptor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function hasNewIncompleteEvidence(before, after) {
|
||||
const previous = new Map(before?.incomplete ?? [])
|
||||
return Boolean(after?.incomplete.some(([file, hash]) => previous.get(file) !== hash))
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const require = createRequire(path.join(process.cwd(), 'package.json'))
|
||||
const { resolveConfig } = await import(pathToFileURL(require.resolve('vitest/node')).href)
|
||||
const config = await resolveConfig({ config: 'config/vitest.config.ts', watch: false })
|
||||
const file =
|
||||
config.test.cache && config.test.experimental.recordDependencies
|
||||
? path.join(config.cacheDir, 'dependencies.json')
|
||||
: null
|
||||
console.log(`${DEPENDENCY_PATH_PREFIX}${JSON.stringify(file)}`)
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { runProcessSync, spawnProcess } from '@orca/process-host'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
const node = process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath
|
||||
const temporary = []
|
||||
const children = []
|
||||
const pids = new Set()
|
||||
|
||||
function directory() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'check-changed-lifecycle-'))
|
||||
temporary.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function alive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return (
|
||||
process.platform !== 'linux' ||
|
||||
!readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1].startsWith('Z')
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function launch(
|
||||
script,
|
||||
cwd,
|
||||
args = [],
|
||||
{ readStdout = true, detached = false, nodeArgs = [], ipc = false } = {}
|
||||
) {
|
||||
const child = spawnProcess({
|
||||
program: node,
|
||||
args: [...nodeArgs, script, ...args],
|
||||
cwd,
|
||||
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
|
||||
detached,
|
||||
stdio: ['ignore', 'pipe', 'pipe', ...(ipc ? ['ipc'] : [])]
|
||||
})
|
||||
children.push(child)
|
||||
if (child.pid) {
|
||||
pids.add(child.pid)
|
||||
}
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
if (readStdout) {
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk
|
||||
})
|
||||
}
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk
|
||||
})
|
||||
const exited = new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error(`Outer command did not exit: ${stdout}\n${stderr}`)),
|
||||
12_000
|
||||
)
|
||||
child.once('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
child.once('exit', (code, signal) => {
|
||||
clearTimeout(timer)
|
||||
resolve({ code, signal })
|
||||
})
|
||||
})
|
||||
return { child, exited, stdout: () => stdout, stderr: () => stderr }
|
||||
}
|
||||
|
||||
function runner(root, body) {
|
||||
const script = join(root, 'runner.mjs')
|
||||
writeFileSync(
|
||||
script,
|
||||
`
|
||||
import { createRequire } from 'node:module'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
const require = createRequire(${JSON.stringify(resolve('src/packages/process-host/package.json'))})
|
||||
const { tsImport } = await import(pathToFileURL(require.resolve('tsx/esm/api')).href)
|
||||
const { runProcess } = await tsImport(${JSON.stringify(resolve('src/packages/process-host/src/run-process.ts'))}, import.meta.url)
|
||||
const { createTaskOutput, runTask } = await import(${JSON.stringify(pathToFileURL(resolve('config/scripts/check-changed-task-runner.mjs')).href)})
|
||||
${body}
|
||||
`
|
||||
)
|
||||
return script
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const child of children.splice(0)) {
|
||||
child.stdout?.resume()
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
} catch {}
|
||||
}
|
||||
await vi.waitFor(() => expect([...pids].filter(alive)).toEqual([]), { timeout: 5000 })
|
||||
pids.clear()
|
||||
temporary.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))
|
||||
})
|
||||
|
||||
it('exits after timeout when an escaped descendant holds stderr after root exit', async () => {
|
||||
const root = directory()
|
||||
const marker = join(root, 'pids.json')
|
||||
const leaf = `process.on('SIGTERM', () => {}); process.send('ready'); setInterval(() => {}, 1000)`
|
||||
const wrapper = join(root, 'wrapper.cjs')
|
||||
writeFileSync(
|
||||
wrapper,
|
||||
`
|
||||
const child = require('node:child_process').spawn(process.execPath, ['-e', ${JSON.stringify(leaf)}], {
|
||||
detached: true, stdio: ['ignore', 'ignore', 'inherit', 'ipc'], windowsHide: true
|
||||
})
|
||||
require('node:fs').writeFileSync(${JSON.stringify(marker)}, JSON.stringify([process.pid, child.pid]))
|
||||
child.once('message', () => process.exit(0))
|
||||
`
|
||||
)
|
||||
const report = join(root, 'result.json')
|
||||
const script = runner(
|
||||
root,
|
||||
`
|
||||
const result = await runTask({ name: 'held pipe', steps: [{ program: process.execPath, args: [${JSON.stringify(wrapper)}] }] },
|
||||
{ root: ${JSON.stringify(root)}, env: process.env, runProcess, stepTimeoutMs: 1000 })
|
||||
writeFileSync(${JSON.stringify(report)}, JSON.stringify(result))
|
||||
process.exitCode = result.ok ? 0 : 1
|
||||
`
|
||||
)
|
||||
const run = launch(script, root)
|
||||
await vi.waitFor(() => {
|
||||
const recorded = JSON.parse(readFileSync(marker, 'utf8'))
|
||||
recorded.forEach((pid) => pids.add(pid))
|
||||
})
|
||||
expect(await run.exited).toEqual({ code: 1, signal: null })
|
||||
const [wrapperPid, leafPid] = JSON.parse(readFileSync(marker, 'utf8'))
|
||||
expect(alive(wrapperPid)).toBe(false)
|
||||
expect(alive(leafPid)).toBe(true)
|
||||
const result = JSON.parse(readFileSync(report, 'utf8'))
|
||||
expect(result.output).toContain('Timed out after')
|
||||
expect(result.output).toContain('Cleanup unverified')
|
||||
}, 15_000)
|
||||
|
||||
it('streams real stdout and stderr while retaining their bounded failure tails', async () => {
|
||||
const root = directory()
|
||||
const fixture = join(root, 'failure.cjs')
|
||||
const report = join(root, 'result.json')
|
||||
writeFileSync(
|
||||
fixture,
|
||||
`console.log('old\\n'.repeat(1000) + 'LAST_STDOUT_DIAGNOSTIC'); console.error('LAST_STDERR_DIAGNOSTIC'); process.exitCode = 2`
|
||||
)
|
||||
const script = runner(
|
||||
root,
|
||||
`
|
||||
const result = await runTask({ name: 'diagnostics', steps: [{ program: process.execPath, args: [${JSON.stringify(fixture)}], liveOutput: true }] },
|
||||
{ root: ${JSON.stringify(root)}, env: process.env, runProcess, stepTimeoutMs: 1000 })
|
||||
writeFileSync(${JSON.stringify(report)}, JSON.stringify(result))
|
||||
process.exitCode = result.ok ? 0 : 1
|
||||
`
|
||||
)
|
||||
const run = launch(script, root)
|
||||
expect(await run.exited).toEqual({ code: 1, signal: null })
|
||||
expect(run.stdout()).toContain('LAST_STDOUT_DIAGNOSTIC')
|
||||
expect(run.stderr()).toContain('LAST_STDERR_DIAGNOSTIC')
|
||||
const result = JSON.parse(readFileSync(report, 'utf8'))
|
||||
expect(result.output).toContain('LAST_STDOUT_DIAGNOSTIC')
|
||||
expect(result.output).toContain('LAST_STDERR_DIAGNOSTIC')
|
||||
expect(result.output.split('\n').length).toBeLessThan(210)
|
||||
})
|
||||
|
||||
it('cancels all active checks when a live output consumer closes', async () => {
|
||||
const root = directory()
|
||||
const marker = join(root, 'pids.json')
|
||||
const report = join(root, 'result.json')
|
||||
const script = runner(
|
||||
root,
|
||||
`
|
||||
const abort = new AbortController()
|
||||
const output = createTaskOutput(() => abort.abort())
|
||||
const recorded = []
|
||||
const launch = (spec, capture) => runProcess({ ...spec, onStderr: chunk => {
|
||||
recorded.push(Number(chunk.toString().trim()))
|
||||
writeFileSync(${JSON.stringify(marker)}, JSON.stringify(recorded))
|
||||
if (recorded.length === 2) output.stderr('BOTH_READY\\n')
|
||||
spec.onStderr?.(chunk)
|
||||
} }, capture)
|
||||
const tasks = [0, 1].map(index => ({ name: 'check ' + index, steps: [{ program: process.execPath,
|
||||
args: ['-e', 'process.stderr.write(String(process.pid)); setInterval(() => {}, 1000)'], liveOutput: true }] }))
|
||||
const pending = tasks.map(task => runTask(task, { root: ${JSON.stringify(root)}, env: process.env, runProcess: launch,
|
||||
stepTimeoutMs: 10000, signal: abort.signal, output }))
|
||||
const timer = setInterval(() => { if (recorded.length === 2) output.stderr('after consumer closed\\n') }, 100)
|
||||
try { writeFileSync(${JSON.stringify(report)}, JSON.stringify(await Promise.all(pending))) }
|
||||
finally { clearInterval(timer); await output.dispose() }
|
||||
process.exitCode = 1
|
||||
`
|
||||
)
|
||||
const run = launch(script, root)
|
||||
await vi.waitFor(() => expect(run.stderr()).toContain('BOTH_READY'))
|
||||
const recorded = JSON.parse(readFileSync(marker, 'utf8'))
|
||||
recorded.forEach((pid) => pids.add(pid))
|
||||
run.child.stderr.destroy()
|
||||
expect(await run.exited).toEqual({ code: 1, signal: null })
|
||||
expect(recorded).toHaveLength(2)
|
||||
expect(recorded.map(alive)).toEqual([false, false])
|
||||
const results = JSON.parse(readFileSync(report, 'utf8'))
|
||||
expect(
|
||||
results.every((result) => !result.ok && result.output.includes('Output consumer failed'))
|
||||
).toBe(true)
|
||||
}, 15_000)
|
||||
|
||||
function gateFixture({ build = '', formatter = '' } = {}) {
|
||||
const root = directory()
|
||||
const git = (...args) => {
|
||||
const result = runProcessSync({ program: 'git', args, cwd: root })
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
}
|
||||
git('init', '-q')
|
||||
git('config', 'user.email', 'test@example.com')
|
||||
git('config', 'user.name', 'Test')
|
||||
writeFileSync(join(root, '.gitignore'), 'config\nnode_modules\n.cache\n*.json\n*.mjs\n')
|
||||
writeFileSync(join(root, 'note.md'), '# Before\n')
|
||||
git('add', '.')
|
||||
git('commit', '-qm', 'base')
|
||||
const scripts = join(root, 'config/scripts')
|
||||
mkdirSync(scripts, { recursive: true })
|
||||
for (const name of [
|
||||
'build-workspace-packages',
|
||||
'run-vitest',
|
||||
'ensure-native-runtime',
|
||||
'check-changed-code-quality'
|
||||
]) {
|
||||
writeFileSync(join(scripts, `${name}.mjs`), name === 'build-workspace-packages' ? build : '')
|
||||
}
|
||||
for (const name of ['oxfmt', 'oxlint']) {
|
||||
const packageDir = join(root, 'node_modules', name)
|
||||
mkdirSync(packageDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(packageDir, 'package.json'),
|
||||
JSON.stringify({ name, bin: { [name]: 'cli.cjs' } })
|
||||
)
|
||||
writeFileSync(join(packageDir, 'cli.cjs'), name === 'oxfmt' ? formatter : '')
|
||||
}
|
||||
writeFileSync(join(root, 'note.md'), '# After\n')
|
||||
return root
|
||||
}
|
||||
|
||||
it('bounds forwarding to a nonreading consumer and the actual gate still exits', async () => {
|
||||
const root = gateFixture({
|
||||
build: `for (let i = 0; i < 320; i++) process.stdout.write('x'.repeat(65536)); setTimeout(() => {}, 50)`
|
||||
})
|
||||
writeFileSync(join(root, 'input.ts'), 'export const value = 1\n')
|
||||
const report = join(root, 'peak.json')
|
||||
const preload = join(root, 'monitor.mjs')
|
||||
writeFileSync(
|
||||
preload,
|
||||
`
|
||||
import { writeFileSync } from 'node:fs'
|
||||
let peak = 0
|
||||
setInterval(() => { peak = Math.max(peak, process.stdout.writableLength) }, 1).unref()
|
||||
process.on('exit', () => writeFileSync(${JSON.stringify(report)}, JSON.stringify({ peak })))
|
||||
`
|
||||
)
|
||||
const run = launch(
|
||||
resolve('config/scripts/check-changed.mjs'),
|
||||
root,
|
||||
['--scope=worktree', '--maxWorkers=2', '--stepTimeoutMs=10000'],
|
||||
{ readStdout: false, nodeArgs: ['--import', pathToFileURL(preload).href] }
|
||||
)
|
||||
expect(await run.exited).toEqual({ code: 1, signal: null })
|
||||
const { peak } = JSON.parse(readFileSync(report, 'utf8'))
|
||||
expect(peak).toBeGreaterThan(0)
|
||||
expect(peak).toBeLessThanOrEqual(2 * 65536)
|
||||
}, 15_000)
|
||||
|
||||
it('reports omitted live output when the consumer resumes before the final flush', async () => {
|
||||
const root = directory()
|
||||
const script = runner(
|
||||
root,
|
||||
`
|
||||
const output = createTaskOutput(() => {})
|
||||
for (let index = 0; index < 320; index++) output.stdout('x'.repeat(65536))
|
||||
process.stderr.write('RESUME_READER\\n')
|
||||
await new Promise(resolve => process.once('message', resolve))
|
||||
process.disconnect()
|
||||
await output.dispose()
|
||||
`
|
||||
)
|
||||
const run = launch(script, root, [], { readStdout: false, ipc: true })
|
||||
await vi.waitFor(() => expect(run.stderr()).toContain('RESUME_READER'))
|
||||
let stdout = ''
|
||||
run.child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk
|
||||
})
|
||||
run.child.send('reading')
|
||||
expect(await run.exited).toEqual({ code: 0, signal: null })
|
||||
expect(stdout).toContain('live output omitted for a slow consumer')
|
||||
})
|
||||
|
||||
it('does not cache a pass when the final summary output fails', async () => {
|
||||
const root = gateFixture()
|
||||
const preload = join(root, 'close-summary.mjs')
|
||||
writeFileSync(
|
||||
preload,
|
||||
`
|
||||
import { closeSync } from 'node:fs'
|
||||
const write = process.stdout.write.bind(process.stdout)
|
||||
process.stdout.write = (chunk, ...args) => {
|
||||
if (String(chunk).includes('check(s) passed in')) closeSync(1)
|
||||
return write(chunk, ...args)
|
||||
}
|
||||
`
|
||||
)
|
||||
const run = launch(
|
||||
resolve('config/scripts/check-changed.mjs'),
|
||||
root,
|
||||
['--scope=worktree', '--maxWorkers=2'],
|
||||
{ nodeArgs: ['--import', pathToFileURL(preload).href] }
|
||||
)
|
||||
expect(await run.exited).toEqual({ code: 1, signal: null })
|
||||
expect(() => readFileSync(join(root, '.cache/check-changed.json'), 'utf8')).toThrow()
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'cancels detached checks on terminal-group SIGHUP',
|
||||
async () => {
|
||||
const root = directory()
|
||||
const git = (...args) => {
|
||||
const result = runProcessSync({ program: 'git', args, cwd: root })
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
}
|
||||
git('init', '-q')
|
||||
git('config', 'user.email', 'test@example.com')
|
||||
git('config', 'user.name', 'Test')
|
||||
writeFileSync(join(root, '.gitignore'), 'config\nnode_modules\n.cache\nformatter-pid\n')
|
||||
writeFileSync(join(root, 'note.md'), '# Before\n')
|
||||
git('add', '.')
|
||||
git('commit', '-qm', 'base')
|
||||
symlinkSync(resolve('config'), join(root, 'config'), 'dir')
|
||||
for (const name of ['oxfmt', 'oxlint']) {
|
||||
const packageDir = join(root, 'node_modules', name)
|
||||
mkdirSync(packageDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(packageDir, 'package.json'),
|
||||
JSON.stringify({ name, bin: { [name]: 'cli.cjs' } })
|
||||
)
|
||||
writeFileSync(
|
||||
join(packageDir, 'cli.cjs'),
|
||||
name === 'oxfmt'
|
||||
? `require('node:fs').writeFileSync('formatter-pid', String(process.pid)); setInterval(() => {}, 1000)`
|
||||
: ''
|
||||
)
|
||||
}
|
||||
writeFileSync(join(root, 'note.md'), '# After\n')
|
||||
const run = launch(
|
||||
resolve('config/scripts/check-changed.mjs'),
|
||||
root,
|
||||
['--scope=worktree', '--maxWorkers=2', '--stepTimeoutMs=10000'],
|
||||
{ detached: true }
|
||||
)
|
||||
let formatterPid
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
formatterPid = Number(readFileSync(join(root, 'formatter-pid'), 'utf8'))
|
||||
pids.add(formatterPid)
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
)
|
||||
process.kill(-run.child.pid, 'SIGHUP')
|
||||
expect(await run.exited).toEqual({ code: 1, signal: null })
|
||||
expect(alive(formatterPid)).toBe(false)
|
||||
expect(run.stderr()).toContain('Cancelled.')
|
||||
},
|
||||
15_000
|
||||
)
|
||||
@@ -1,13 +1,10 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import darkBuildSwitches from '../dark-build-switches.cjs'
|
||||
import { readDependencyEvidence } from './check-changed-dependency-evidence.mjs'
|
||||
|
||||
export const PASS_CACHE_PATH = '.cache/check-changed.json'
|
||||
const CACHE_VERSION = 4
|
||||
const CACHE_VERSION = 1
|
||||
|
||||
// Inputs outside the diff that still decide what the checks report.
|
||||
const HASHED_CONFIG_FILES = [
|
||||
@@ -16,24 +13,11 @@ const HASHED_CONFIG_FILES = [
|
||||
'.oxlintrc.json',
|
||||
'.oxfmtrc.json',
|
||||
'config/vitest.config.ts',
|
||||
'config/.bun-version',
|
||||
'config/dark-build-switches.cjs',
|
||||
'config/scripts/check-changed.mjs',
|
||||
'config/scripts/check-changed-selection.mjs',
|
||||
'config/scripts/check-changed-pass-cache.mjs',
|
||||
'config/scripts/check-changed-dependency-evidence.mjs',
|
||||
'config/scripts/check-changed-task-runner.mjs'
|
||||
'config/scripts/check-changed-pass-cache.mjs'
|
||||
]
|
||||
|
||||
export function changedCheckEnvironment(scope, env = process.env) {
|
||||
return {
|
||||
...env,
|
||||
ORCA_BACKGROUND_LAUNCH: '1',
|
||||
ORCA_UNIT_INVENTORY: scope === 'worktree' ? 'ci' : 'local',
|
||||
ORCA_BALANCE_UNIT_SHARDS: '0'
|
||||
}
|
||||
}
|
||||
|
||||
function git(root, args) {
|
||||
return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
|
||||
}
|
||||
@@ -61,7 +45,7 @@ export function collectChangedFiles(root, mergeBase) {
|
||||
return [...new Set([...tracked, ...untracked])].sort()
|
||||
}
|
||||
|
||||
export function computePassHash(root, mergeBase, scope = 'branch', env = process.env) {
|
||||
export function computePassHash(root, mergeBase) {
|
||||
const hash = createHash('sha256')
|
||||
const field = (label, value) => {
|
||||
hash.update(`${label}\0${value.length}\0`)
|
||||
@@ -70,24 +54,6 @@ export function computePassHash(root, mergeBase, scope = 'branch', env = process
|
||||
field('version', String(CACHE_VERSION))
|
||||
field('runtime', `${process.platform} ${process.arch} ${process.version}`)
|
||||
field('base', mergeBase)
|
||||
field('scope', scope)
|
||||
const context = changedCheckEnvironment(scope, env)
|
||||
field('inventory', context.ORCA_UNIT_INVENTORY)
|
||||
field('ci', String(Boolean(context.CI)))
|
||||
field('defines', JSON.stringify(darkBuildSwitches.darkBuildDefines(context)))
|
||||
for (const name of ['ORCA_UNIT_MODULE_CACHE_PATH', 'ORCA_TEST_NODE_VERSION']) {
|
||||
field(name, context[name] ?? '')
|
||||
}
|
||||
try {
|
||||
const requireFromRoot = createRequire(path.join(root, 'package.json'))
|
||||
field('bun', readIfFile(requireFromRoot.resolve('bun/package.json')) ?? '')
|
||||
} catch {
|
||||
field('bun', '')
|
||||
}
|
||||
if (scope === 'worktree') {
|
||||
// A committed dependency can affect an unchanged local edit.
|
||||
field('head', git(root, ['rev-parse', 'HEAD']).trim())
|
||||
}
|
||||
// Base plus each changed path's working-tree bytes pins the tree like the diff does, but stays
|
||||
// stable when an edit is staged or committed, so committing a passing change keeps its pass.
|
||||
for (const file of collectChangedFiles(root, mergeBase)) {
|
||||
@@ -101,68 +67,20 @@ export function computePassHash(root, mergeBase, scope = 'branch', env = process
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
export function readPassHash(root, scope = 'branch') {
|
||||
export function readPassHash(root) {
|
||||
try {
|
||||
const { version, passes } = JSON.parse(readFileSync(path.join(root, PASS_CACHE_PATH), 'utf8'))
|
||||
const evidence = passes?.[scope]?.dependencyEvidence
|
||||
const current = readDependencyEvidence(evidence?.path)
|
||||
return version === CACHE_VERSION &&
|
||||
typeof passes?.[scope]?.hash === 'string' &&
|
||||
current &&
|
||||
current.hash === evidence?.hash
|
||||
? passes[scope].hash
|
||||
: null
|
||||
const { version, hash } = JSON.parse(readFileSync(path.join(root, PASS_CACHE_PATH), 'utf8'))
|
||||
return version === CACHE_VERSION && typeof hash === 'string' ? hash : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readPasses(root) {
|
||||
try {
|
||||
const cached = JSON.parse(readFileSync(path.join(root, PASS_CACHE_PATH), 'utf8'))
|
||||
if (
|
||||
cached.version === CACHE_VERSION &&
|
||||
cached.passes &&
|
||||
typeof cached.passes === 'object' &&
|
||||
!Array.isArray(cached.passes)
|
||||
) {
|
||||
return cached.passes
|
||||
}
|
||||
} catch {
|
||||
// Missing or invalid caches require a fresh pass.
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export function invalidatePassHash(root, scope = 'branch', hash) {
|
||||
const passes = readPasses(root)
|
||||
if (Object.hasOwn(passes, scope) && (hash === undefined || passes[scope]?.hash === hash)) {
|
||||
delete passes[scope]
|
||||
savePasses(root, passes)
|
||||
}
|
||||
}
|
||||
|
||||
export function writePassHash(
|
||||
root,
|
||||
hash,
|
||||
scope = 'branch',
|
||||
evidence = readDependencyEvidence(null)
|
||||
) {
|
||||
if (!evidence || readDependencyEvidence(evidence.path)?.hash !== evidence.hash) {
|
||||
return false
|
||||
}
|
||||
const passes = readPasses(root)
|
||||
passes[scope] = {
|
||||
hash,
|
||||
dependencyEvidence: { path: evidence.path, hash: evidence.hash },
|
||||
passedAt: new Date().toISOString()
|
||||
}
|
||||
savePasses(root, passes)
|
||||
return true
|
||||
}
|
||||
|
||||
function savePasses(root, passes) {
|
||||
export function writePassHash(root, hash) {
|
||||
const file = path.join(root, PASS_CACHE_PATH)
|
||||
mkdirSync(path.dirname(file), { recursive: true })
|
||||
writeFileSync(file, `${JSON.stringify({ version: CACHE_VERSION, passes })}\n`)
|
||||
writeFileSync(
|
||||
file,
|
||||
`${JSON.stringify({ version: CACHE_VERSION, hash, passedAt: new Date().toISOString() })}\n`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { runProcessSync } from '@orca/process-host'
|
||||
@@ -6,15 +6,9 @@ import { afterEach, beforeEach, expect, it } from 'vitest'
|
||||
import {
|
||||
collectChangedFiles,
|
||||
computePassHash,
|
||||
changedCheckEnvironment,
|
||||
invalidatePassHash,
|
||||
readPassHash,
|
||||
writePassHash
|
||||
} from './check-changed-pass-cache.mjs'
|
||||
import {
|
||||
hasNewIncompleteEvidence,
|
||||
readDependencyEvidence
|
||||
} from './check-changed-dependency-evidence.mjs'
|
||||
|
||||
let root
|
||||
let base
|
||||
@@ -74,188 +68,3 @@ it('keys the pass on every edit state and ignores its own cache file', () => {
|
||||
writeFileSync(join(root, 'pnpm-lock.yaml'), 'lockfileVersion: 10\n')
|
||||
expect(computePassHash(root, base)).not.toBe(beforeLockfile)
|
||||
})
|
||||
|
||||
it('limits worktree selection to edits after HEAD, including staged additions and deletions', () => {
|
||||
writeFileSync(join(root, 'committed.ts'), 'export const committed = 1\n')
|
||||
git('add', '.')
|
||||
git('commit', '-qm', 'branch change')
|
||||
expect(collectChangedFiles(root, base)).toEqual(['committed.ts'])
|
||||
expect(collectChangedFiles(root, 'HEAD')).toEqual([])
|
||||
|
||||
rmSync(join(root, 'tracked.ts'))
|
||||
writeFileSync(join(root, 'staged.ts'), 'export const staged = 1\n')
|
||||
git('add', '.')
|
||||
writeFileSync(join(root, 'untracked.ts'), 'export const untracked = 1\n')
|
||||
expect(collectChangedFiles(root, 'HEAD')).toEqual(['staged.ts', 'tracked.ts', 'untracked.ts'])
|
||||
})
|
||||
|
||||
it('keeps scope passes separate and invalidates iteration passes after a commit', () => {
|
||||
writeFileSync(join(root, 'tracked.ts'), 'export const a = 2\n')
|
||||
writeFileSync(join(root, 'committed.ts'), 'export const committed = 1\n')
|
||||
const branchHash = computePassHash(root, base)
|
||||
const iterationHash = computePassHash(root, base, 'worktree')
|
||||
expect(branchHash).not.toBe(iterationHash)
|
||||
writePassHash(root, iterationHash, 'worktree')
|
||||
expect(readPassHash(root)).toBeNull()
|
||||
writePassHash(root, branchHash)
|
||||
expect(readPassHash(root, 'worktree')).toBe(iterationHash)
|
||||
expect(readPassHash(root)).toBe(branchHash)
|
||||
|
||||
const selected = collectChangedFiles(root, base)
|
||||
git('add', 'committed.ts')
|
||||
git('commit', '-qm', 'new dependency')
|
||||
expect(collectChangedFiles(root, base)).toEqual(selected)
|
||||
expect(computePassHash(root, base)).toBe(branchHash)
|
||||
expect(computePassHash(root, base, 'worktree')).not.toBe(iterationHash)
|
||||
expect(computePassHash(root, git('rev-parse', 'HEAD'), 'worktree')).not.toBe(iterationHash)
|
||||
})
|
||||
|
||||
it('keys effective CI and build context while fixing inventory by scope', () => {
|
||||
const local = { CI: '', ORCA_BALANCE_UNIT_SHARDS: '0', ORCA_UNIT_INVENTORY: 'local' }
|
||||
const inherited = { ...local, ORCA_BALANCE_UNIT_SHARDS: '1', ORCA_UNIT_INVENTORY: 'ci' }
|
||||
expect(changedCheckEnvironment('branch', inherited)).toMatchObject({
|
||||
ORCA_BALANCE_UNIT_SHARDS: '0',
|
||||
ORCA_UNIT_INVENTORY: 'local'
|
||||
})
|
||||
expect(changedCheckEnvironment('worktree', local).ORCA_UNIT_INVENTORY).toBe('ci')
|
||||
const hash = computePassHash(root, base, 'branch', local)
|
||||
expect(computePassHash(root, base, 'branch', inherited)).toBe(hash)
|
||||
for (const context of [
|
||||
{ CI: '1' },
|
||||
{ ORCA_LAYOUT_OWNER: 'main' },
|
||||
{ ORCA_SIDEBAR_INPUTS: 'main' },
|
||||
{ ORCA_UNIT_MODULE_CACHE_PATH: '/other/cache' }
|
||||
]) {
|
||||
expect(computePassHash(root, base, 'branch', { ...local, ...context })).not.toBe(hash)
|
||||
}
|
||||
})
|
||||
|
||||
it('invalidates only the scope that is being rechecked', () => {
|
||||
writePassHash(root, 'branch pass')
|
||||
writePassHash(root, 'iteration pass', 'worktree')
|
||||
invalidatePassHash(root, 'branch', 'older pass')
|
||||
expect(readPassHash(root)).toBe('branch pass')
|
||||
invalidatePassHash(root)
|
||||
expect(readPassHash(root)).toBeNull()
|
||||
expect(readPassHash(root, 'worktree')).toBe('iteration pass')
|
||||
})
|
||||
|
||||
it('selects materialized bytes when staged and unstaged changes cancel', () => {
|
||||
const original = 'export const a = 1\n'
|
||||
writeFileSync(join(root, 'tracked.ts'), 'export const a = 2\n')
|
||||
git('add', 'tracked.ts')
|
||||
writeFileSync(join(root, 'tracked.ts'), original)
|
||||
expect(git('status', '--short')).toContain('MM tracked.ts')
|
||||
expect(collectChangedFiles(root, base)).toEqual([])
|
||||
})
|
||||
|
||||
function dependencyRecord(overrides = {}) {
|
||||
return {
|
||||
version: 2,
|
||||
projects: {
|
||||
unit: {
|
||||
modules: ['tracked.ts', 'consumer.test.ts'],
|
||||
files: { 'consumer.test.ts': [0, 1] },
|
||||
signatures: { 'consumer.test.ts': 'source signature' },
|
||||
contexts: { 'consumer.test.ts': 'context signature' },
|
||||
incomplete: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('attests semantic dependency evidence and preserves the other scope when records strengthen', () => {
|
||||
const file = join(root, '.cache/dependencies.json')
|
||||
mkdirSync(join(root, '.cache'))
|
||||
const write = (record) => writeFileSync(file, JSON.stringify(record))
|
||||
write(dependencyRecord())
|
||||
const evidence = readDependencyEvidence(file)
|
||||
expect(writePassHash(root, 'tested sources', 'worktree', evidence)).toBe(true)
|
||||
writePassHash(root, 'docs only branch')
|
||||
write(
|
||||
dependencyRecord({
|
||||
modules: ['unused.ts', 'consumer.test.ts', 'tracked.ts'],
|
||||
files: { 'consumer.test.ts': [1, 2, 1] }
|
||||
})
|
||||
)
|
||||
expect(readPassHash(root, 'worktree')).toBe('tested sources')
|
||||
const incomplete = dependencyRecord({ incomplete: ['consumer.test.ts'] })
|
||||
write(incomplete)
|
||||
expect(readPassHash(root, 'worktree')).toBeNull()
|
||||
expect(hasNewIncompleteEvidence(evidence, readDependencyEvidence(file))).toBe(true)
|
||||
expect(writePassHash(root, 'old evidence', 'worktree', evidence)).toBe(false)
|
||||
expect(readPassHash(root)).toBe('docs only branch')
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(root, '.cache/check-changed.json'), 'utf8')).passes.worktree.hash
|
||||
).toBe('tested sources')
|
||||
})
|
||||
|
||||
it('accepts passing record updates in one invocation and distinguishes stable incomplete evidence', () => {
|
||||
const file = join(root, '.cache/dependencies.json')
|
||||
mkdirSync(join(root, '.cache'))
|
||||
writeFileSync(file, JSON.stringify(dependencyRecord({ incomplete: ['consumer.test.ts'] })))
|
||||
const before = readDependencyEvidence(file)
|
||||
expect(hasNewIncompleteEvidence(before, readDependencyEvidence(file))).toBe(false)
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify(dependencyRecord({ modules: ['other.ts', 'consumer.test.ts'] }))
|
||||
)
|
||||
const passing = readDependencyEvidence(file)
|
||||
expect(passing.hash).not.toBe(before.hash)
|
||||
expect(hasNewIncompleteEvidence(before, passing)).toBe(false)
|
||||
expect(writePassHash(root, 'passed once', 'branch', passing)).toBe(true)
|
||||
expect(readPassHash(root)).toBe('passed once')
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify(dependencyRecord({ contexts: { 'consumer.test.ts': 'new context' } }))
|
||||
)
|
||||
expect(readPassHash(root)).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses malformed, unsupported, oversized, and removed dependency evidence', () => {
|
||||
const file = join(root, '.cache/dependencies.json')
|
||||
mkdirSync(join(root, '.cache'))
|
||||
writeFileSync(file, JSON.stringify(dependencyRecord()))
|
||||
const evidence = readDependencyEvidence(file)
|
||||
for (const contents of [
|
||||
'{',
|
||||
JSON.stringify({ version: 1, projects: {} }),
|
||||
JSON.stringify(dependencyRecord({ files: { 'consumer.test.ts': [99] } }))
|
||||
]) {
|
||||
writeFileSync(file, contents)
|
||||
expect(readDependencyEvidence(file)).toBeNull()
|
||||
expect(writePassHash(root, 'unverifiable', 'branch', evidence)).toBe(false)
|
||||
}
|
||||
truncateSync(file, 32 * 1024 * 1024 + 1)
|
||||
expect(readDependencyEvidence(file)).toBeNull()
|
||||
writeFileSync(file, JSON.stringify(dependencyRecord()))
|
||||
writePassHash(root, 'before clearing', 'branch', evidence)
|
||||
rmSync(file)
|
||||
expect(readPassHash(root)).toBeNull()
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rejects FIFO evidence without blocking cache lookup',
|
||||
() => {
|
||||
const file = join(root, 'dependencies.json')
|
||||
const fifo = runProcessSync({ program: 'mkfifo', args: [file], cwd: root })
|
||||
expect(fifo.code, fifo.stderr).toBe(0)
|
||||
const result = runProcessSync({
|
||||
program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath,
|
||||
args: [
|
||||
'--input-type=module',
|
||||
'-e',
|
||||
`
|
||||
import { readDependencyEvidence } from ${JSON.stringify(new URL('./check-changed-dependency-evidence.mjs', import.meta.url).href)}
|
||||
if (readDependencyEvidence(${JSON.stringify(file)}) !== null) process.exitCode = 1
|
||||
`
|
||||
],
|
||||
cwd: root,
|
||||
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
|
||||
timeoutMs: 1_000
|
||||
})
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
const OUTPUT_TAIL_LINES = 200
|
||||
const HEARTBEAT_MS = 15_000
|
||||
const LIVE_OUTPUT_QUEUE_BYTES = 64 * 1024
|
||||
const OUTPUT_FLUSH_MS = 250
|
||||
|
||||
export function createTaskOutput(cancel) {
|
||||
let failure
|
||||
let blocked = false
|
||||
let disposing = false
|
||||
const targets = [process.stdout, process.stderr].map((stream) => {
|
||||
let dropped = false
|
||||
let closed = false
|
||||
const fail = (error) => {
|
||||
closed = true
|
||||
failure ??= `Output consumer failed: ${error.message ?? error}`
|
||||
cancel()
|
||||
}
|
||||
const onClose = () => {
|
||||
if (!disposing) {
|
||||
fail(new Error('stream closed'))
|
||||
}
|
||||
}
|
||||
stream.on('error', fail)
|
||||
stream.on('close', onClose)
|
||||
const write = (chunk) => {
|
||||
if (closed || stream.destroyed) {
|
||||
return
|
||||
}
|
||||
if (stream.writableNeedDrain || stream.writableLength >= LIVE_OUTPUT_QUEUE_BYTES) {
|
||||
dropped = true
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (dropped) {
|
||||
dropped = false
|
||||
if (
|
||||
!stream.write(
|
||||
'… live output omitted for a slow consumer; failure diagnostics are retained.\n'
|
||||
)
|
||||
) {
|
||||
dropped = true
|
||||
return
|
||||
}
|
||||
}
|
||||
stream.write(chunk)
|
||||
} catch (error) {
|
||||
fail(error)
|
||||
}
|
||||
}
|
||||
const drain = () =>
|
||||
new Promise((resolve) => {
|
||||
if (closed || stream.destroyed || !stream.writableLength) {
|
||||
setImmediate(resolve)
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
blocked = true
|
||||
fail(new Error('stream did not drain'))
|
||||
stream.unref?.()
|
||||
setImmediate(resolve)
|
||||
}, OUTPUT_FLUSH_MS)
|
||||
stream.write('', () => {
|
||||
clearTimeout(timer)
|
||||
setImmediate(resolve)
|
||||
})
|
||||
})
|
||||
const flush = async () => {
|
||||
await drain()
|
||||
if (dropped && !closed) {
|
||||
write('')
|
||||
await drain()
|
||||
}
|
||||
}
|
||||
return {
|
||||
write,
|
||||
flush,
|
||||
dispose: () => {
|
||||
stream.off('error', fail)
|
||||
stream.off('close', onClose)
|
||||
}
|
||||
}
|
||||
})
|
||||
return {
|
||||
get failure() {
|
||||
return failure
|
||||
},
|
||||
get blocked() {
|
||||
return blocked
|
||||
},
|
||||
stdout: targets[0].write,
|
||||
stderr: targets[1].write,
|
||||
log: (message) => targets[0].write(`${message}\n`),
|
||||
error: (message) => targets[1].write(`${message}\n`),
|
||||
flush: () => Promise.all(targets.map((target) => target.flush())),
|
||||
async dispose() {
|
||||
disposing = true
|
||||
await Promise.all(targets.map((target) => target.flush()))
|
||||
targets.forEach((target) => target.dispose())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function elapsed(startedAt) {
|
||||
return `${((performance.now() - startedAt) / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
export function outputTail(text) {
|
||||
const lines = text.trimEnd().split(/\r?\n/)
|
||||
return lines.length <= OUTPUT_TAIL_LINES
|
||||
? lines.join('\n')
|
||||
: [
|
||||
`… ${lines.length - OUTPUT_TAIL_LINES} earlier lines omitted`,
|
||||
...lines.slice(-OUTPUT_TAIL_LINES)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export async function runTask(
|
||||
task,
|
||||
{ root, env, runProcess, stepTimeoutMs, signal, output: sharedOutput }
|
||||
) {
|
||||
const abort = new AbortController()
|
||||
const cancel = () => abort.abort()
|
||||
signal?.addEventListener('abort', cancel, { once: true })
|
||||
if (signal?.aborted) {
|
||||
cancel()
|
||||
}
|
||||
const live = sharedOutput ?? createTaskOutput(cancel)
|
||||
const startedAt = performance.now()
|
||||
let output = ''
|
||||
try {
|
||||
for (const [index, { program, args, liveOutput }] of task.steps.entries()) {
|
||||
if (abort.signal.aborted) {
|
||||
return { task, ok: false, output: `${output}\nCancelled.`, elapsed: elapsed(startedAt) }
|
||||
}
|
||||
const command = [program, ...args].map((argument) => JSON.stringify(argument)).join(' ')
|
||||
const label = `${task.name} [${index + 1}/${task.steps.length}]`
|
||||
live.log(`▶ ${label}: ${command} (deadline ${stepTimeoutMs / 1000}s)`)
|
||||
const heartbeat = setInterval(() => {
|
||||
live.log(`… ${label} still running (${elapsed(startedAt)} elapsed)`)
|
||||
}, HEARTBEAT_MS)
|
||||
try {
|
||||
const result = await runProcess(
|
||||
{
|
||||
program,
|
||||
args,
|
||||
cwd: root,
|
||||
env,
|
||||
timeoutMs: stepTimeoutMs,
|
||||
maxOutputBytes: 256 * 1024,
|
||||
signal: abort.signal,
|
||||
detached: process.platform !== 'win32',
|
||||
terminationBarrier: true,
|
||||
...(liveOutput
|
||||
? {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
onStdout: live.stdout,
|
||||
onStderr: live.stderr
|
||||
}
|
||||
: {})
|
||||
},
|
||||
'tail'
|
||||
)
|
||||
output = outputTail(output + result.stdout + result.stderr)
|
||||
if (!sharedOutput) {
|
||||
await live.flush()
|
||||
}
|
||||
if (
|
||||
result.timedOut ||
|
||||
abort.signal.aborted ||
|
||||
result.code !== 0 ||
|
||||
result.terminationUnverified
|
||||
) {
|
||||
const reason = result.timedOut
|
||||
? `Timed out after ${stepTimeoutMs / 1000}s; increase --stepTimeoutMs=<ms> for a longer run.`
|
||||
: abort.signal.aborted
|
||||
? (live.failure ?? 'Cancelled.')
|
||||
: `Exited with ${result.signal ?? result.code}.`
|
||||
const cleanup = result.terminationUnverified
|
||||
? '\nCleanup unverified: output pipes remained open or tree termination was not confirmed; escaped descendants may remain.'
|
||||
: ''
|
||||
return {
|
||||
task,
|
||||
ok: false,
|
||||
output: `${output}\n${reason}${cleanup}\nCommand: ${command}`,
|
||||
elapsed: elapsed(startedAt)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
task,
|
||||
ok: false,
|
||||
output: `${output}\n${error.message ?? error}\nCommand: ${command}`,
|
||||
elapsed: elapsed(startedAt)
|
||||
}
|
||||
} finally {
|
||||
clearInterval(heartbeat)
|
||||
}
|
||||
}
|
||||
live.log(`✓ ${task.name} (${elapsed(startedAt)})`)
|
||||
return { task, ok: true, output, elapsed: elapsed(startedAt) }
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', cancel)
|
||||
if (!sharedOutput) {
|
||||
await live.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPool(tasks, concurrency, run) {
|
||||
const results = []
|
||||
let next = 0
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(concurrency, tasks.length) }, async () => {
|
||||
while (next < tasks.length) {
|
||||
results.push(await run(tasks[next++]))
|
||||
}
|
||||
})
|
||||
)
|
||||
return results
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { runPool, runTask } from './check-changed-task-runner.mjs'
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reports live progress, bounds the failure tail, and stops a task on timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
const logs = vi.fn()
|
||||
const output = { log: logs, stdout: vi.fn(), stderr: vi.fn() }
|
||||
let finish
|
||||
const task = {
|
||||
name: 'unit tests',
|
||||
steps: [
|
||||
{ program: 'test-runner', args: ['run'], liveOutput: true },
|
||||
{ program: 'must-not-run', args: [] }
|
||||
]
|
||||
}
|
||||
const launch = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const pending = runTask(task, {
|
||||
root: '.',
|
||||
env: {},
|
||||
runProcess: launch,
|
||||
stepTimeoutMs: 30_000,
|
||||
output
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
expect(logs.mock.calls.flat().join('\n')).toContain('still running')
|
||||
expect(launch.mock.calls[0][0]).toMatchObject({
|
||||
timeoutMs: 30_000,
|
||||
terminationBarrier: true,
|
||||
detached: process.platform !== 'win32',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
onStdout: expect.any(Function)
|
||||
})
|
||||
finish({
|
||||
code: 0,
|
||||
signal: null,
|
||||
timedOut: true,
|
||||
stdout: `${'old\n'.repeat(500)}last diagnostic\n`,
|
||||
stderr: ''
|
||||
})
|
||||
const result = await pending
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.output).toContain('last diagnostic')
|
||||
expect(result.output).toContain('Timed out after 30s')
|
||||
expect(result.output).toContain('Command: "test-runner" "run"')
|
||||
expect(result.output.split('\n').length).toBeLessThan(210)
|
||||
expect(launch).toHaveBeenCalledTimes(1)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('cancels a real process and never starts subsequent steps', async () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const abort = new AbortController()
|
||||
const task = {
|
||||
name: 'cancel',
|
||||
steps: [
|
||||
{
|
||||
program: process.execPath,
|
||||
args: ['-e', 'process.stderr.write("ready"); setInterval(() => {}, 1000)'],
|
||||
liveOutput: true
|
||||
},
|
||||
{ program: 'must-not-run', args: [] }
|
||||
]
|
||||
}
|
||||
const launch = (spec, capture) => runProcess({ ...spec, onStderr: () => abort.abort() }, capture)
|
||||
const result = await runTask(task, {
|
||||
root: process.cwd(),
|
||||
env: process.env,
|
||||
runProcess: launch,
|
||||
stepTimeoutMs: 10_000,
|
||||
signal: abort.signal
|
||||
})
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.output).toContain('ready')
|
||||
expect(result.output).toContain('Cancelled.')
|
||||
})
|
||||
|
||||
it('caps concurrency while continuing independent checks after a failure', async () => {
|
||||
let active = 0
|
||||
let peak = 0
|
||||
const releases = []
|
||||
const pending = runPool([0, 1, 2], 2, async (task) => {
|
||||
active += 1
|
||||
peak = Math.max(peak, active)
|
||||
await new Promise((resolve) => releases.push(resolve))
|
||||
active -= 1
|
||||
return { task, ok: task !== 0 }
|
||||
})
|
||||
expect(releases).toHaveLength(2)
|
||||
releases[0]()
|
||||
await vi.waitFor(() => expect(releases).toHaveLength(3))
|
||||
releases[1]()
|
||||
releases[2]()
|
||||
const results = await pending
|
||||
expect(peak).toBe(2)
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ task: 0, ok: false },
|
||||
{ task: 1, ok: true },
|
||||
{ task: 2, ok: true }
|
||||
])
|
||||
)
|
||||
})
|
||||
+124
-216
@@ -1,7 +1,6 @@
|
||||
// Branch handoff and worktree iteration checks share selection, deadlines, and progress.
|
||||
// Pre-handoff gate: runs the checks a change's paths need, in parallel, printing only failures.
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { availableParallelism } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
@@ -9,39 +8,21 @@ import { pathToFileURL } from 'node:url'
|
||||
import {
|
||||
collectChangedFiles,
|
||||
computePassHash,
|
||||
changedCheckEnvironment,
|
||||
invalidatePassHash,
|
||||
readPassHash,
|
||||
writePassHash
|
||||
} from './check-changed-pass-cache.mjs'
|
||||
import { planChangedChecks } from './check-changed-selection.mjs'
|
||||
import {
|
||||
DEPENDENCY_PATH_PREFIX,
|
||||
hasNewIncompleteEvidence,
|
||||
readDependencyEvidence
|
||||
} from './check-changed-dependency-evidence.mjs'
|
||||
import {
|
||||
createTaskOutput,
|
||||
outputTail as tail,
|
||||
runPool,
|
||||
runTask
|
||||
} from './check-changed-task-runner.mjs'
|
||||
import { resolveOxcCliInvocation } from './oxc-cli-invocation.mjs'
|
||||
import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs'
|
||||
|
||||
const OUTPUT_TAIL_LINES = 200
|
||||
// Windows caps a command line near 32K characters; past this, lint the whole tree instead.
|
||||
const MAX_PATH_ARGUMENT_CHARS = 24_000
|
||||
const LINTABLE = /\.[cm]?[jt]sx?$/
|
||||
const ANTI_SLOP_ROOTS = ['src/', 'config/', 'tests/', 'mobile/']
|
||||
const runsAsCommand = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
force: false,
|
||||
base: null,
|
||||
maxWorkers: null,
|
||||
scope: 'branch',
|
||||
stepTimeoutMs: 300_000
|
||||
}
|
||||
const options = { force: false, base: null, maxWorkers: null }
|
||||
for (const argument of argv) {
|
||||
if (argument === '--') {
|
||||
continue
|
||||
@@ -49,37 +30,12 @@ function parseArgs(argv) {
|
||||
options.force = true
|
||||
} else if (argument.startsWith('--base=')) {
|
||||
options.base = argument.slice('--base='.length)
|
||||
if (!options.base) {
|
||||
throw new Error('Invalid --base=: pass an existing commit or ref.')
|
||||
}
|
||||
} else if (argument.startsWith('--scope=')) {
|
||||
options.scope = argument.slice('--scope='.length)
|
||||
} else if (argument.startsWith('--stepTimeoutMs=')) {
|
||||
options.stepTimeoutMs = Number(argument.slice('--stepTimeoutMs='.length))
|
||||
} else if (/^--max-?workers=\d+$/i.test(argument)) {
|
||||
options.maxWorkers = argument.split('=')[1]
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unknown option ${argument}. Use --force, --base=<ref>, --scope=branch|worktree, --stepTimeoutMs=<ms>, --maxWorkers=<n>.`
|
||||
)
|
||||
throw new Error(`Unknown option ${argument}. Use --force, --base=<ref>, --maxWorkers=<n>.`)
|
||||
}
|
||||
}
|
||||
if (!['branch', 'worktree'].includes(options.scope)) {
|
||||
throw new Error('--scope must be branch or worktree.')
|
||||
}
|
||||
if (options.scope === 'worktree' && options.base) {
|
||||
throw new Error('--base applies only to --scope=branch.')
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(options.stepTimeoutMs) ||
|
||||
options.stepTimeoutMs <= 0 ||
|
||||
options.stepTimeoutMs > 2_147_483_647
|
||||
) {
|
||||
throw new Error('--stepTimeoutMs must be a positive integer no larger than 2147483647.')
|
||||
}
|
||||
if (options.maxWorkers !== null && Number(options.maxWorkers) < 1) {
|
||||
throw new Error('--maxWorkers must be positive.')
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
@@ -88,10 +44,7 @@ function git(root, args) {
|
||||
}
|
||||
|
||||
function resolveMergeBase(root, requested) {
|
||||
const candidates = requested
|
||||
? [requested]
|
||||
: [process.env.ORCA_CODE_QUALITY_BASE, 'origin/main', 'main']
|
||||
for (const candidate of candidates) {
|
||||
for (const candidate of [requested, process.env.ORCA_CODE_QUALITY_BASE, 'origin/main', 'main']) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
@@ -107,9 +60,6 @@ function resolveMergeBase(root, requested) {
|
||||
return { ref: candidate, mergeBase: git(root, ['merge-base', candidate, 'HEAD']) }
|
||||
}
|
||||
}
|
||||
if (requested) {
|
||||
throw new Error(`Invalid --base=${requested}: fetch that ref or pass an existing commit.`)
|
||||
}
|
||||
throw new Error('No base: fetch origin/main or pass --base=<ref>.')
|
||||
}
|
||||
|
||||
@@ -117,10 +67,37 @@ function seconds(startedAt) {
|
||||
return `${((performance.now() - startedAt) / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
function tail(text) {
|
||||
const lines = text.trimEnd().split(/\r?\n/)
|
||||
if (lines.length <= OUTPUT_TAIL_LINES) {
|
||||
return lines.join('\n')
|
||||
}
|
||||
return [
|
||||
`… ${lines.length - OUTPUT_TAIL_LINES} earlier lines omitted`,
|
||||
...lines.slice(-OUTPUT_TAIL_LINES)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function fitsCommandLine(files) {
|
||||
return files.reduce((total, file) => total + file.length + 1, 0) <= MAX_PATH_ARGUMENT_CHARS
|
||||
}
|
||||
|
||||
// Why before importing @orca/process-host: its dist is what this builds, and a cache hit skips both.
|
||||
function buildWorkspacePackages(root, env) {
|
||||
const pnpm = resolvePnpmCliInvocation()
|
||||
const result = spawnSync(pnpm.command, [...pnpm.prefixArgs, 'run', 'build:packages'], {
|
||||
cwd: root,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
shell: pnpm.shell,
|
||||
windowsHide: true
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
console.error(tail(`${result.stdout ?? ''}${result.stderr ?? result.error?.message ?? ''}`))
|
||||
throw new Error('pnpm run build:packages failed.')
|
||||
}
|
||||
}
|
||||
|
||||
function buildTasks(root, plan, { files, mergeBase, maxWorkers, bun }) {
|
||||
const node = process.execPath
|
||||
const oxlint = resolveOxcCliInvocation('oxlint', 'oxlint', root)
|
||||
@@ -142,22 +119,18 @@ function buildTasks(root, plan, { files, mergeBase, maxWorkers, bun }) {
|
||||
]
|
||||
})
|
||||
}
|
||||
const related = plan.unitTests.related
|
||||
const deletedRelated = related.some((file) => !existsSync(path.join(root, file)))
|
||||
const related = plan.unitTests.related.filter((file) => existsSync(path.join(root, file)))
|
||||
const colocated = plan.unitTests.colocated.filter((file) => existsSync(path.join(root, file)))
|
||||
if (related.length > 0 || colocated.length > 0) {
|
||||
const vitest = (selection) => ({
|
||||
...step(node, [
|
||||
const vitest = (selection) =>
|
||||
step(node, [
|
||||
'config/scripts/run-vitest.mjs',
|
||||
...selection,
|
||||
'--config',
|
||||
'config/vitest.config.ts',
|
||||
'--passWithNoTests',
|
||||
'--reporter=default',
|
||||
...(maxWorkers ? [`--maxWorkers=${maxWorkers}`] : [])
|
||||
]),
|
||||
liveOutput: true
|
||||
})
|
||||
])
|
||||
tasks.push({
|
||||
name: 'unit tests',
|
||||
steps: [
|
||||
@@ -165,11 +138,9 @@ function buildTasks(root, plan, { files, mergeBase, maxWorkers, bun }) {
|
||||
...(related.length > 0
|
||||
? [
|
||||
vitest(
|
||||
deletedRelated
|
||||
? ['run']
|
||||
: fitsCommandLine(related)
|
||||
? ['related', '--run', ...related]
|
||||
: ['run', `--changed=${mergeBase}`]
|
||||
fitsCommandLine(related)
|
||||
? ['related', '--run', ...related]
|
||||
: ['run', `--changed=${mergeBase}`]
|
||||
)
|
||||
]
|
||||
: []),
|
||||
@@ -235,30 +206,55 @@ function buildTasks(root, plan, { files, mergeBase, maxWorkers, bun }) {
|
||||
return tasks
|
||||
}
|
||||
|
||||
async function runTask(task, { root, env, runProcess }) {
|
||||
const startedAt = performance.now()
|
||||
let output = ''
|
||||
for (const { program, args } of task.steps) {
|
||||
try {
|
||||
const result = await runProcess({ program, args, cwd: root, env, timeoutMs: null }, 'tail')
|
||||
output += result.stdout + result.stderr
|
||||
if (result.code !== 0) {
|
||||
return { task, ok: false, output, elapsed: seconds(startedAt) }
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
task,
|
||||
ok: false,
|
||||
output: `${output}${error.message ?? error}`,
|
||||
elapsed: seconds(startedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { task, ok: true, output, elapsed: seconds(startedAt) }
|
||||
}
|
||||
|
||||
async function runPool(tasks, concurrency, run) {
|
||||
const results = []
|
||||
let next = 0
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(concurrency, tasks.length) }, async () => {
|
||||
while (next < tasks.length) {
|
||||
results.push(await run(tasks[next++]))
|
||||
}
|
||||
})
|
||||
)
|
||||
return results
|
||||
}
|
||||
|
||||
export async function main(root, argv) {
|
||||
const startedAt = performance.now()
|
||||
const options = parseArgs(argv)
|
||||
const { ref, mergeBase } =
|
||||
options.scope === 'worktree'
|
||||
? { ref: 'HEAD (staged, unstaged, untracked)', mergeBase: git(root, ['rev-parse', 'HEAD']) }
|
||||
: resolveMergeBase(root, options.base)
|
||||
const { ref, mergeBase } = resolveMergeBase(root, options.base)
|
||||
const files = collectChangedFiles(root, mergeBase)
|
||||
if (files.length === 0) {
|
||||
console.log(`check:changed: no changes against ${ref}.`)
|
||||
return 0
|
||||
}
|
||||
const env = {
|
||||
...changedCheckEnvironment(options.scope),
|
||||
// Spawned gates that resolve their own base must use the same pinned comparison.
|
||||
ORCA_CODE_QUALITY_BASE: mergeBase,
|
||||
...(process.stdout.isTTY ? { FORCE_COLOR: '1' } : {})
|
||||
}
|
||||
const hash = computePassHash(root, mergeBase, options.scope, env)
|
||||
if (!options.force && readPassHash(root, options.scope) === hash) {
|
||||
console.log(`check:changed (${options.scope}): nothing changed since last passing run.`)
|
||||
const hash = computePassHash(root, mergeBase)
|
||||
if (!options.force && readPassHash(root) === hash) {
|
||||
console.log('Nothing changed since last passing run.')
|
||||
return 0
|
||||
}
|
||||
invalidatePassHash(root, options.scope)
|
||||
|
||||
const plan = planChangedChecks(files, {
|
||||
scriptsDir: path.join(root, 'config', 'scripts'),
|
||||
@@ -270,142 +266,54 @@ export async function main(root, argv) {
|
||||
}
|
||||
}
|
||||
})
|
||||
const abort = new AbortController()
|
||||
const cancel = () => abort.abort()
|
||||
const signals =
|
||||
process.platform === 'win32' ? ['SIGINT', 'SIGTERM'] : ['SIGINT', 'SIGTERM', 'SIGHUP']
|
||||
signals.forEach((signal) => process.on(signal, cancel))
|
||||
const output = createTaskOutput(cancel)
|
||||
let completedPass = false
|
||||
try {
|
||||
output.log(
|
||||
`check:changed (${options.scope}): ${files.length} changed file(s) since ${ref} (${mergeBase.slice(0, 10)}); unit inventory: ${options.scope === 'worktree' ? 'CI' : 'local'}.`
|
||||
)
|
||||
// Load the source runner so even its own initial build has tree cancellation and a deadline.
|
||||
const requireFromHost = createRequire(
|
||||
new URL('../../src/packages/process-host/package.json', import.meta.url)
|
||||
)
|
||||
const { tsImport } = await import(pathToFileURL(requireFromHost.resolve('tsx/esm/api')).href)
|
||||
const { runProcess, runProcessSync } = await tsImport(
|
||||
'../../src/packages/process-host/src/run-process.ts',
|
||||
import.meta.url
|
||||
)
|
||||
const run = async (task) => {
|
||||
const result = await runTask(task, {
|
||||
root,
|
||||
env,
|
||||
runProcess,
|
||||
stepTimeoutMs: options.stepTimeoutMs,
|
||||
signal: abort.signal,
|
||||
output
|
||||
})
|
||||
if (!result.ok) {
|
||||
output.error(`\n✗ ${task.name} (${result.elapsed})\n${tail(result.output)}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
if (
|
||||
plan.typecheckProjects.length > 0 ||
|
||||
plan.unitTests.related.length + plan.unitTests.colocated.length > 0
|
||||
) {
|
||||
const build = await run({
|
||||
name: 'build workspace packages',
|
||||
steps: [
|
||||
{
|
||||
program: process.execPath,
|
||||
args: ['config/scripts/build-workspace-packages.mjs'],
|
||||
liveOutput: true
|
||||
}
|
||||
]
|
||||
})
|
||||
if (!build.ok) {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
const { resolvePinnedBunExecutable } = await import('./pinned-bun-executable.mjs')
|
||||
const bun = () => resolvePinnedBunExecutable(root, runProcessSync) ?? 'bun'
|
||||
const tasks = buildTasks(root, plan, { files, mergeBase, maxWorkers: options.maxWorkers, bun })
|
||||
let evidencePath = null
|
||||
if (tasks.some((task) => task.name === 'unit tests')) {
|
||||
const resolved = await run({
|
||||
name: 'dependency evidence',
|
||||
steps: [
|
||||
{
|
||||
program: bun(),
|
||||
args: ['--bun', path.join(import.meta.dirname, 'check-changed-dependency-evidence.mjs')]
|
||||
}
|
||||
]
|
||||
})
|
||||
if (!resolved.ok) {
|
||||
return 1
|
||||
}
|
||||
const record = resolved.output
|
||||
.split('\n')
|
||||
.find((line) => line.startsWith(DEPENDENCY_PATH_PREFIX))
|
||||
evidencePath = record ? JSON.parse(record.slice(DEPENDENCY_PATH_PREFIX.length)) : undefined
|
||||
}
|
||||
const evidence = readDependencyEvidence(evidencePath)
|
||||
output.log(`check:changed: running ${tasks.length} check(s).`)
|
||||
// Typecheck and tests lead the queue so short checks fill in around them.
|
||||
const concurrency = options.maxWorkers
|
||||
? Number(options.maxWorkers)
|
||||
: Math.max(3, Math.floor(availableParallelism() / 3))
|
||||
const results = await runPool(tasks, concurrency, run)
|
||||
await output.flush()
|
||||
const failed = results.filter((result) => !result.ok)
|
||||
if (failed.length > 0 || abort.signal.aborted) {
|
||||
output.error(
|
||||
`\n✗ ${failed.length} of ${results.length} check(s) failed in ${seconds(startedAt)}: ${failed.map((result) => result.task.name).join(', ')}`
|
||||
)
|
||||
return 1
|
||||
}
|
||||
output.log(`✓ ${results.length} check(s) passed in ${seconds(startedAt)}.`)
|
||||
await output.flush()
|
||||
if (abort.signal.aborted) {
|
||||
return 1
|
||||
}
|
||||
if (computePassHash(root, mergeBase, options.scope, env) !== hash) {
|
||||
output.error('check:changed: inputs changed during checks; rerun before recording a pass.')
|
||||
return 1
|
||||
}
|
||||
const finalEvidence = readDependencyEvidence(evidencePath)
|
||||
if (hasNewIncompleteEvidence(evidence, finalEvidence)) {
|
||||
output.error(
|
||||
'check:changed: new incomplete dependency evidence arrived during checks; rerun before recording a pass.'
|
||||
)
|
||||
return 1
|
||||
}
|
||||
if (!evidence || !finalEvidence) {
|
||||
output.log('check:changed: dependency evidence unavailable; passing checks were not cached.')
|
||||
} else {
|
||||
completedPass = writePassHash(root, hash, options.scope, finalEvidence)
|
||||
if (!completedPass) {
|
||||
output.error(
|
||||
'check:changed: dependency evidence changed while recording a pass; rerun checks.'
|
||||
)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
let disposed = false
|
||||
try {
|
||||
await output.dispose()
|
||||
disposed = true
|
||||
} finally {
|
||||
if (!completedPass || !disposed || abort.signal.aborted) {
|
||||
invalidatePassHash(root, options.scope, hash)
|
||||
}
|
||||
signals.forEach((signal) => process.off(signal, cancel))
|
||||
if (output.blocked && runsAsCommand) {
|
||||
// Node protects stdio from destroy(); exit only after owned checks have settled.
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
const env = {
|
||||
...process.env,
|
||||
ORCA_BACKGROUND_LAUNCH: '1',
|
||||
// Why: spawned gates that read the base themselves (flaky-test ratchet) must use --base too.
|
||||
ORCA_CODE_QUALITY_BASE: mergeBase,
|
||||
...(process.stdout.isTTY ? { FORCE_COLOR: '1' } : {})
|
||||
}
|
||||
return abort.signal.aborted ? 1 : 0
|
||||
if (
|
||||
plan.typecheckProjects.length > 0 ||
|
||||
plan.unitTests.related.length + plan.unitTests.colocated.length > 0
|
||||
) {
|
||||
buildWorkspacePackages(root, env)
|
||||
}
|
||||
const { runProcess, runProcessSync } = await import('@orca/process-host')
|
||||
const { resolvePinnedBunExecutable } = await import('./pinned-bun-executable.mjs')
|
||||
const bun = () => resolvePinnedBunExecutable(root, runProcessSync) ?? 'bun'
|
||||
const tasks = buildTasks(root, plan, {
|
||||
files,
|
||||
mergeBase,
|
||||
maxWorkers: options.maxWorkers,
|
||||
bun
|
||||
})
|
||||
console.log(
|
||||
`check:changed: ${files.length} changed file(s) since ${ref} (${mergeBase.slice(0, 10)}); running ${tasks.length} check(s).`
|
||||
)
|
||||
|
||||
// Typecheck and tests lead the queue: they take longest, so the short checks fill in around them.
|
||||
const concurrency = Math.max(3, Math.floor(availableParallelism() / 3))
|
||||
const results = await runPool(tasks, concurrency, async (task) => {
|
||||
const result = await runTask(task, { root, env, runProcess })
|
||||
if (!result.ok) {
|
||||
console.error(`\n✗ ${task.name} (${result.elapsed})\n${tail(result.output)}`)
|
||||
}
|
||||
return result
|
||||
})
|
||||
const failed = results.filter((result) => !result.ok)
|
||||
if (failed.length > 0) {
|
||||
console.error(
|
||||
`\n✗ ${failed.length} of ${results.length} check(s) failed in ${seconds(startedAt)}: ${failed.map((result) => result.task.name).join(', ')}`
|
||||
)
|
||||
return 1
|
||||
}
|
||||
writePassHash(root, hash)
|
||||
console.log(`✓ ${results.length} check(s) passed in ${seconds(startedAt)}.`)
|
||||
return 0
|
||||
}
|
||||
|
||||
if (runsAsCommand) {
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
try {
|
||||
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim()
|
||||
process.exitCode = await main(root, process.argv.slice(2))
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { runProcess, runProcessSync } from '@orca/process-host'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { readPassHash } from './check-changed-pass-cache.mjs'
|
||||
import { resolvePinnedBunExecutable } from './pinned-bun-executable.mjs'
|
||||
|
||||
it('keeps branch coverage after an iteration pass and never caches a timed-out gate', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'check-changed-gate-'))
|
||||
function git(...args) {
|
||||
const result = runProcessSync({ program: 'git', args, cwd: root })
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
return result.stdout.trim()
|
||||
}
|
||||
function check(...args) {
|
||||
return runProcessSync({
|
||||
program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath,
|
||||
args: [resolve('config/scripts/check-changed.mjs'), ...args, '--maxWorkers=2'],
|
||||
cwd: root,
|
||||
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
}
|
||||
try {
|
||||
git('init', '-q')
|
||||
git('config', 'user.email', 'test@example.com')
|
||||
git('config', 'user.name', 'Test')
|
||||
git('config', 'core.autocrlf', 'false')
|
||||
writeFileSync(join(root, '.gitignore'), '.cache/\nconfig\nnode_modules\n')
|
||||
git('add', '.')
|
||||
git('commit', '-qm', 'base')
|
||||
const base = git('rev-parse', 'HEAD')
|
||||
symlinkSync(resolve('config'), join(root, 'config'), 'junction')
|
||||
symlinkSync(resolve('node_modules'), join(root, 'node_modules'), 'junction')
|
||||
writeFileSync(join(root, 'committed.md'), '# Branch change\n')
|
||||
git('add', 'committed.md')
|
||||
git('commit', '-qm', 'branch change')
|
||||
|
||||
const cleanIteration = check('--scope=worktree')
|
||||
expect(cleanIteration.code, cleanIteration.stderr).toBe(0)
|
||||
expect(cleanIteration.stdout).toContain('no changes against HEAD')
|
||||
|
||||
writeFileSync(join(root, 'local.md'), '# Local change\n')
|
||||
const iteration = check('--scope=worktree')
|
||||
expect(iteration.code, iteration.stderr).toBe(0)
|
||||
expect(iteration.stdout).toContain('1 changed file(s) since HEAD')
|
||||
const iterationPass = readFileSync(join(root, '.cache/check-changed.json'), 'utf8')
|
||||
expect(check('--scope=worktree').stdout).toContain('nothing changed since last passing run')
|
||||
|
||||
const timedOut = check(`--base=${base}`, '--stepTimeoutMs=1')
|
||||
expect(timedOut.code).toBe(1)
|
||||
expect(timedOut.stderr).toContain('Timed out after')
|
||||
expect(readFileSync(join(root, '.cache/check-changed.json'), 'utf8')).toBe(iterationPass)
|
||||
|
||||
const branch = check(`--base=${base}`)
|
||||
expect(branch.code, branch.stderr).toBe(0)
|
||||
expect(branch.stdout).toContain('2 changed file(s)')
|
||||
expect(branch.stdout).not.toContain('nothing changed since last passing run')
|
||||
expect(check(`--base=${base}`).stdout).toContain('nothing changed since last passing run')
|
||||
expect(check('--scope=worktree').stdout).toContain('nothing changed since last passing run')
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
function gateFixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'check-changed-integrity-'))
|
||||
function git(...args) {
|
||||
const result = runProcessSync({ program: 'git', args, cwd: root })
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
return result.stdout.trim()
|
||||
}
|
||||
function write(file, contents) {
|
||||
mkdirSync(join(root, file, '..'), { recursive: true })
|
||||
writeFileSync(join(root, file), contents)
|
||||
}
|
||||
function checkSpec(args = [], env = {}) {
|
||||
return {
|
||||
program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath,
|
||||
args: [resolve('config/scripts/check-changed.mjs'), '--maxWorkers=1', ...args],
|
||||
cwd: root,
|
||||
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ...env },
|
||||
timeoutMs: 30_000
|
||||
}
|
||||
}
|
||||
git('init', '-q')
|
||||
git('config', 'user.email', 'test@example.com')
|
||||
git('config', 'user.name', 'Test')
|
||||
git('config', 'core.autocrlf', 'false')
|
||||
write('.gitignore', '.cache/\nconfig/\nnode_modules\n')
|
||||
write('.oxfmtrc.json', '{"semi":false}\n')
|
||||
write('sample.md', '# Original\n')
|
||||
git('add', '.')
|
||||
git('commit', '-qm', 'base')
|
||||
const base = git('rev-parse', 'HEAD')
|
||||
symlinkSync(resolve('node_modules'), join(root, 'node_modules'), 'junction')
|
||||
write(
|
||||
'config/scripts/check-changed-code-quality.mjs',
|
||||
`
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
if (process.env.ORCA_BALANCE_UNIT_SHARDS !== '0') throw Error('inherited sharding')
|
||||
if (process.env.ORCA_UNIT_INVENTORY !== process.env.EXPECTED_INVENTORY) throw Error('inventory')
|
||||
const mode = readFileSync('.cache/control', 'utf8')
|
||||
if (mode === 'fail') process.exitCode = 1
|
||||
if (mode === 'mutate') writeFileSync('sample.md', '# Good\\n')
|
||||
if (process.env.ORCA_GATE_DELAYED_FAILURE === '1' || process.env.ORCA_GATE_WAIT === '1') {
|
||||
writeFileSync('.cache/waiting', '')
|
||||
while (!existsSync('.cache/release')) await setTimeout(10)
|
||||
if (process.env.ORCA_GATE_DELAYED_FAILURE === '1') process.exitCode = 1
|
||||
}
|
||||
`
|
||||
)
|
||||
write('.cache/control', 'pass')
|
||||
return {
|
||||
root,
|
||||
base,
|
||||
git,
|
||||
write,
|
||||
check: (...args) => runProcessSync(checkSpec(...args)),
|
||||
startCheck: (...args) => runProcess(checkSpec(...args))
|
||||
}
|
||||
}
|
||||
|
||||
function configureGateVitest({ root, write }) {
|
||||
write('config/.bun-version', readFileSync(resolve('config/.bun-version')))
|
||||
for (const script of ['build-workspace-packages', 'ensure-native-runtime']) {
|
||||
write(`config/scripts/${script}.mjs`, '')
|
||||
}
|
||||
const bun = resolvePinnedBunExecutable(process.cwd(), runProcessSync)
|
||||
expect(bun).toBeTruthy()
|
||||
write(
|
||||
'config/vitest.config.ts',
|
||||
`export default {
|
||||
root: ${JSON.stringify(root)}, cacheDir: ${JSON.stringify(join(root, '.cache/vitest'))},
|
||||
test: { include: ['tests/tools/*.test.mjs'], maxWorkers: 1,
|
||||
experimental: { recordDependencies: true } }
|
||||
}`
|
||||
)
|
||||
write(
|
||||
'config/scripts/run-vitest.mjs',
|
||||
`
|
||||
import { runProcessSync } from '@orca/process-host'
|
||||
const result = runProcessSync({
|
||||
program: ${JSON.stringify(bun)},
|
||||
args: ['--bun', ${JSON.stringify(resolve('node_modules/vitest/vitest.mjs'))}, ...process.argv.slice(2)],
|
||||
cwd: process.cwd(), env: process.env, timeoutMs: 10000
|
||||
})
|
||||
process.stdout.write(result.stdout)
|
||||
process.stderr.write(result.stderr)
|
||||
process.exitCode = result.code ?? 1
|
||||
`
|
||||
)
|
||||
return (...selection) =>
|
||||
runProcessSync({
|
||||
program: bun,
|
||||
args: [
|
||||
'--bun',
|
||||
resolve('node_modules/vitest/vitest.mjs'),
|
||||
...selection,
|
||||
'--config',
|
||||
'config/vitest.config.ts'
|
||||
],
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_UNIT_INVENTORY: 'ci',
|
||||
ORCA_BALANCE_UNIT_SHARDS: '0',
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
},
|
||||
timeoutMs: 10_000
|
||||
})
|
||||
}
|
||||
|
||||
it('removes a concurrent matching pass when an older forced check finishes with failure', async () => {
|
||||
const { root, base, write, check, startCheck } = gateFixture()
|
||||
const args = [`--base=${base}`]
|
||||
const env = { EXPECTED_INVENTORY: 'local' }
|
||||
let delayed
|
||||
try {
|
||||
write('sample.md', '# Changed\n')
|
||||
const iteration = check(['--scope=worktree'], { EXPECTED_INVENTORY: 'ci' })
|
||||
expect(iteration.code, iteration.stderr).toBe(0)
|
||||
const iterationHash = readPassHash(root, 'worktree')
|
||||
delayed = startCheck([...args, '--force'], { ...env, ORCA_GATE_DELAYED_FAILURE: '1' })
|
||||
await vi.waitFor(() => expect(existsSync(join(root, '.cache/waiting'))).toBe(true), {
|
||||
timeout: 10_000
|
||||
})
|
||||
const successful = check(args, env)
|
||||
expect(successful.code, successful.stderr).toBe(0)
|
||||
expect(readPassHash(root)).toBeTruthy()
|
||||
write('.cache/release', '')
|
||||
expect((await delayed).code).toBe(1)
|
||||
expect(readPassHash(root)).toBeNull()
|
||||
expect(readPassHash(root, 'worktree')).toBe(iterationHash)
|
||||
} finally {
|
||||
if (delayed) {
|
||||
write('.cache/release', '')
|
||||
await delayed
|
||||
}
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('rehashes changed inputs, separates CI passes, and invalidates a forced failed recheck', () => {
|
||||
const fixture = gateFixture()
|
||||
const { root, base, git, write } = fixture
|
||||
const branchArgs = [`--base=${base}`]
|
||||
const branchEnv = { EXPECTED_INVENTORY: 'local', ORCA_BALANCE_UNIT_SHARDS: '1', CI: '' }
|
||||
const check = (args = branchArgs, env = {}) => fixture.check(args, { ...branchEnv, ...env })
|
||||
try {
|
||||
write('sample.md', '# Changed\n')
|
||||
const ci = check(branchArgs, { CI: '1' })
|
||||
expect(ci.code, ci.stderr).toBe(0)
|
||||
const local = check()
|
||||
expect(local.code, local.stderr).toBe(0)
|
||||
expect(local.stdout).not.toContain('nothing changed since last passing run')
|
||||
const iteration = check(['--scope=worktree'], { EXPECTED_INVENTORY: 'ci' })
|
||||
expect(iteration.code, iteration.stderr).toBe(0)
|
||||
const iterationHash = readPassHash(root, 'worktree')
|
||||
|
||||
write('.cache/control', 'fail')
|
||||
expect(check([...branchArgs, '--force']).code).toBe(1)
|
||||
expect(readPassHash(root)).toBeNull()
|
||||
expect(readPassHash(root, 'worktree')).toBe(iterationHash)
|
||||
const failedRetry = check()
|
||||
expect(failedRetry.code).toBe(1)
|
||||
expect(failedRetry.stdout).not.toContain('nothing changed since last passing run')
|
||||
|
||||
write('.cache/control', 'mutate')
|
||||
write('sample.md', '# Bad\n\n- bad\n')
|
||||
const mutated = check()
|
||||
expect(mutated.code).toBe(1)
|
||||
expect(mutated.stderr).toContain('inputs changed during checks')
|
||||
expect(readPassHash(root)).toBeNull()
|
||||
write('.cache/control', 'pass')
|
||||
write('sample.md', '# Bad\n\n- bad\n')
|
||||
expect(check().code).toBe(1)
|
||||
|
||||
git('update-ref', 'refs/remotes/origin/main', base)
|
||||
const invalid = check(['--base=missing-review-base'])
|
||||
expect(invalid.code).toBe(1)
|
||||
expect(invalid.stderr).toContain('Invalid --base=missing-review-base')
|
||||
const emptyBase = check(['--base='])
|
||||
expect(emptyBase.code).toBe(1)
|
||||
expect(emptyBase.stderr).toContain('Invalid --base=')
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.each(['cold', 'recorded'])(
|
||||
'runs a deleted dependency consumer with %s records',
|
||||
(records) => {
|
||||
const fixture = gateFixture()
|
||||
const { root, git, write, check } = fixture
|
||||
try {
|
||||
write('value.mjs', 'export const value = 1\n')
|
||||
write(
|
||||
'tests/tools/deleted-consumer.test.mjs',
|
||||
`
|
||||
import { expect, it } from 'vitest'
|
||||
import { value } from '../../value.mjs'
|
||||
it('loads its dependency', () => expect(value).toBe(1))
|
||||
`
|
||||
)
|
||||
git('add', 'value.mjs', 'tests')
|
||||
git('commit', '-qm', 'consumer')
|
||||
const vitest = configureGateVitest(fixture)
|
||||
if (records === 'recorded') {
|
||||
const warm = vitest('run')
|
||||
expect(warm.code, warm.stderr).toBe(0)
|
||||
}
|
||||
rmSync(join(root, 'value.mjs'))
|
||||
const result = check(['--scope=worktree'], { EXPECTED_INVENTORY: 'ci' })
|
||||
expect(result.code).toBe(1)
|
||||
expect(result.stdout).toContain('unit tests')
|
||||
expect(result.stderr).toContain('value.mjs')
|
||||
expect(readPassHash(root, 'worktree')).toBeNull()
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
60_000
|
||||
)
|
||||
|
||||
function computedConsumerFixture() {
|
||||
const fixture = gateFixture()
|
||||
const { git, write } = fixture
|
||||
write('value.mjs', 'export const value = 1\n')
|
||||
write(
|
||||
'loader.mjs',
|
||||
`export async function load() { const file = './value.mjs'; return (await import(file)).value }\n`
|
||||
)
|
||||
write(
|
||||
'tests/tools/static.test.mjs',
|
||||
`import {expect,it} from 'vitest'; import {value} from '../../value.mjs'; it('static consumer', () => expect(value).toBeGreaterThan(0))\n`
|
||||
)
|
||||
write(
|
||||
'tests/tools/dynamic.test.mjs',
|
||||
`import {expect,it} from 'vitest'; import {load} from '../../loader.mjs'; it('computed consumer', async () => expect(await load()).toBe(1))\n`
|
||||
)
|
||||
for (let index = 0; index < 102; index++) {
|
||||
write(
|
||||
`tests/tools/cold-${index}.test.mjs`,
|
||||
`import {it} from 'vitest'; it('unrelated', () => {})\n`
|
||||
)
|
||||
}
|
||||
git('add', 'value.mjs', 'loader.mjs', 'tests')
|
||||
git('commit', '-qm', 'computed consumers')
|
||||
const vitest = configureGateVitest(fixture)
|
||||
write('value.mjs', 'export const value = 2\n')
|
||||
return { ...fixture, vitest }
|
||||
}
|
||||
|
||||
it.each(['failure', 'timeout'])(
|
||||
'fails the gate when dependency path resolution has a %s',
|
||||
(mode) => {
|
||||
const fixture = gateFixture()
|
||||
const { root, write, check } = fixture
|
||||
try {
|
||||
configureGateVitest(fixture)
|
||||
write('value.mjs', 'export const value = 2\n')
|
||||
write(
|
||||
'config/vitest.config.ts',
|
||||
mode === 'failure'
|
||||
? `throw new Error('configuration resolution failed'); export default {}\n`
|
||||
: `export default async () => { await new Promise(() => {}); return {} }\n`
|
||||
)
|
||||
const result = check(['--scope=worktree', '--stepTimeoutMs=1000'], {
|
||||
EXPECTED_INVENTORY: 'ci'
|
||||
})
|
||||
expect(result.code).toBe(1)
|
||||
expect(result.stderr).toContain('✗ dependency evidence')
|
||||
expect(result.stderr).toContain(
|
||||
mode === 'failure' ? 'configuration resolution failed' : 'Timed out after 1s'
|
||||
)
|
||||
expect(result.stdout).not.toContain('▶ unit tests')
|
||||
expect(readPassHash(root, 'worktree')).toBeNull()
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
30_000
|
||||
)
|
||||
|
||||
it('invalidates a cached gate when an unchanged computed consumer records a failure', () => {
|
||||
const { root, check, vitest } = computedConsumerFixture()
|
||||
const args = ['--scope=worktree']
|
||||
const env = { EXPECTED_INVENTORY: 'ci' }
|
||||
try {
|
||||
const initial = check(args, env)
|
||||
expect(initial.code, initial.stderr).toBe(0)
|
||||
expect(initial.stdout).toContain('1 of 104 test files')
|
||||
// The gate's own successful dependency update must be cacheable immediately.
|
||||
const cached = check(args, env)
|
||||
expect(cached.code, cached.stderr).toBe(0)
|
||||
expect(cached.stdout).toContain('nothing changed since last passing run')
|
||||
expect(vitest('run', 'tests/tools/dynamic.test.mjs').code).toBe(1)
|
||||
expect(readPassHash(root, 'worktree')).toBeNull()
|
||||
const retry = check(args, env)
|
||||
expect(retry.code).toBe(1)
|
||||
expect(retry.stdout).not.toContain('nothing changed since last passing run')
|
||||
expect(retry.stderr).toContain('computed consumer')
|
||||
expect(readPassHash(root, 'worktree')).toBeNull()
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('rejects failed dependency evidence arriving after unit tests while another check is active', async () => {
|
||||
const { root, write, check, startCheck, vitest } = computedConsumerFixture()
|
||||
const args = ['--scope=worktree']
|
||||
const env = { EXPECTED_INVENTORY: 'ci' }
|
||||
let pending
|
||||
try {
|
||||
pending = startCheck(args, { ...env, ORCA_GATE_WAIT: '1' })
|
||||
// With one worker, quality starts only after the selected static unit suite passes.
|
||||
await vi.waitFor(() => expect(existsSync(join(root, '.cache/waiting'))).toBe(true), {
|
||||
timeout: 10_000
|
||||
})
|
||||
expect(vitest('run', 'tests/tools/dynamic.test.mjs').code).toBe(1)
|
||||
write('.cache/release', '')
|
||||
const result = await pending
|
||||
expect(result.code).toBe(1)
|
||||
expect(result.stderr).toContain('new incomplete dependency evidence')
|
||||
expect(readPassHash(root, 'worktree')).toBeNull()
|
||||
const retry = check(args, env)
|
||||
expect(retry.code).toBe(1)
|
||||
expect(retry.stderr).toContain('computed consumer')
|
||||
} finally {
|
||||
if (pending) {
|
||||
write('.cache/release', '')
|
||||
await pending
|
||||
}
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 60_000)
|
||||
@@ -1,263 +0,0 @@
|
||||
import { runProcessSync } from '@orca/process-host'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
it('uses recorded imports in both runtimes and retains cold, new, and global-input tests', () => {
|
||||
const root = process.cwd()
|
||||
mkdirSync(join(root, '.tmp'), { recursive: true })
|
||||
const directory = mkdtempSync(join(root, '.tmp', 'vitest-affected-'))
|
||||
const config = join(directory, 'vitest.config.mjs')
|
||||
const report = join(directory, 'report.json')
|
||||
const transformed = join(directory, 'transformed.jsonl')
|
||||
const input = join(directory, 'value.ts')
|
||||
const runtimes = ['bun', 'node-runtime']
|
||||
const localPath = (file) => relative(root, file).replaceAll('\\', '/')
|
||||
function writeTest(runtime, name, imported = false) {
|
||||
const file = join(directory, runtime, `${name}.test.ts`)
|
||||
writeFileSync(
|
||||
file,
|
||||
`
|
||||
import { expect, it } from 'vitest'
|
||||
${imported ? "import { value } from './value'" : 'const value = 1'}
|
||||
it('runs under ${runtime}', () => {
|
||||
expect(value).toBe(1)
|
||||
expect(Boolean(process.versions.bun)).toBe(${runtime === 'bun'})
|
||||
})
|
||||
`
|
||||
)
|
||||
return file
|
||||
}
|
||||
try {
|
||||
writeFileSync(input, 'export const value = 1')
|
||||
const affected = []
|
||||
const unrelated = []
|
||||
const runtimeSources = []
|
||||
for (const runtime of runtimes) {
|
||||
mkdirSync(join(directory, runtime))
|
||||
const source = join(directory, runtime, 'value.ts')
|
||||
writeFileSync(source, "export { value } from '../value'")
|
||||
runtimeSources.push(source)
|
||||
affected.push(writeTest(runtime, 'affected', true))
|
||||
unrelated.push(writeTest(runtime, 'unrelated'))
|
||||
}
|
||||
writeFileSync(
|
||||
config,
|
||||
`
|
||||
import base from ${JSON.stringify(pathToFileURL(resolve('config/vitest.config.ts')).href)}
|
||||
import { appendFileSync } from 'node:fs'
|
||||
export default {
|
||||
...base,
|
||||
cacheDir: ${JSON.stringify(join(directory, 'cache'))},
|
||||
define: {
|
||||
...base.define,
|
||||
ORCA_AFFECTED_CONTEXT: JSON.stringify(process.env.ORCA_AFFECTED_CONTEXT ?? 'good')
|
||||
},
|
||||
test: {
|
||||
...base.test,
|
||||
maxWorkers: 1,
|
||||
projects: base.test.projects
|
||||
.filter(project => ${JSON.stringify(runtimes)}.includes(project.test.name))
|
||||
.map(project => ({
|
||||
...project,
|
||||
plugins: [...project.plugins, {
|
||||
name: 'observe-affected-test-transforms',
|
||||
transform(code, id) {
|
||||
const file = id.split('?')[0].replaceAll('\\\\', '/')
|
||||
if (file.startsWith(${JSON.stringify(`${directory.replaceAll('\\', '/')}/`)})) {
|
||||
appendFileSync(${JSON.stringify(transformed)}, JSON.stringify({
|
||||
runtime: project.test.name,
|
||||
file
|
||||
}) + '\\n')
|
||||
}
|
||||
}
|
||||
}],
|
||||
define: {
|
||||
...project.define,
|
||||
ORCA_AFFECTED_CONTEXT: JSON.stringify(process.env.ORCA_AFFECTED_CONTEXT ?? 'good')
|
||||
},
|
||||
test: {
|
||||
...project.test,
|
||||
include: [${JSON.stringify(localPath(directory))} + '/' + project.test.name + '/*.test.ts'],
|
||||
exclude: []
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
function run(changed, { args, env, code = 0 } = {}) {
|
||||
writeFileSync(transformed, '')
|
||||
const result = runProcessSync({
|
||||
program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath,
|
||||
args: [
|
||||
resolve('config/scripts/run-vitest.mjs'),
|
||||
...(args ?? ['related', '--run', localPath(changed)]),
|
||||
'--config',
|
||||
config,
|
||||
'--reporter=json',
|
||||
'--outputFile',
|
||||
report
|
||||
],
|
||||
cwd: root,
|
||||
env: { ...process.env, CI: '', ORCA_BACKGROUND_LAUNCH: '1', ...env },
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
expect(result.timedOut, result.stderr).toBe(false)
|
||||
expect(result.code, `${result.stdout}\n${result.stderr}`).toBe(code)
|
||||
const output = JSON.parse(readFileSync(report, 'utf8'))
|
||||
expect(output.success).toBe(code === 0)
|
||||
return output.testResults.map(({ name }) => name.replaceAll('\\', '/')).sort()
|
||||
}
|
||||
const normalize = (files) => files.map((file) => file.replaceAll('\\', '/')).sort()
|
||||
const readTransforms = () =>
|
||||
readFileSync(transformed, 'utf8')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line))
|
||||
function expectKnownTransforms() {
|
||||
const observed = readTransforms()
|
||||
for (const [index, runtime] of runtimes.entries()) {
|
||||
expect(observed).toEqual(
|
||||
expect.arrayContaining(
|
||||
normalize([affected[index], runtimeSources[index]]).map((file) => ({ runtime, file }))
|
||||
)
|
||||
)
|
||||
}
|
||||
expect(observed.map(({ file }) => file)).toContain(normalize([input])[0])
|
||||
return observed
|
||||
}
|
||||
expect(run(input)).toEqual(normalize([...affected, ...unrelated]))
|
||||
expectKnownTransforms()
|
||||
expect(run(input)).toEqual(normalize(affected))
|
||||
// Force observable work in both warm projects while unrelated records stay reusable.
|
||||
for (const file of [...affected, ...runtimeSources, input]) {
|
||||
writeFileSync(file, `${readFileSync(file, 'utf8')}\n`)
|
||||
}
|
||||
expect(run(input)).toEqual(normalize(affected))
|
||||
const transforms = expectKnownTransforms().map(({ file }) => file)
|
||||
for (const file of unrelated) {
|
||||
expect(transforms).not.toContain(file.replaceAll('\\', '/'))
|
||||
}
|
||||
const fresh = writeTest(runtimes[0], 'new')
|
||||
expect(run(input)).toEqual(normalize([...affected, fresh]))
|
||||
expect(run(join(root, 'pnpm-lock.yaml'))).toEqual(normalize([...affected, ...unrelated, fresh]))
|
||||
const coldAffected = writeTest(runtimes[0], 'cold-affected', true)
|
||||
const coldUnrelated = []
|
||||
for (let index = 0; index < 101; index += 1) {
|
||||
const runtime = runtimes[index % runtimes.length]
|
||||
coldUnrelated.push({
|
||||
runtime,
|
||||
file: writeTest(runtime, `cold-unrelated-${index}`).replaceAll('\\', '/')
|
||||
})
|
||||
}
|
||||
expect(run(input)).toEqual(normalize([...affected, coldAffected]))
|
||||
const coldTransforms = readTransforms()
|
||||
expect(coldTransforms).toEqual(expect.arrayContaining(coldUnrelated))
|
||||
expect(coldTransforms).toContainEqual({
|
||||
runtime: runtimes[0],
|
||||
file: coldAffected.replaceAll('\\', '/')
|
||||
})
|
||||
|
||||
const knownArgs = ['run', ...affected.map(localPath)]
|
||||
for (const file of affected) {
|
||||
writeFileSync(
|
||||
file,
|
||||
`
|
||||
import { expect, it } from 'vitest'
|
||||
it('loads a computed dependency', async () => {
|
||||
if (process.env.ORCA_AFFECTED_EARLY_FAIL === '1') throw Error('early failure')
|
||||
const source = '../value'
|
||||
expect((await import(source)).value).toBe(1)
|
||||
})
|
||||
`
|
||||
)
|
||||
}
|
||||
expect(run(input, { args: knownArgs })).toEqual(expect.arrayContaining(normalize(affected)))
|
||||
expect(
|
||||
run(input, { args: knownArgs, env: { ORCA_AFFECTED_EARLY_FAIL: '1' }, code: 1 })
|
||||
).toEqual(expect.arrayContaining(normalize(affected)))
|
||||
writeFileSync(input, 'export const value = 2')
|
||||
// Incomplete specs must run even beside >100 cold files and without a static import edge.
|
||||
expect(run(input, { code: 1 })).toEqual(expect.arrayContaining(normalize(affected)))
|
||||
expect(run(join(directory, 'unrelated.ts'), { code: 1 })).toEqual(
|
||||
expect.arrayContaining(normalize(affected))
|
||||
)
|
||||
writeFileSync(input, 'export const value = 1')
|
||||
run(input, { args: knownArgs })
|
||||
expect(run(input, { args: [...knownArgs, '--testNamePattern=absent'] })).toEqual(
|
||||
expect.arrayContaining(normalize(affected))
|
||||
)
|
||||
writeFileSync(input, 'export const value = 2')
|
||||
expect(run(input, { code: 1 })).toEqual(expect.arrayContaining(normalize(affected)))
|
||||
writeFileSync(input, 'export const value = 1')
|
||||
run(input, { args: knownArgs })
|
||||
|
||||
const importer = join(directory, 'importer.ts')
|
||||
writeFileSync(importer, 'export const value = 1')
|
||||
function importerGit(...args) {
|
||||
const result = runProcessSync({ program: 'git', args, cwd: directory })
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
}
|
||||
importerGit('init', '-q')
|
||||
importerGit('config', 'user.email', 'test@example.com')
|
||||
importerGit('config', 'user.name', 'Test')
|
||||
importerGit('add', 'importer.ts')
|
||||
importerGit('commit', '-qm', 'initial importer')
|
||||
for (const file of affected) {
|
||||
writeFileSync(
|
||||
file,
|
||||
`
|
||||
import { expect, it } from 'vitest'
|
||||
import { value } from '../importer'
|
||||
it('uses an importer', () => expect(value).toBe(1))
|
||||
`
|
||||
)
|
||||
}
|
||||
run(input, { args: knownArgs })
|
||||
writeFileSync(importer, "export { value } from './value'")
|
||||
importerGit('add', 'importer.ts')
|
||||
importerGit('commit', '-qm', 'importer now depends on value')
|
||||
writeFileSync(input, 'export const value = 2')
|
||||
expect(run(input, { code: 1 })).toEqual(expect.arrayContaining(normalize(affected)))
|
||||
writeFileSync(input, 'export const value = 1')
|
||||
run(input, { args: knownArgs })
|
||||
|
||||
for (const file of affected) {
|
||||
writeFileSync(
|
||||
file,
|
||||
`
|
||||
import { expect, it } from 'vitest'
|
||||
it('uses the resolved config context', () => expect(ORCA_AFFECTED_CONTEXT).toBe('good'))
|
||||
`
|
||||
)
|
||||
}
|
||||
run(input, { args: knownArgs })
|
||||
expect(run(input, { env: { ORCA_AFFECTED_CONTEXT: 'bad' }, code: 1 })).toEqual(
|
||||
expect.arrayContaining(normalize(affected))
|
||||
)
|
||||
|
||||
for (const [index, file] of affected.entries()) {
|
||||
const runtimeImporter = join(directory, `mutated-importer-${index}.ts`)
|
||||
writeFileSync(runtimeImporter, 'export const value = 1')
|
||||
writeFileSync(
|
||||
file,
|
||||
`
|
||||
import { expect, it } from 'vitest'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { value } from '../mutated-importer-${index}'
|
||||
it('edits an importer while its old graph is executing', () => {
|
||||
expect(value).toBe(1)
|
||||
writeFileSync(${JSON.stringify(runtimeImporter)}, "export { value } from './value'")
|
||||
})
|
||||
`
|
||||
)
|
||||
}
|
||||
run(input, { args: knownArgs })
|
||||
writeFileSync(input, 'export const value = 2')
|
||||
expect(run(input, { code: 1 })).toEqual(expect.arrayContaining(normalize(affected)))
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true, maxRetries: 3 })
|
||||
}
|
||||
}, 180_000)
|
||||
@@ -16,7 +16,6 @@ const nodeRuntimeInclude = process.versions.bun
|
||||
? getNodeRuntimeInclude({ filters: readRunFilters() })
|
||||
: []
|
||||
const balancedShards = process.env.ORCA_BALANCE_UNIT_SHARDS === '1'
|
||||
const ciUnitInventory = balancedShards || process.env.ORCA_UNIT_INVENTORY === 'ci'
|
||||
const moduleCachePath = process.env.ORCA_UNIT_MODULE_CACHE_PATH
|
||||
const measurementFile = 'src/main/foreign-sqlite-readers/foreign-sqlite-reader-event-loop.test.ts'
|
||||
const paletteMeasurementFile =
|
||||
@@ -96,7 +95,7 @@ const testOptions = {
|
||||
execArgv: ['--no-experimental-webstorage', '--expose-gc'],
|
||||
setupFiles: UNIT_SETUP_FILES.map((file) => resolve(file)),
|
||||
include: UNIT_INCLUDE,
|
||||
exclude: ciUnitInventory ? UNIT_EXCLUDE : defaultExclude,
|
||||
exclude: balancedShards ? UNIT_EXCLUDE : defaultExclude,
|
||||
hookTimeout: 60_000,
|
||||
testTimeout: 30_000
|
||||
}
|
||||
@@ -161,7 +160,6 @@ export default defineConfig({
|
||||
...transforms,
|
||||
test: {
|
||||
...testOptions,
|
||||
experimental: { recordDependencies: !process.env.CI },
|
||||
forceRerunTriggers: [
|
||||
...configDefaults.forceRerunTriggers,
|
||||
'**/pnpm-lock.yaml',
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"audit:react-doctor": "node config/scripts/run-audit-tool.mjs react-doctor . --yes --no-supply-chain --no-telemetry --blocking none",
|
||||
"audit:dead-code": "node config/scripts/run-audit-tool.mjs knip --config config/knip.json",
|
||||
"check:changed": "node config/scripts/check-changed.mjs",
|
||||
"check:iteration": "node config/scripts/check-changed.mjs --scope=worktree",
|
||||
"check:code-quality:changed": "node config/scripts/check-changed-code-quality.mjs",
|
||||
"lint:js-plugins": "oxlint --config config/oxlint-js-plugins.json src config tests mobile native .github",
|
||||
"lint:ast-grep": "ast-grep scan --config config/sgconfig.yml --min-severity warning src config tests mobile",
|
||||
|
||||
Generated
+8
-8
@@ -187,7 +187,7 @@ patchedDependencies:
|
||||
node-pty@1.1.0: 3072026a166d508c622ee025bfceb011ce97e2133efc93bd014e9374f3febf43
|
||||
oxlint-plugin-anti-slop@0.1.2: c10c871702365a0c2c59e9f1c5fb92df97cae5755d534c0a3d83c6368f6feafe
|
||||
sprintf-js@1.1.3: 05316a8b01f155fe19a1e0cb4042c81d43d798a76755c407684e1199a18b1e08
|
||||
vitest@5.0.5: fc4a4e3051497ebacbc5962ad177ceab8f94df4db6466234cb4f004552a042d1
|
||||
vitest@5.0.5: a1a4e3c1fe4348649acf21d13315b98c967d257c6e349a6047aee7befd8d97db
|
||||
ws@8.22.0: 9393e04dd9a90b01eb7aa35fcd5b1431b5038ce999fbbd82651136193ba51f14
|
||||
|
||||
importers:
|
||||
@@ -326,7 +326,7 @@ importers:
|
||||
version: 3.14.13(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)
|
||||
'@testing-library/jest-dom':
|
||||
specifier: ^6.9.1
|
||||
version: 6.9.1(vitest@5.0.5(patch_hash=fc4a4e3051497ebacbc5962ad177ceab8f94df4db6466234cb4f004552a042d1)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))
|
||||
version: 6.9.1(vitest@5.0.5(patch_hash=a1a4e3c1fe4348649acf21d13315b98c967d257c6e349a6047aee7befd8d97db)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))
|
||||
'@testing-library/react':
|
||||
specifier: ^16.3.3
|
||||
version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)
|
||||
@@ -626,7 +626,7 @@ importers:
|
||||
version: 8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)
|
||||
vitest:
|
||||
specifier: 5.0.5
|
||||
version: 5.0.5(patch_hash=fc4a4e3051497ebacbc5962ad177ceab8f94df4db6466234cb4f004552a042d1)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
|
||||
version: 5.0.5(patch_hash=a1a4e3c1fe4348649acf21d13315b98c967d257c6e349a6047aee7befd8d97db)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
|
||||
vscode-oniguruma:
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
@@ -687,7 +687,7 @@ importers:
|
||||
version: 7.0.2
|
||||
vitest:
|
||||
specifier: 5.0.5
|
||||
version: 5.0.5(patch_hash=fc4a4e3051497ebacbc5962ad177ceab8f94df4db6466234cb4f004552a042d1)(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(happy-dom@21.0.4)(msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2))(vite@8.3.5(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
|
||||
version: 5.0.5(patch_hash=a1a4e3c1fe4348649acf21d13315b98c967d257c6e349a6047aee7befd8d97db)(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(happy-dom@21.0.4)(msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2))(vite@8.3.5(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -10453,7 +10453,7 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
pretty-format: 27.5.1
|
||||
|
||||
'@testing-library/jest-dom@6.9.1(vitest@5.0.5(patch_hash=fc4a4e3051497ebacbc5962ad177ceab8f94df4db6466234cb4f004552a042d1)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))':
|
||||
'@testing-library/jest-dom@6.9.1(vitest@5.0.5(patch_hash=a1a4e3c1fe4348649acf21d13315b98c967d257c6e349a6047aee7befd8d97db)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))':
|
||||
dependencies:
|
||||
'@adobe/css-tools': 4.5.0
|
||||
'@types/aria-query': 5.0.4
|
||||
@@ -10463,7 +10463,7 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
redent: 3.0.0
|
||||
optionalDependencies:
|
||||
vitest: 5.0.5(patch_hash=fc4a4e3051497ebacbc5962ad177ceab8f94df4db6466234cb4f004552a042d1)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
|
||||
vitest: 5.0.5(patch_hash=a1a4e3c1fe4348649acf21d13315b98c967d257c6e349a6047aee7befd8d97db)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
|
||||
|
||||
'@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
@@ -15184,7 +15184,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@types/picomatch'
|
||||
|
||||
vitest@5.0.5(patch_hash=fc4a4e3051497ebacbc5962ad177ceab8f94df4db6466234cb4f004552a042d1)(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(happy-dom@21.0.4)(msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2))(vite@8.3.5(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)):
|
||||
vitest@5.0.5(patch_hash=a1a4e3c1fe4348649acf21d13315b98c967d257c6e349a6047aee7befd8d97db)(@opentelemetry/api@1.9.1)(@types/node@18.19.130)(happy-dom@21.0.4)(msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2))(vite@8.3.5(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.0.5(msw@2.14.3(@types/node@18.19.130)(typescript@7.0.2))(vite@8.3.5(@types/node@18.19.130)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
|
||||
@@ -15209,7 +15209,7 @@ snapshots:
|
||||
- '@types/picomatch'
|
||||
- msw
|
||||
|
||||
vitest@5.0.5(patch_hash=fc4a4e3051497ebacbc5962ad177ceab8f94df4db6466234cb4f004552a042d1)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)):
|
||||
vitest@5.0.5(patch_hash=a1a4e3c1fe4348649acf21d13315b98c967d257c6e349a6047aee7befd8d97db)(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(happy-dom@21.0.4)(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.0.5(msw@2.14.3(@types/node@24.19.2)(typescript@7.0.2))(vite@8.3.5(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
|
||||
|
||||
@@ -38,8 +38,6 @@ export type ProcessSpec = {
|
||||
terminationBarrier?: boolean | ProcessTerminationBarrier
|
||||
/** Called once when the child exits or tree termination is verified. */
|
||||
onChildTerminated?: () => void
|
||||
/** Every raw stdout chunk as it arrives, including any past the capture cap. */
|
||||
onStdout?: (chunk: Buffer | string) => void
|
||||
/** Every raw stderr chunk as it arrives, including any past the capture cap. */
|
||||
onStderr?: (chunk: Buffer | string) => void
|
||||
}
|
||||
@@ -67,8 +65,6 @@ export type ProcessResult = {
|
||||
stderrBytes?: Buffer
|
||||
timedOut: boolean
|
||||
outputTruncated?: boolean
|
||||
/** Shutdown settled without tree proof or output EOF; escaped descendants may remain. */
|
||||
terminationUnverified?: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_PROCESS_TIMEOUT_MS = 30_000
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { spawnSync as nodeSpawnSync } from 'node:child_process'
|
||||
import { resolveSpawn, type ResolvedSpawn } from './spawn-resolution'
|
||||
import { hasSpawnObserver, notifySpawnObserver } from './spawn-observer'
|
||||
import { DEFAULT_PROCESS_TIMEOUT_MS, DEFAULT_MAX_OUTPUT_BYTES } from './process-spec'
|
||||
import type { ProcessSpec, ProcessResult } from './process-spec'
|
||||
|
||||
/** Prefer runProcess; reserve blocking execution for synchronous entry points and teardown. */
|
||||
export function runProcessSync(spec: ProcessSpec): ProcessResult {
|
||||
const resolved = resolveSpawn(spec, process.platform)
|
||||
// spawnSync blocks until exit; report that duration even if it throws.
|
||||
const spawnStartedAt = hasSpawnObserver() ? performance.now() : null
|
||||
try {
|
||||
return runResolvedProcessSync(spec, resolved)
|
||||
} finally {
|
||||
if (spawnStartedAt !== null) {
|
||||
notifySpawnObserver(resolved.file, resolved.args, performance.now() - spawnStartedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runResolvedProcessSync(spec: ProcessSpec, resolved: ResolvedSpawn): ProcessResult {
|
||||
const result = nodeSpawnSync(resolved.file, [...resolved.args], {
|
||||
...resolved.options,
|
||||
input: spec.input,
|
||||
timeout: spec.timeoutMs === null ? undefined : (spec.timeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS),
|
||||
maxBuffer: spec.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
|
||||
// Keep raw output without applying the nonexistent 'buffer' encoding to string stdin.
|
||||
encoding: null
|
||||
})
|
||||
const timedOut = result.error
|
||||
? 'code' in result.error && result.error.code === 'ETIMEDOUT'
|
||||
: false
|
||||
if (result.error && !timedOut) {
|
||||
throw result.error
|
||||
}
|
||||
return {
|
||||
code: result.status,
|
||||
signal: result.signal,
|
||||
stdout: spec.captureStdoutAsBytes ? '' : (result.stdout?.toString('utf8') ?? ''),
|
||||
...(spec.captureStdoutAsBytes ? { stdoutBytes: result.stdout ?? Buffer.alloc(0) } : {}),
|
||||
stderr: spec.captureStderrAsBytes ? '' : (result.stderr?.toString('utf8') ?? ''),
|
||||
...(spec.captureStderrAsBytes ? { stderrBytes: result.stderr ?? Buffer.alloc(0) } : {}),
|
||||
// ENOBUFS is rethrown above, so truncated output never reaches this result.
|
||||
outputTruncated: false,
|
||||
// SIGTERM can be deliberate; only ETIMEDOUT proves a timeout.
|
||||
timedOut
|
||||
}
|
||||
}
|
||||
@@ -134,17 +134,7 @@ describe('runProcess termination failure', () => {
|
||||
expect(onChildTerminated).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
code: null,
|
||||
timedOut: true,
|
||||
terminationUnverified: true
|
||||
})
|
||||
expect(child.stdin.destroyed).toBe(true)
|
||||
expect(child.stdout.destroyed).toBe(true)
|
||||
expect(child.stderr.destroyed).toBe(true)
|
||||
expect(child.stdout.listenerCount('data')).toBe(0)
|
||||
expect(child.stderr.listenerCount('data')).toBe(0)
|
||||
expect(child.unref).toHaveBeenCalledOnce()
|
||||
await expect(pending).resolves.toMatchObject({ code: null, timedOut: true })
|
||||
expect(onChildTerminated).not.toHaveBeenCalled()
|
||||
child.emit('close', null, 'SIGKILL')
|
||||
expect(onChildTerminated).toHaveBeenCalledOnce()
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { spawn as nodeSpawn, type ChildProcess } from 'node:child_process'
|
||||
import { resolveSpawn } from './spawn-resolution'
|
||||
import {
|
||||
spawn as nodeSpawn,
|
||||
spawnSync as nodeSpawnSync,
|
||||
type ChildProcess
|
||||
} from 'node:child_process'
|
||||
import { resolveSpawn, type ResolvedSpawn } from './spawn-resolution'
|
||||
import { forceTerminateProcessTree, signalProcessTree } from './process-tree-termination'
|
||||
import { inFlightWindowsTreeKill } from './windows-tree-kill'
|
||||
|
||||
import { hasSpawnObserver, notifySpawnObserver } from './spawn-observer'
|
||||
import { createOutputSink } from './bounded-output-sink'
|
||||
import { createChildTerminationReporter } from './child-termination-reporter'
|
||||
import { runProcessSync as runSynchronousProcess } from './run-process-sync'
|
||||
|
||||
// Keep the CommonJS export writable for process instrumentation.
|
||||
export const runProcessSync = runSynchronousProcess
|
||||
export { WindowsCmdLineBreakError } from './windows-command-line'
|
||||
|
||||
import type {
|
||||
@@ -64,8 +65,6 @@ export function runProcess(
|
||||
const stderr = createOutputSink(maxOutputBytes, outputCapture)
|
||||
let timedOut = false
|
||||
let settled = false
|
||||
let stopping = false
|
||||
let terminationUnverified = false
|
||||
let barrierStopping = false
|
||||
let barrierAttemptComplete = false
|
||||
let barrierTerminationVerified = false
|
||||
@@ -84,30 +83,16 @@ export function runProcess(
|
||||
clearTimeout(graceTimer)
|
||||
clearTimeout(barrierDeadlineTimer)
|
||||
spec.signal?.removeEventListener('abort', onAbort)
|
||||
const openOutput = [child.stdout, child.stderr].some(
|
||||
(stream) => stream && !stream.readableEnded && !stream.destroyed
|
||||
)
|
||||
terminationUnverified = Boolean(
|
||||
stopping && (openOutput || (spec.terminationBarrier && !barrierTerminationVerified))
|
||||
)
|
||||
child.stdout?.off('data', onStdout)
|
||||
child.stderr?.off('data', onStderr)
|
||||
for (const stream of [child.stdin, child.stdout, child.stderr]) {
|
||||
stream?.destroy()
|
||||
}
|
||||
// A fallback releases our wait, not proof that the child or escaped descendants exited.
|
||||
child.unref()
|
||||
act()
|
||||
}
|
||||
|
||||
const onStdout = (chunk: Buffer | string): void => {
|
||||
child.stdout?.on('data', (chunk: Buffer | string) => {
|
||||
stdout.write(chunk)
|
||||
spec.onStdout?.(chunk)
|
||||
if (spec.killOnOutputLimit && stdout.truncated()) {
|
||||
stopAndSettle()
|
||||
}
|
||||
}
|
||||
const onStderr = (chunk: Buffer | string): void => {
|
||||
})
|
||||
child.stderr?.on('data', (chunk: Buffer | string) => {
|
||||
stderr.write(chunk)
|
||||
spec.onStderr?.(chunk)
|
||||
if (spec.killOnOutputLimit && stderr.truncated()) {
|
||||
@@ -116,9 +101,7 @@ export function runProcess(
|
||||
if (typeof spec.terminationBarrier === 'object') {
|
||||
spec.terminationBarrier.observeStderr?.(chunk)
|
||||
}
|
||||
}
|
||||
child.stdout?.on('data', onStdout)
|
||||
child.stderr?.on('data', onStderr)
|
||||
})
|
||||
// Stream errors (including stdin EPIPE) are otherwise uncaught; the exit still reaches callers.
|
||||
for (const stream of [child.stdin, child.stdout, child.stderr]) {
|
||||
stream?.on('error', () => {})
|
||||
@@ -147,7 +130,6 @@ export function runProcess(
|
||||
stderr: spec.captureStderrAsBytes ? '' : stderr.text(),
|
||||
...(spec.captureStderrAsBytes ? { stderrBytes: stderr.buffer() } : {}),
|
||||
timedOut,
|
||||
...(terminationUnverified ? { terminationUnverified: true } : {}),
|
||||
outputTruncated: stdout.truncated() || stderr.truncated()
|
||||
})
|
||||
)
|
||||
@@ -175,9 +157,6 @@ export function runProcess(
|
||||
}
|
||||
|
||||
const resolveBarrierIfSafe = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
const rootExit = deferredClose ?? deferredExit
|
||||
if (barrierTerminationVerified || (rootExitedBeforeBarrier && rootExit)) {
|
||||
settleBarrierOutcome()
|
||||
@@ -193,10 +172,6 @@ export function runProcess(
|
||||
|
||||
// Barrier callers wait for termination evidence, bounded by a final deadline.
|
||||
const stopAndSettle = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
stopping = true
|
||||
if (spec.terminationBarrier) {
|
||||
barrierStopping = true
|
||||
initialBarrierTermination ??= signalBarrierTree()
|
||||
@@ -321,3 +296,46 @@ function terminate(child: ChildProcess, signal?: NodeJS.Signals): void {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
/** Prefer runProcess; reserve blocking execution for synchronous entry points and teardown. */
|
||||
export function runProcessSync(spec: ProcessSpec): ProcessResult {
|
||||
const resolved = resolveSpawn(spec, process.platform)
|
||||
// spawnSync blocks until exit; report that duration even if it throws.
|
||||
const spawnStartedAt = hasSpawnObserver() ? performance.now() : null
|
||||
try {
|
||||
return runResolvedProcessSync(spec, resolved)
|
||||
} finally {
|
||||
if (spawnStartedAt !== null) {
|
||||
notifySpawnObserver(resolved.file, resolved.args, performance.now() - spawnStartedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runResolvedProcessSync(spec: ProcessSpec, resolved: ResolvedSpawn): ProcessResult {
|
||||
const result = nodeSpawnSync(resolved.file, [...resolved.args], {
|
||||
...resolved.options,
|
||||
input: spec.input,
|
||||
timeout: spec.timeoutMs === null ? undefined : (spec.timeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS),
|
||||
maxBuffer: spec.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES,
|
||||
// Keep raw output without applying the nonexistent 'buffer' encoding to string stdin.
|
||||
encoding: null
|
||||
})
|
||||
const timedOut = result.error
|
||||
? 'code' in result.error && result.error.code === 'ETIMEDOUT'
|
||||
: false
|
||||
if (result.error && !timedOut) {
|
||||
throw result.error
|
||||
}
|
||||
return {
|
||||
code: result.status,
|
||||
signal: result.signal,
|
||||
stdout: spec.captureStdoutAsBytes ? '' : (result.stdout?.toString('utf8') ?? ''),
|
||||
...(spec.captureStdoutAsBytes ? { stdoutBytes: result.stdout ?? Buffer.alloc(0) } : {}),
|
||||
stderr: spec.captureStderrAsBytes ? '' : (result.stderr?.toString('utf8') ?? ''),
|
||||
...(spec.captureStderrAsBytes ? { stderrBytes: result.stderr ?? Buffer.alloc(0) } : {}),
|
||||
// ENOBUFS is rethrown above, so truncated output never reaches this result.
|
||||
outputTruncated: false,
|
||||
// SIGTERM can be deliberate; only ETIMEDOUT proves a timeout.
|
||||
timedOut
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user