Package modules that other code imports must not suspend evaluation at module scope. A syntax-aware gate scans packages/*/*/src and excludes only executables (a shebang or a package bin target). The SenseVoice worker, the SSH helper entry, and the Inspector worker entry now run an explicit main().
1372 lines
59 KiB
TypeScript
1372 lines
59 KiB
TypeScript
import { readFileSync } from 'node:fs'
|
|
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi, type MockInstance } from 'vitest'
|
|
import {
|
|
cliGateOptions,
|
|
ciWorkerEnvironment,
|
|
collectDescendants,
|
|
defaultConcurrency,
|
|
formatGateResultReason,
|
|
gatesForMode,
|
|
parsePidPpidLines,
|
|
runGate,
|
|
runGates,
|
|
taskkillArgs,
|
|
type Gate,
|
|
type GateResult,
|
|
} from './run-gates.ts'
|
|
|
|
// Graph fixtures select their own browser pool instead of inheriting the CI
|
|
// host's pool, and coverage gates take their shape from the values each case
|
|
// sets, not from the lane that runs this file (a partitioned lane exports
|
|
// DSH_COVERAGE_PARTITIONS to every child).
|
|
beforeEach(() => {
|
|
vi.stubEnv('DSH_WEB_SNAPSHOT_WORKERS', undefined)
|
|
vi.stubEnv('DSH_COVERAGE_MAX_WORKERS', undefined)
|
|
vi.stubEnv('DSH_COVERAGE_PARTITIONS', undefined)
|
|
})
|
|
afterEach(() => vi.unstubAllEnvs())
|
|
|
|
/**
|
|
* Capture output a gate streams through runGate's streamOutput path.
|
|
* @param onOutput - optional observer called after each captured chunk.
|
|
* @returns the accumulated chunks and the stdout spy to restore in finally.
|
|
*/
|
|
function captureStreamedOutput(onOutput?: (chunks: readonly string[]) => void): { writes: string[]; write: MockInstance } {
|
|
const writes: string[] = []
|
|
const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
|
writes.push(String(chunk))
|
|
onOutput?.(writes)
|
|
return true
|
|
})
|
|
return { writes, write }
|
|
}
|
|
|
|
/**
|
|
* A process has stopped executing when its /proc entry is gone, or when it
|
|
* lingers as a zombie ('Z') — an un-reaped but dead entry still answers
|
|
* kill(pid, 0), so existence is not a liveness check. Non-Linux falls back to
|
|
* kill(pid, 0), whose ESRCH means the process is gone.
|
|
*/
|
|
function procStopped(pid: number): boolean {
|
|
if (process.platform === 'linux') {
|
|
try {
|
|
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
|
|
return /\)\s+Z\s/.test(stat)
|
|
} catch {
|
|
return true
|
|
}
|
|
}
|
|
try {
|
|
process.kill(pid, 0)
|
|
return false
|
|
} catch {
|
|
return true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wait until the captured output contains `marker` and the grandchild pid the
|
|
* gate printed, then return that pid.
|
|
* @param writes - chunks captured from the gate's streamed stdout.
|
|
* @param marker - the output line that proves the gate reached the abort point.
|
|
* @param deadline - fail the wait when exceeded.
|
|
* @returns the grandchild pid printed by the gate script.
|
|
*/
|
|
async function waitForGrandchildPid(writes: string[], marker: string, deadline: number): Promise<number> {
|
|
let pid: number | undefined
|
|
while ((pid === undefined || !writes.join('').includes(marker)) && Date.now() < deadline) {
|
|
const match = writes.join('').match(/grandchild:(\d+)/)
|
|
if (match !== null) pid = Number(match[1])
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
}
|
|
expect(pid ?? 0).toBeGreaterThan(0)
|
|
expect(writes.join('')).toContain(marker)
|
|
return pid!
|
|
}
|
|
|
|
/**
|
|
* Abort the run and assert it settles marked aborted with the grandchild no
|
|
* longer executing — the abort path must have signalled it from the captured
|
|
* descendant list rather than settling over a live orphan.
|
|
* @param promise - the pending `runGate` promise.
|
|
* @param controller - the signal source to abort.
|
|
* @param pid - the grandchild pid the gate script printed.
|
|
*/
|
|
async function abortAndExpectTreeStopped(promise: Promise<GateResult>, controller: AbortController, pid: number): Promise<void> {
|
|
controller.abort()
|
|
const result = await promise
|
|
expect(result.aborted).toBe(true)
|
|
const stopDeadline = Date.now() + 8000
|
|
while (!procStopped(pid) && Date.now() < stopDeadline) {
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
}
|
|
expect(procStopped(pid)).toBe(true)
|
|
}
|
|
|
|
|
|
function gate(id: string, options: Partial<Gate> = {}): Gate {
|
|
return {
|
|
id,
|
|
label: id,
|
|
displayCommand: `run ${id}`,
|
|
command: process.execPath,
|
|
args: ['-e', ''],
|
|
...options,
|
|
}
|
|
}
|
|
|
|
function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): GateResult {
|
|
return {
|
|
gate: subject,
|
|
status,
|
|
durationMs: 10,
|
|
output: [],
|
|
exitCode: status === 'passed' ? 0 : 1,
|
|
signalCode: null,
|
|
}
|
|
}
|
|
|
|
function withPnpmEntrypoint<T>(action: () => T, entrypoint = '/private/pnpm.cjs'): T {
|
|
const previous = process.env.npm_execpath
|
|
process.env.npm_execpath = entrypoint
|
|
try {
|
|
return action()
|
|
} finally {
|
|
if (previous === undefined) Reflect.deleteProperty(process.env, 'npm_execpath')
|
|
else process.env.npm_execpath = previous
|
|
}
|
|
}
|
|
|
|
function withEnv<T>(name: string, value: string | undefined, action: () => T): T {
|
|
const previous = process.env[name]
|
|
if (value === undefined) Reflect.deleteProperty(process.env, name)
|
|
else process.env[name] = value
|
|
try {
|
|
return action()
|
|
} finally {
|
|
if (previous === undefined) Reflect.deleteProperty(process.env, name)
|
|
else process.env[name] = previous
|
|
}
|
|
}
|
|
|
|
// Mutates worker-global state: only use for synchronous, non-concurrent graph inspection.
|
|
function withPlatform<T>(platform: NodeJS.Platform, action: () => T): T {
|
|
const original = Object.getOwnPropertyDescriptor(process, 'platform')!
|
|
Object.defineProperty(process, 'platform', { ...original, value: platform })
|
|
try { return action() } finally { Object.defineProperty(process, 'platform', original) }
|
|
}
|
|
|
|
// Mutates worker-global state: only use for synchronous, non-concurrent graph inspection.
|
|
function withNodeVersion<T>(version: string, action: () => T): T {
|
|
const original = Object.getOwnPropertyDescriptor(process.versions, 'node')!
|
|
Object.defineProperty(process.versions, 'node', { ...original, value: version })
|
|
try { return action() } finally { Object.defineProperty(process.versions, 'node', original) }
|
|
}
|
|
|
|
describe('CI worker allocation', () => {
|
|
it.each([1, 2, 4, 8, 16, 64])('shares a %i CPU coverage budget without multiplying pools', (cpus) => {
|
|
const env = ciWorkerEnvironment('ci-coverage', { CI: 'true' }, cpus)
|
|
const exempt = Math.max(1, Math.floor(cpus / 3))
|
|
const instrumented = Number(env.DSH_COVERAGE_PARTITIONS ?? 1)
|
|
expect(Number(env.DSH_COVERAGE_MAX_WORKERS)).toBe(cpus)
|
|
expect(instrumented + exempt).toBe(Math.max(2, cpus))
|
|
expect(defaultConcurrency('ci-coverage', 3, cpus, { CI: 'true' }).workers).toBe(Math.min(3, cpus))
|
|
if (cpus <= 2) expect(env.DSH_COVERAGE_PARTITIONS).toBeUndefined()
|
|
})
|
|
|
|
it('bounds overlapping readers and lets the isolated browser pool use the runner', () => {
|
|
const env = ciWorkerEnvironment('ci-consumers', { CI: 'true' }, 16)
|
|
expect(env).toMatchObject({
|
|
DSH_OXLINT_THREADS: '8',
|
|
DSH_PUBLINT_CONCURRENCY: '8',
|
|
DSH_SNAPSHOT_MAX_WORKERS: '1',
|
|
DSH_SNAPSHOT_MAX_CONCURRENCY: '8',
|
|
DSH_WEB_SNAPSHOT_WORKERS: '16',
|
|
})
|
|
})
|
|
|
|
it('caps local defaults below the CPU count Node reports', () => {
|
|
expect(ciWorkerEnvironment('ci-coverage', {}, 256)).toMatchObject({
|
|
DSH_OXLINT_THREADS: '6',
|
|
DSH_COVERAGE_MAX_WORKERS: '48',
|
|
DSH_COVERAGE_PARTITIONS: '32',
|
|
DSH_WEB_SNAPSHOT_WORKERS: '12',
|
|
})
|
|
expect(defaultConcurrency('ci-static', 60, 256, {})).toEqual({
|
|
workers: 12,
|
|
source: '12 local default worker(s) of 256 CPU(s)',
|
|
})
|
|
expect(defaultConcurrency('ci-static', 60, 8, {}).workers).toBe(8)
|
|
})
|
|
|
|
it('preserves serial reference overrides on large hosts', () => {
|
|
const inherited = {
|
|
DSH_GATE_CONCURRENCY: '1',
|
|
DSH_COVERAGE_MAX_WORKERS: '1',
|
|
DSH_OXLINT_THREADS: '1',
|
|
DSH_PUBLINT_CONCURRENCY: '1',
|
|
DSH_SNAPSHOT_MAX_CONCURRENCY: '1',
|
|
DSH_WEB_SNAPSHOT_WORKERS: '1',
|
|
}
|
|
const additions = ciWorkerEnvironment('ci-primary', inherited, 64)
|
|
expect(additions).toEqual({ DSH_SNAPSHOT_MAX_WORKERS: '1' })
|
|
expect(inherited.DSH_COVERAGE_MAX_WORKERS).toBe('1')
|
|
})
|
|
|
|
it('honors an explicit coverage budget and partition override', () => {
|
|
expect(ciWorkerEnvironment('ci-coverage', { DSH_COVERAGE_MAX_WORKERS: '6' }, 16))
|
|
.toHaveProperty('DSH_COVERAGE_PARTITIONS', '4')
|
|
expect(ciWorkerEnvironment('ci-coverage', { DSH_COVERAGE_PARTITIONS: '3' }, 16))
|
|
.not.toHaveProperty('DSH_COVERAGE_PARTITIONS')
|
|
})
|
|
|
|
it('leaves the plain unit inventory with the environment pnpm run test finds', () => {
|
|
// A 3-CPU host would otherwise receive DSH_COVERAGE_PARTITIONS=2, which
|
|
// the inventory's own coverage-gate tests read as a partitioned lane.
|
|
expect(ciWorkerEnvironment('ci-unit', {}, 3)).toEqual({})
|
|
expect(ciWorkerEnvironment('ci-unit', {}, 16)).toEqual({})
|
|
})
|
|
|
|
it.each(['0', '-1', 'NaN', '2.5'])('rejects invalid worker budget %s', (raw) => {
|
|
expect(() => ciWorkerEnvironment('ci-coverage', { DSH_COVERAGE_MAX_WORKERS: raw }, 16))
|
|
.toThrow('DSH_COVERAGE_MAX_WORKERS must be a positive integer')
|
|
})
|
|
|
|
it('keeps local documentation defaults unchanged', () => {
|
|
expect(ciWorkerEnvironment('doc-sync', {}, 64)).toEqual({})
|
|
})
|
|
})
|
|
|
|
describe('gate graph validation', () => {
|
|
it.each([
|
|
'ci-primary',
|
|
'ci-linux-primary',
|
|
'ci-static',
|
|
'ci-lint-contracts-ready',
|
|
'ci-coverage',
|
|
'ci-unit',
|
|
'ci-bench',
|
|
'ci-snapshot',
|
|
'ci-artifacts',
|
|
'ci-consumers',
|
|
'ci-windows-blocking',
|
|
'ci-windows-complete',
|
|
'ci-windows-observational-ready',
|
|
'node-compat',
|
|
'check-all',
|
|
'hygiene',
|
|
'doc-sync',
|
|
'doc-quick',
|
|
] as const)('constructs and executes preflight for a valid non-empty %s graph', async (mode) => {
|
|
const subject = withPnpmEntrypoint(() => gatesForMode(mode))
|
|
const execute = vi.fn(async (item: Gate) => resultFor(item))
|
|
|
|
await expect(runGates(subject, subject.length, execute)).resolves.toHaveLength(subject.length)
|
|
})
|
|
|
|
it('uses the internal CI benchmark runner without changing the public benchmark scripts', () => {
|
|
const subject = withPnpmEntrypoint(() => gatesForMode('ci-bench'))
|
|
const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
|
|
scripts: Record<string, string>
|
|
}
|
|
|
|
expect(scripts['check:ci:bench']).toBe('tsx scripts/run-gates.ts ci-bench')
|
|
expect(subject).toHaveLength(1)
|
|
expect(subject[0]).toMatchObject({
|
|
id: 'bench',
|
|
displayCommand: 'pnpm exec tsx scripts/run-ci-bench.ts',
|
|
args: ['/private/pnpm.cjs', 'exec', 'tsx', 'scripts/run-ci-bench.ts'],
|
|
})
|
|
expect(scripts['test:bench']).toBe('npm run build:bench && npm run build:web && npm run test:bench:built')
|
|
expect(scripts['build:bench']).toBe(
|
|
'npm run build:native-system && npm run build:lib && tsdown --config-loader native --config benchmarks/tsdown.config.ts',
|
|
)
|
|
expect(scripts['build:native-system']).toBe('tsx native/system/scripts/build.ts --host-addon-only')
|
|
expect(scripts['test:bench:built']).toBe('vitest run --config vitest.bench.config.ts')
|
|
})
|
|
|
|
it('checks all maintained repository references locally and in CI', () => {
|
|
const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
|
|
scripts: Record<string, string>
|
|
}
|
|
for (const mode of ['doc-sync', 'doc-quick', 'ci-static'] as const) {
|
|
const gates = withPnpmEntrypoint(() => gatesForMode(mode))
|
|
expect(gates).toContainEqual(expect.objectContaining({
|
|
id: 'repository-references',
|
|
displayCommand: 'pnpm run verify-repository-references',
|
|
}))
|
|
}
|
|
expect(scripts['verify-repository-references']).toBe('tsx scripts/verify-repository-references.ts')
|
|
})
|
|
|
|
it('keeps the public repository link policy in the documentation gate', () => {
|
|
const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
|
|
|
|
expect(ids).toContain('public-repository-links')
|
|
})
|
|
|
|
it('checks the complete CLI reference locally and in the static CI lane', () => {
|
|
for (const mode of ['doc-sync', 'ci-static'] as const) {
|
|
const gates = withPnpmEntrypoint(() => gatesForMode(mode))
|
|
expect(gates.find(gate => gate.id === 'cli-help')).toBeDefined()
|
|
}
|
|
})
|
|
|
|
it('keeps the concrete terminology policy in the documentation gate', () => {
|
|
const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
|
|
|
|
expect(ids).toContain('concrete-terms')
|
|
})
|
|
|
|
it('checks retrospective releases alongside the current persistence history', () => {
|
|
for (const mode of ['doc-sync', 'ci-static'] as const) {
|
|
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
|
|
expect(ids).toEqual(expect.arrayContaining(['persistence-changes', 'persistence-releases']))
|
|
}
|
|
})
|
|
|
|
it('requires complete Session format references locally and in CI', () => {
|
|
const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
|
|
scripts: Record<string, string>
|
|
}
|
|
for (const mode of ['doc-sync', 'doc-quick', 'ci-static'] as const) {
|
|
expect(withPnpmEntrypoint(() => gatesForMode(mode))).toContainEqual(expect.objectContaining({
|
|
id: 'persistence-formats',
|
|
displayCommand: 'pnpm run verify-persistence-formats',
|
|
}))
|
|
}
|
|
expect(scripts['verify-persistence-formats']).toBe('tsx scripts/persistence-formats.ts')
|
|
})
|
|
|
|
it('keeps package-group subsystem ownership in the documentation gate', () => {
|
|
const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
|
|
|
|
expect(ids).toContain('subsystem-pages')
|
|
})
|
|
|
|
it('keeps the package README Summary limit in the documentation gate', () => {
|
|
const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
|
|
|
|
expect(ids).toContain('package-readme-summaries')
|
|
})
|
|
|
|
it('derives the quick documentation aggregate from marked doc-sync leaves', () => {
|
|
const full = withPnpmEntrypoint(() => gatesForMode('doc-sync'))
|
|
const quick = withPnpmEntrypoint(() => gatesForMode('doc-quick'))
|
|
|
|
expect(quick).toEqual(full.filter(gate => gate.quick === true))
|
|
})
|
|
|
|
it('checks the recorded npm dependency catalog in both documentation aggregates', () => {
|
|
for (const mode of ['doc-sync', 'doc-quick'] as const) {
|
|
expect(withPnpmEntrypoint(() => gatesForMode(mode).find(gate => gate.id === 'dependency-catalog')))
|
|
.toMatchObject({ args: ['/private/pnpm.cjs', 'run', 'verify-dependency-catalog'] })
|
|
}
|
|
})
|
|
|
|
it('keeps the hygiene aggregate aligned with the package script checks', () => {
|
|
const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id))
|
|
|
|
expect(ids).toEqual([
|
|
'rescope-vendor', 'publint', 'constraints', 'default-product-isolation', 'product-use', 'official-bundle-catalog', 'provider-data', 'package-dependencies', 'application-entrypoints',
|
|
'dsh-package-licenses', 'node-next-types',
|
|
'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'client-route-resolution', 'no-bare-dispatcher',
|
|
'plugin-record-callers', 'no-unknown-casts', 'no-top-level-await',
|
|
'cordis-config', 'runtime-closure',
|
|
])
|
|
})
|
|
|
|
it('caps the local hygiene aggregate at eight workers', () => {
|
|
const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id))
|
|
|
|
expect(defaultConcurrency('hygiene', ids.length, 16, { CI: 'true' })).toEqual({
|
|
workers: 8,
|
|
source: '16 available CPU(s), hygiene cap 8',
|
|
})
|
|
expect(defaultConcurrency('doc-sync', 43, 256, {})).toEqual({
|
|
workers: 8,
|
|
source: '12 local default worker(s) of 256 CPU(s), doc-sync cap 8',
|
|
})
|
|
})
|
|
|
|
it('schedules the longest documentation leaves before short checks', () => {
|
|
const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
|
|
|
|
expect(ids.slice(0, 11)).toEqual([
|
|
'doc-typecheck', 'docs-site-build', 'doc-graphs', 'cli-help', 'markdown-links', 'type-equivalence',
|
|
'cordis-catalog', 'cordis-inspect-catalog', 'workflow-guest', 'mermaid', 'translation-pairing',
|
|
])
|
|
})
|
|
|
|
it('launches a native pnpm entrypoint directly', () => {
|
|
const entrypoint = String.raw`C:\Program Files\pnpm\pnpm.exe`
|
|
const subject = withPnpmEntrypoint(() => gatesForMode('ci-windows-blocking')[0], entrypoint)
|
|
|
|
expect(subject).toMatchObject({
|
|
command: entrypoint,
|
|
args: ['run', 'build'],
|
|
})
|
|
})
|
|
|
|
it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
|
|
'keeps the DSH package license policy in %s',
|
|
(mode) => {
|
|
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
|
|
|
|
expect(ids).toContain('dsh-package-licenses')
|
|
},
|
|
)
|
|
|
|
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
|
|
'rejects new unknown casts in %s',
|
|
(mode) => {
|
|
const gate = withPnpmEntrypoint(() => gatesForMode(mode).find(subject => subject.id === 'no-unknown-casts'))
|
|
|
|
expect(gate?.args).toContain('verify-no-unknown-casts')
|
|
expect(gate?.allowFailure).not.toBe(true)
|
|
},
|
|
)
|
|
|
|
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
|
|
'keeps package dependency enforcement in %s',
|
|
(mode) => {
|
|
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
|
|
|
|
expect(ids).toContain('package-dependencies')
|
|
},
|
|
)
|
|
|
|
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
|
|
'executes default-product experimental isolation in %s',
|
|
(mode) => {
|
|
const gate = withPnpmEntrypoint(() => gatesForMode(mode)
|
|
.find(subject => subject.id === 'default-product-isolation'))
|
|
|
|
expect(gate?.args).toContain('verify-default-product-isolation')
|
|
expect(gate?.allowFailure).not.toBe(true)
|
|
},
|
|
)
|
|
|
|
it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
|
|
'keeps the client dependency policy in %s',
|
|
(mode) => {
|
|
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
|
|
|
|
expect(ids).toContain('client-packages')
|
|
},
|
|
)
|
|
|
|
it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
|
|
'keeps weighted approval policy tests in %s',
|
|
(mode) => {
|
|
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
|
|
|
|
expect(ids).toContain('approval-policy')
|
|
},
|
|
)
|
|
|
|
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
|
|
'keeps hard-coded Client UI copy enforcement in %s',
|
|
(mode) => {
|
|
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
|
|
|
|
expect(ids).toContain('client-ui-i18n')
|
|
},
|
|
)
|
|
|
|
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
|
|
'keeps browser app-route resolution enforcement in %s',
|
|
(mode) => {
|
|
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
|
|
|
|
expect(ids).toContain('client-route-resolution')
|
|
},
|
|
)
|
|
|
|
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
|
|
'keeps application entrypoint enforcement in %s',
|
|
(mode) => {
|
|
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
|
|
|
|
expect(ids).toContain('application-entrypoints')
|
|
},
|
|
)
|
|
|
|
it('keeps native Windows coverage blocking and behind the complete build', () => {
|
|
const complete = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
|
|
const observational = withPnpmEntrypoint(() => gatesForMode('ci-windows-observational-ready'))
|
|
.filter(gate => gate.id !== 'docs-site-build' && gate.id !== 'electron-install')
|
|
const byId = new Map(complete.map(subject => [subject.id, subject]))
|
|
|
|
expect(byId.get('coverage')?.allowFailure).not.toBe(true)
|
|
expect(byId.get('coverage')?.needs).toContain('build')
|
|
expect(byId.get('coverage-exempt-heavy')?.allowFailure).not.toBe(true)
|
|
expect(byId.get('coverage')?.needs).toContain('build')
|
|
expect(byId.get('coverage-exempt-heavy')?.needs).toContain('build')
|
|
expect(byId.get('coverage-exempt-heavy')?.args).toContain(
|
|
'packages/experimental/webworker-packer/tests/image-loadable.spec.ts',
|
|
)
|
|
expect(observational).not.toHaveLength(0)
|
|
for (const gate of observational) {
|
|
const completeGate = byId.get(gate.id)
|
|
expect(completeGate?.allowFailure).toBe(true)
|
|
expect(completeGate?.after).toEqual(expect.arrayContaining([
|
|
'coverage',
|
|
'coverage-exempt-heavy',
|
|
]))
|
|
expect((completeGate?.needs ?? []).filter(id => id !== 'build')).toEqual(gate.needs ?? [])
|
|
}
|
|
})
|
|
|
|
it('runs the Windows built-bin smoke after other observational gates settle', () => {
|
|
const observational = withPnpmEntrypoint(() => gatesForMode('ci-windows-observational-ready'))
|
|
const builtBin = observational.find(gate => gate.id === 'built-bin-smoke')
|
|
|
|
expect(builtBin?.after).toEqual(
|
|
observational.filter(gate => gate.id !== 'built-bin-smoke').map(gate => gate.id),
|
|
)
|
|
|
|
const completeBuiltBin = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
|
|
.find(gate => gate.id === 'built-bin-smoke')
|
|
expect(completeBuiltBin?.after).toContain('windows-site')
|
|
expect(completeBuiltBin?.after).not.toContain('docs-site-build')
|
|
})
|
|
|
|
it('reuses the Windows build without dropping diagnostics or rebuilding the workspace', () => {
|
|
const ready = withPnpmEntrypoint(() => gatesForMode('ci-windows-observational-ready'))
|
|
const complete = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
|
|
const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
|
|
scripts: Record<string, string>
|
|
}
|
|
|
|
expect(scripts['check:ci:windows-observational-ready']).toBe('tsx scripts/run-gates.ts ci-windows-observational-ready')
|
|
expect(scripts).not.toHaveProperty('check:ci:windows-observational')
|
|
const completeOnly = new Set(['build', 'windows-site', 'native-system', 'coverage', 'coverage-exempt-heavy'])
|
|
const shared = complete.filter(gate => !completeOnly.has(gate.id))
|
|
expect(ready.map(gate => gate.id).sort()).toEqual([...shared.map(gate => gate.id), 'docs-site-build'].sort())
|
|
expect(ready.some(gate => gate.id === 'build')).toBe(false)
|
|
expect(ready.find(gate => gate.id === 'docs-site-build')?.displayCommand).toBe('pnpm run docs:build:mpa')
|
|
for (const diagnostic of shared) {
|
|
expect(ready.find(gate => gate.id === diagnostic.id)).toMatchObject({
|
|
command: diagnostic.command,
|
|
args: diagnostic.args,
|
|
})
|
|
expect(ready.find(gate => gate.id === diagnostic.id)?.env).toEqual(diagnostic.env)
|
|
}
|
|
expect(complete.find(gate => gate.id === 'build')).toBeDefined()
|
|
expect(complete.find(gate => gate.id === 'coverage')).toBeDefined()
|
|
})
|
|
|
|
it('runs built Windows smoke after failed diagnostics settle on an existing build', async () => {
|
|
const ready = withPnpmEntrypoint(() => gatesForMode('ci-windows-observational-ready'))
|
|
const settled = new Set<string>()
|
|
const results = await runGates(ready, 8, async (subject) => {
|
|
if (subject.id === 'built-bin-smoke') {
|
|
expect(settled.size).toBe(ready.length - 1)
|
|
expect(settled.has('doc-graphs')).toBe(true)
|
|
}
|
|
settled.add(subject.id)
|
|
return resultFor(subject, subject.id === 'doc-graphs' ? 'failed' : 'passed')
|
|
})
|
|
|
|
expect(results.filter(result => result.status === 'failed').map(result => result.gate.id)).toEqual(['doc-graphs'])
|
|
expect(results.find(result => result.gate.id === 'built-bin-smoke')?.status).toBe('passed')
|
|
expect(results.some(result => result.status === 'skipped')).toBe(false)
|
|
})
|
|
|
|
it.each([undefined, '3'])('provisions Electron before Windows coverage with partition count %s', async (partitions) => {
|
|
const gates = withEnv('DSH_COVERAGE_PARTITIONS', partitions, () =>
|
|
withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))
|
|
const installers = gates.filter(gate => gate.id === 'electron-install')
|
|
expect(installers).toHaveLength(1)
|
|
expect(installers[0]?.allowFailure).not.toBe(true)
|
|
expect(installers[0]?.needs ?? []).not.toContain('build')
|
|
expect(gates.find(gate => gate.id === 'coverage')?.needs).toContain('electron-install')
|
|
let installed = false
|
|
await runGates(gates, 8, async (subject) => {
|
|
if (subject.id === 'electron-install') installed = true
|
|
if (subject.id === 'coverage') expect(installed).toBe(true)
|
|
return resultFor(subject, 'passed')
|
|
})
|
|
})
|
|
|
|
it.each(['ci-windows-complete', 'ci-windows-observational-ready'] as const)(
|
|
'provisions the locked Electron binary before built smoke in %s', (mode) => {
|
|
const gates = withPnpmEntrypoint(() => gatesForMode(mode))
|
|
const install = gates.find(gate => gate.id === 'electron-install')
|
|
expect(install).toMatchObject({
|
|
command: process.execPath,
|
|
args: ['/private/pnpm.cjs', '--filter', '@deepseek-ai/dsh-desktop', 'exec', 'install-electron'],
|
|
env: { ELECTRON_GET_USE_PROXY: '1' },
|
|
})
|
|
expect(gates.find(gate => gate.id === 'built-bin-smoke')?.needs).toContain('electron-install')
|
|
expect(gates.filter(gate => gate.id !== 'electron-install').every(gate => gate.env?.ELECTRON_GET_USE_PROXY === undefined)).toBe(true)
|
|
},
|
|
)
|
|
|
|
it.each(['ci-windows-complete', 'ci-windows-observational-ready'] as const)(
|
|
'keeps failed Electron provisioning visible without stopping unrelated diagnostics in %s', async (mode) => {
|
|
const gates = withPnpmEntrypoint(() => gatesForMode(mode))
|
|
const attempted: string[] = []
|
|
const results = await runGates(gates, 8, async (subject) => {
|
|
attempted.push(subject.id)
|
|
return resultFor(subject, subject.id === 'electron-install' ? 'failed' : 'passed')
|
|
})
|
|
expect(results.filter(result => result.status === 'failed').map(result => result.gate.id)).toEqual(['electron-install'])
|
|
expect(attempted).not.toContain('built-bin-smoke')
|
|
expect(results.find(result => result.gate.id === 'built-bin-smoke')?.status).toBe('skipped')
|
|
expect(results.find(result => result.gate.id === 'doc-graphs')?.status).toBe('passed')
|
|
if (mode === 'ci-windows-complete') {
|
|
expect(results.filter(result => result.status === 'failed' && !result.gate.allowFailure).map(result => result.gate.id))
|
|
.toEqual(['electron-install'])
|
|
expect(attempted).not.toContain('coverage')
|
|
expect(results.find(result => result.gate.id === 'coverage')?.status).toBe('skipped')
|
|
expect(results.find(result => result.gate.id === 'coverage-exempt-heavy')?.status).toBe('passed')
|
|
}
|
|
},
|
|
)
|
|
|
|
it.each(['ci-coverage', 'ci-artifacts', 'ci-windows-blocking'] as const)(
|
|
'only provisions Electron for native Windows coverage in %s', (mode) => {
|
|
const original = Object.getOwnPropertyDescriptor(process, 'platform')
|
|
for (const platform of ['linux', 'darwin', 'win32'] as const) {
|
|
const gates = withPlatform(platform, () => withPnpmEntrypoint(() => gatesForMode(mode)))
|
|
const needed = platform === 'win32' && mode === 'ci-coverage'
|
|
expect(gates.filter(gate => gate.id === 'electron-install')).toHaveLength(needed ? 1 : 0)
|
|
if (needed) {
|
|
expect(gates.find(gate => gate.id === 'coverage')?.needs).toContain('electron-install')
|
|
expect(gates.find(gate => gate.id === 'electron-install')?.env).toEqual({ ELECTRON_GET_USE_PROXY: '1' })
|
|
expect(gates.filter(gate => gate.id !== 'electron-install').every(gate => gate.env?.ELECTRON_GET_USE_PROXY === undefined)).toBe(true)
|
|
}
|
|
}
|
|
expect(Object.getOwnPropertyDescriptor(process, 'platform')).toEqual(original)
|
|
},
|
|
)
|
|
|
|
it('restores the platform descriptor when coverage configuration is rejected', () => {
|
|
const original = Object.getOwnPropertyDescriptor(process, 'platform')
|
|
expect(() => withEnv('DSH_COVERAGE_PARTITIONS', '1', () =>
|
|
withPlatform('win32', () => withPnpmEntrypoint(() => gatesForMode('ci-coverage')))))
|
|
.toThrow('DSH_COVERAGE_PARTITIONS must be an integer greater than 1')
|
|
expect(Object.getOwnPropertyDescriptor(process, 'platform')).toEqual(original)
|
|
})
|
|
|
|
it('leaves the lane test budget to the inherited environment on both coverage gates', () => {
|
|
// vitest.config.ts reads DSH_COVERAGE_TEST_TIMEOUT_MS per inline project
|
|
// (coverageTestTimeoutOptions); a gate argument would add a second owner.
|
|
for (const budget of ['15000', undefined]) {
|
|
const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', budget, () =>
|
|
withPnpmEntrypoint(() => gatesForMode('ci-windows-complete')))
|
|
for (const id of ['coverage', 'coverage-exempt-heavy']) {
|
|
const gate = gates.find(subject => subject.id === id)
|
|
if (gate === undefined) throw new Error(`ci-windows-complete must define the ${id} gate`)
|
|
expect(gate.args).not.toEqual(expect.arrayContaining([
|
|
expect.stringMatching(/^--(?:testTimeout|expect\.poll\.timeout|hookTimeout)=/),
|
|
]))
|
|
expect(gate.env ?? {}).not.toHaveProperty('DSH_COVERAGE_TEST_TIMEOUT_MS')
|
|
}
|
|
}
|
|
})
|
|
|
|
it('runs the plain unit inventory after the native build and leaves its budget to the inherited environment', () => {
|
|
const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
|
|
scripts: Record<string, string>
|
|
}
|
|
expect(scripts['check:ci:unit']).toBe('tsx scripts/run-gates.ts ci-unit')
|
|
|
|
// The aggregate is the `test` package script's two segments, in order,
|
|
// so a step added to one cannot silently leave the other. The lane sets
|
|
// DSH_COVERAGE_TEST_TIMEOUT_MS; vitest.config.ts reads it per inline
|
|
// project (coverageTestTimeoutOptions), so the gate passes no budget flag.
|
|
const [nativeBuild, unitRun, ...rest] = (scripts.test ?? '').split(' && ')
|
|
expect(rest).toEqual([])
|
|
for (const budget of ['15000', undefined]) {
|
|
const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', budget, () =>
|
|
withPnpmEntrypoint(() => gatesForMode('ci-unit')))
|
|
expect(gates.map(gate => gate.id)).toEqual(['native-system', 'unit'])
|
|
expect(gates[0]).toMatchObject({ displayCommand: nativeBuild })
|
|
expect(gates[1]?.displayCommand).toBe(`pnpm exec ${unitRun}`)
|
|
expect(gates[1]).toMatchObject({
|
|
label: 'test',
|
|
needs: ['native-system'],
|
|
streamOutput: true,
|
|
args: ['/private/pnpm.cjs', 'exec', 'vitest', 'run'],
|
|
})
|
|
expect(gates[1]?.env ?? {}).not.toHaveProperty('DSH_COVERAGE_TEST_TIMEOUT_MS')
|
|
}
|
|
})
|
|
|
|
it('selects partitioned coverage only when explicitly configured', () => {
|
|
const coverage = withEnv('DSH_COVERAGE_PARTITIONS', '3', () =>
|
|
withPnpmEntrypoint(() => gatesForMode('ci-windows-complete').find(subject => subject.id === 'coverage')))
|
|
|
|
expect(coverage).toMatchObject({
|
|
displayCommand: 'DSH_COVERAGE_PARTITIONS=3 pnpm run test:coverage:partitioned',
|
|
args: ['/private/pnpm.cjs', 'run', 'test:coverage:partitioned'],
|
|
env: { DSH_COVERAGE_EXEMPT_HEAVY: '1' },
|
|
streamOutput: true,
|
|
})
|
|
})
|
|
|
|
it('rejects an invalid coverage partition count before starting a gate', () => {
|
|
expect(() => withEnv('DSH_COVERAGE_PARTITIONS', '1', () =>
|
|
withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))))
|
|
.toThrow('DSH_COVERAGE_PARTITIONS must be an integer greater than 1')
|
|
})
|
|
|
|
it.each([
|
|
['empty', [], /gate graph has no gates/],
|
|
['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/],
|
|
['unknown dependencies', [gate('subject', { needs: ['missing'] })], /depends on unknown gate "missing"/],
|
|
['unknown ordering predecessors', [gate('subject', { after: ['missing'] })], /waits for unknown gate "missing"/],
|
|
['cycles', [gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })], /dependency cycle: first -> second -> first/],
|
|
['mixed cycles', [gate('first', { after: ['second'] }), gate('second', { needs: ['first'] })], /dependency cycle: first -> second -> first/],
|
|
] as const)('rejects %s before starting a child', async (_label, invalid, message) => {
|
|
const execute = vi.fn(async (subject: Gate) => resultFor(subject))
|
|
|
|
await expect(runGates([...invalid], 1, execute)).rejects.toThrow(message)
|
|
expect(execute).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects an invalid worker count before starting a child', async () => {
|
|
const execute = vi.fn(async (subject: Gate) => resultFor(subject))
|
|
|
|
await expect(runGates([gate('subject')], 0, execute)).rejects.toThrow('max concurrency must be a positive integer')
|
|
expect(execute).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('skips dependents after their prerequisite fails', async () => {
|
|
const dependent = gate('dependent', { needs: ['root'] })
|
|
const root = gate('root')
|
|
const execute = vi.fn(async (subject: Gate) => resultFor(subject, 'failed'))
|
|
|
|
const results = await runGates([dependent, root], 1, execute)
|
|
|
|
expect(execute).toHaveBeenCalledOnce()
|
|
expect(execute).toHaveBeenCalledWith(root, undefined)
|
|
expect(results[0]).toMatchObject({ gate: dependent, status: 'skipped', error: 'dependency failed or skipped: root' })
|
|
})
|
|
|
|
it('runs an ordered follower after its predecessor fails', async () => {
|
|
const follower = gate('follower', { after: ['root'] })
|
|
const root = gate('root')
|
|
const execute = vi.fn(async (subject: Gate) => resultFor(subject, subject === root ? 'failed' : 'passed'))
|
|
|
|
const results = await runGates([follower, root], 2, execute)
|
|
|
|
expect(execute.mock.calls.map(([subject]) => subject.id)).toEqual(['root', 'follower'])
|
|
expect(results.map(result => result.status)).toEqual(['passed', 'failed'])
|
|
})
|
|
|
|
it('runs an ordered follower after its predecessor is skipped', async () => {
|
|
const follower = gate('follower', { after: ['dependent'] })
|
|
const dependent = gate('dependent', { needs: ['root'] })
|
|
const root = gate('root')
|
|
const execute = vi.fn(async (subject: Gate) => resultFor(subject, subject === root ? 'failed' : 'passed'))
|
|
|
|
const results = await runGates([follower, dependent, root], 2, execute)
|
|
|
|
expect(execute.mock.calls.map(([subject]) => subject.id)).toEqual(['root', 'follower'])
|
|
expect(results.map(result => result.status)).toEqual(['passed', 'skipped', 'failed'])
|
|
})
|
|
})
|
|
|
|
describe('Oxlint gate', () => {
|
|
it('uses the package script when no worker bound is configured', () => {
|
|
const subject = withEnv('DSH_OXLINT_THREADS', undefined, () =>
|
|
withPnpmEntrypoint(() => gatesForMode('ci-lint-contracts-ready')[0]))
|
|
|
|
expect(subject).toMatchObject({
|
|
id: 'lint',
|
|
displayCommand: 'pnpm run lint:contracts-ready',
|
|
command: process.execPath,
|
|
args: ['/private/pnpm.cjs', 'run', 'lint:contracts-ready'],
|
|
})
|
|
})
|
|
|
|
it('surfaces the configured worker bound on the shared package script', () => {
|
|
const subject = withEnv('DSH_OXLINT_THREADS', '4', () =>
|
|
withPnpmEntrypoint(() => gatesForMode('ci-lint-contracts-ready')[0]))
|
|
|
|
expect(subject).toMatchObject({
|
|
id: 'lint',
|
|
displayCommand: 'DSH_OXLINT_THREADS=4 pnpm run lint:contracts-ready',
|
|
command: process.execPath,
|
|
args: ['/private/pnpm.cjs', 'run', 'lint:contracts-ready'],
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Typert contract preparation', () => {
|
|
it('prepares primary source consumers once before they run', () => {
|
|
const subject = withEnv('DSH_OXLINT_THREADS', undefined, () =>
|
|
withPnpmEntrypoint(() => gatesForMode('ci-primary')))
|
|
|
|
expect(subject.find(item => item.id === 'typert-contracts')).toMatchObject({
|
|
displayCommand: 'pnpm exec tsx scripts/compile-referenced-projects.ts host-contracts',
|
|
args: ['/private/pnpm.cjs', 'exec', 'tsx', 'scripts/compile-referenced-projects.ts', 'host-contracts'],
|
|
})
|
|
for (const [id, script] of [
|
|
['typecheck', 'typecheck:contracts-ready'],
|
|
['lint', 'lint:contracts-ready'],
|
|
['doc-typecheck', 'doc-typecheck:contracts-ready'],
|
|
] as const) {
|
|
expect(subject.find(item => item.id === id)).toMatchObject({
|
|
displayCommand: `pnpm run ${script}`,
|
|
args: ['/private/pnpm.cjs', 'run', script],
|
|
needs: ['typert-contracts'],
|
|
})
|
|
}
|
|
expect(subject.find(item => item.id === 'build')?.needs).toEqual([
|
|
'typecheck',
|
|
'lint',
|
|
'doc-typecheck',
|
|
])
|
|
})
|
|
|
|
it('reuses contracts from the validated consumer build', () => {
|
|
const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
|
|
|
|
expect(subject.find(item => item.id === 'lint-and-duplication')).toMatchObject({
|
|
displayCommand: 'pnpm run check:ci:lint:contracts-ready',
|
|
args: ['/private/pnpm.cjs', 'run', 'check:ci:lint:contracts-ready'],
|
|
})
|
|
expect(subject.find(item => item.id === 'doc-typecheck')).toMatchObject({
|
|
displayCommand: 'pnpm run doc-typecheck:contracts-ready',
|
|
args: ['/private/pnpm.cjs', 'run', 'doc-typecheck:contracts-ready'],
|
|
})
|
|
})
|
|
|
|
it('keeps standalone doc sync responsible for preparation', () => {
|
|
const docTypecheck = withPnpmEntrypoint(() =>
|
|
gatesForMode('doc-sync').find(item => item.id === 'doc-typecheck'))
|
|
|
|
expect(docTypecheck?.displayCommand).toBe('pnpm run doc-typecheck')
|
|
})
|
|
})
|
|
|
|
describe('Node compatibility graph', () => {
|
|
it.each(['22.19.0', '24.9.0', '26.0.0'])('runs all source compatibility smokes serially on Node %s', (version) => {
|
|
const subject = withNodeVersion(version, () =>
|
|
withPnpmEntrypoint(() => gatesForMode('node-compat')))
|
|
|
|
expect(subject.filter(item => item.id === 'source-compat-smokes')).toMatchObject([{
|
|
label: 'source compatibility smokes',
|
|
args: [
|
|
'/private/pnpm.cjs',
|
|
'exec',
|
|
'vitest',
|
|
'run',
|
|
'packages/workflow/workflow-ptc/tests/source-runtime.compat.spec.ts',
|
|
'packages/session/session-persistence-jsonl/tests/zstd.compat.spec.ts',
|
|
'apps/cli/tests/source-launch.compat.spec.ts',
|
|
'scripts/vitest-environment.compat.spec.ts',
|
|
'packages/boot/app-boot/tests/profile-resolution.spec.ts',
|
|
'packages/boot/app-boot/tests/profile-resolution-service.spec.ts',
|
|
'packages/boot/app-boot/tests/profile-resolution-worker-bootstrap.spec.ts',
|
|
'--no-file-parallelism',
|
|
'--maxWorkers=1',
|
|
],
|
|
env: { VITEST_MAX_WORKERS: '1' },
|
|
}])
|
|
})
|
|
|
|
it.each(['1', '8'])('keeps source compatibility smokes serial with inherited VITEST_MAX_WORKERS=%s', (workers) => {
|
|
const subject = withEnv('VITEST_MAX_WORKERS', workers, () =>
|
|
withPnpmEntrypoint(() => gatesForMode('node-compat')))
|
|
|
|
expect(subject.find(item => item.id === 'source-compat-smokes')?.env).toEqual({ VITEST_MAX_WORKERS: '1' })
|
|
})
|
|
|
|
it.each([undefined, '1'])('builds the complete Node 22 artifacts once with skip-typecheck=%s', (skipTypecheck) => {
|
|
const subject = withNodeVersion('22.19.0', () =>
|
|
withEnv('DSH_NODE_COMPAT_SKIP_TYPECHECK', skipTypecheck, () =>
|
|
withPnpmEntrypoint(() => gatesForMode('node-compat'))))
|
|
|
|
expect(subject.filter(item => item.displayCommand === 'pnpm run build:artifacts')).toHaveLength(1)
|
|
expect(subject.find(item => item.id === 'build')?.args).toEqual([
|
|
'/private/pnpm.cjs', 'run', 'build:artifacts',
|
|
])
|
|
expect(subject.map(item => item.id)).not.toContain('build:web')
|
|
expect(subject.find(item => item.id === 'cli-lazy-search-startup-smoke')).toMatchObject({
|
|
needs: ['build'],
|
|
env: { DSH_REQUIRE_BUILT_CLI_SMOKE: '1' },
|
|
})
|
|
})
|
|
|
|
it.each(['passed', 'failed'] as const)('runs the Node 22 lazy-search smoke only after a passed build (%s)', async (buildStatus) => {
|
|
const subject = withNodeVersion('22.19.0', () =>
|
|
withEnv('DSH_NODE_COMPAT_SKIP_TYPECHECK', '1', () =>
|
|
withPnpmEntrypoint(() => gatesForMode('node-compat'))))
|
|
let buildCompleted = false
|
|
let smokeStarted = false
|
|
const results = await runGates(subject, 2, async (item) => {
|
|
if (item.id === 'build') {
|
|
buildCompleted = true
|
|
return resultFor(item, buildStatus)
|
|
}
|
|
if (item.id === 'cli-lazy-search-startup-smoke') {
|
|
expect(buildCompleted).toBe(true)
|
|
smokeStarted = true
|
|
}
|
|
return resultFor(item)
|
|
})
|
|
|
|
expect(smokeStarted).toBe(buildStatus === 'passed')
|
|
expect(results.find(item => item.gate.id === 'cli-lazy-search-startup-smoke')?.status)
|
|
.toBe(buildStatus === 'passed' ? 'passed' : 'skipped')
|
|
})
|
|
})
|
|
|
|
describe('Node 24 lane ownership', () => {
|
|
it('keeps the static lane source-only', () => {
|
|
const subject = withPnpmEntrypoint(() => gatesForMode('ci-static'))
|
|
|
|
expect(subject.map(item => item.id)).not.toContain('build')
|
|
expect(subject.map(item => item.id)).not.toContain('doc-typecheck')
|
|
})
|
|
|
|
it('owns the build and orders its artifact consumers', () => {
|
|
const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
|
|
|
|
expect(defaultConcurrency('ci-consumers', subject.length, 4, { CI: 'true' })).toEqual({
|
|
workers: 4,
|
|
source: '4 available CPU(s)',
|
|
})
|
|
expect(subject.map(item => item.id)).toEqual([
|
|
'build',
|
|
'node-compat',
|
|
'publint',
|
|
'lint-and-duplication',
|
|
'snapshot',
|
|
'expected-output',
|
|
'web-snapshot',
|
|
'doc-typecheck',
|
|
'node-next-types',
|
|
'built-bin-smoke',
|
|
])
|
|
expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build'])
|
|
expect(subject.find(item => item.id === 'build')?.env).toEqual({
|
|
DSH_BUILD_CLIENT_PROFILE: 'official',
|
|
})
|
|
expect(subject.find(item => item.id === 'node-compat')?.env).toEqual({
|
|
DSH_BUILD_CLIENT_PROFILE: 'official',
|
|
})
|
|
expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['build'])
|
|
for (const id of [
|
|
'snapshot',
|
|
'expected-output',
|
|
'web-snapshot',
|
|
'doc-typecheck',
|
|
'node-next-types',
|
|
'built-bin-smoke',
|
|
]) {
|
|
expect(subject.find(item => item.id === id)?.needs).toEqual(['build'])
|
|
}
|
|
expect(subject.find(item => item.id === 'snapshot')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' })
|
|
expect(subject.find(item => item.id === 'expected-output')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' })
|
|
expect(subject.find(item => item.id === 'built-bin-smoke')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' })
|
|
expect(subject.find(item => item.id === 'doc-typecheck')?.env).toEqual({
|
|
DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1',
|
|
})
|
|
expect(subject.find(item => item.id === 'built-bin-smoke')?.args).toEqual(
|
|
expect.arrayContaining([
|
|
'apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts',
|
|
'apps/desktop/tests/acl-skill.built.e2e.ts',
|
|
'packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts',
|
|
'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts',
|
|
'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',
|
|
'packages/experimental/agent-team/tests/built-lib.e2e.ts',
|
|
]),
|
|
)
|
|
expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({
|
|
displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
|
|
env: { DSH_SNAPSHOT: 'replay' },
|
|
after: [
|
|
'publint',
|
|
'lint-and-duplication',
|
|
'snapshot',
|
|
'expected-output',
|
|
'doc-typecheck',
|
|
'node-next-types',
|
|
'built-bin-smoke',
|
|
],
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Linux primary graph', () => {
|
|
it('adds the same compare-only web gate after built client artifacts', () => {
|
|
const subject = withPnpmEntrypoint(() => gatesForMode('ci-linux-primary'))
|
|
const web = subject.find(item => item.id === 'web-snapshot')
|
|
|
|
expect(web).toMatchObject({
|
|
displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
|
|
env: { DSH_SNAPSHOT: 'replay' },
|
|
needs: ['build'],
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('gate process outcomes', () => {
|
|
it('streams selected gate output without retaining it', async () => {
|
|
const write = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
|
|
try {
|
|
const result = await runGate(gate('streamed', {
|
|
args: ['-e', "process.stdout.write('live output')"],
|
|
streamOutput: true,
|
|
}))
|
|
|
|
expect(result.status).toBe('passed')
|
|
expect(result.output).toEqual([])
|
|
expect(write).toHaveBeenCalledWith('live output')
|
|
} finally {
|
|
write.mockRestore()
|
|
}
|
|
})
|
|
|
|
it.skipIf(process.platform === 'win32')('reports signal termination independently from exit status', async () => {
|
|
const result = await runGate(gate('terminated', {
|
|
args: ['-e', "process.kill(process.pid, 'SIGTERM')"],
|
|
}))
|
|
|
|
expect(result.status).toBe('failed')
|
|
expect(result.exitCode).toBeNull()
|
|
expect(result.signalCode).toBe('SIGTERM')
|
|
expect(formatGateResultReason(result)).toBe('signal SIGTERM')
|
|
})
|
|
})
|
|
|
|
describe('fail-fast scheduling', () => {
|
|
it('aborts the aggregate at the first blocking failure', async () => {
|
|
const slow = gate('slow')
|
|
const fast = gate('fast')
|
|
const dependent = gate('dependent', { needs: ['slow'] })
|
|
const execute = vi.fn(async (subject: Gate, signal?: AbortSignal) => {
|
|
if (subject.id === 'fast') {
|
|
return new Promise<GateResult>((resolve) => {
|
|
signal?.addEventListener('abort', () => {
|
|
// The real runGate marks a gate the abort terminated; the drain
|
|
// must then record it skipped rather than keep the failure.
|
|
resolve({ ...resultFor(subject, 'failed'), aborted: true })
|
|
}, { once: true })
|
|
})
|
|
}
|
|
return resultFor(subject, subject.id === 'slow' ? 'failed' : 'passed')
|
|
})
|
|
|
|
const results = await runGates([slow, fast, dependent], 2, execute, () => {}, { failFast: true })
|
|
|
|
expect(execute.mock.calls.map(([subject]) => subject.id)).toEqual(['slow', 'fast'])
|
|
expect(results.map(result => result.status)).toEqual(['failed', 'skipped', 'skipped'])
|
|
expect(results[1]).toMatchObject({
|
|
status: 'skipped',
|
|
error: 'aborted by fail-fast: slow failed',
|
|
})
|
|
expect(results[2]).toMatchObject({
|
|
status: 'skipped',
|
|
error: 'aborted by fail-fast: slow failed',
|
|
})
|
|
})
|
|
|
|
it('does not abort on a non-blocking gate failure', async () => {
|
|
const observational = gate('observational', { allowFailure: true })
|
|
const root = gate('root')
|
|
const execute = vi.fn(async (subject: Gate) => (
|
|
resultFor(subject, subject.id === 'observational' ? 'failed' : 'passed')
|
|
))
|
|
|
|
const results = await runGates([observational, root], 2, execute, () => {}, { failFast: true })
|
|
|
|
expect(execute).toHaveBeenCalledTimes(2)
|
|
expect(results.map(result => result.status)).toEqual(['failed', 'passed'])
|
|
})
|
|
|
|
it('runs independent gates to completion when fail-fast is disabled', async () => {
|
|
const root = gate('root')
|
|
const sibling = gate('sibling')
|
|
const execute = vi.fn(async (subject: Gate) => (
|
|
resultFor(subject, subject.id === 'root' ? 'failed' : 'passed')
|
|
))
|
|
|
|
const results = await runGates([root, sibling], 2, execute, () => {}, { failFast: false })
|
|
|
|
expect(execute).toHaveBeenCalledTimes(2)
|
|
expect(results.map(result => result.status)).toEqual(['failed', 'passed'])
|
|
})
|
|
|
|
it('kills the child when the abort signal fires', async () => {
|
|
const controller = new AbortController()
|
|
const promise = runGate(gate('killable', { args: ['-e', 'setInterval(() => {}, 1000)'] }), controller.signal)
|
|
controller.abort()
|
|
const result = await promise
|
|
|
|
expect(result.status).toBe('failed')
|
|
expect(result.aborted).toBe(true)
|
|
if (process.platform !== 'win32') expect(result.signalCode).toBe('SIGTERM')
|
|
})
|
|
|
|
it.skipIf(process.platform === 'win32')('marks a zero-exit child as aborted when the signal fired', async () => {
|
|
const marker = 'signal-trap-armed'
|
|
const armed = Promise.withResolvers<undefined>()
|
|
const controller = new AbortController()
|
|
let promise: Promise<GateResult> | undefined
|
|
const { write } = captureStreamedOutput((chunks) => {
|
|
if (chunks.join('').split('\n').slice(0, -1).includes(marker)) armed.resolve(undefined)
|
|
})
|
|
const cleanup = async (): Promise<void> => {
|
|
controller.abort()
|
|
try { await promise } finally { write.mockRestore() }
|
|
}
|
|
onTestFinished(cleanup)
|
|
try {
|
|
// Readiness belongs after native signal-handler registration.
|
|
const script = [
|
|
"process.on('SIGTERM', () => process.exit(0))",
|
|
`process.stdout.write(${JSON.stringify(`${marker}\n`)})`,
|
|
'setInterval(() => {}, 1000)',
|
|
].join(';')
|
|
const child = gate('traps-signal', { args: ['-e', script], streamOutput: true })
|
|
promise = runGate(child, controller.signal)
|
|
await Promise.race([
|
|
armed.promise,
|
|
promise.then((result) => { throw new Error(`signal fixture exited before readiness: ${formatGateResultReason(result)}`) }),
|
|
])
|
|
controller.abort()
|
|
const result = await promise
|
|
|
|
// The child trapped the signal and exited zero; the drain must not
|
|
// report this gate passed, so the raw outcome carries the abort mark.
|
|
expect(result, formatGateResultReason(result)).toMatchObject({
|
|
status: 'passed', exitCode: 0, signalCode: null, aborted: true,
|
|
})
|
|
} finally {
|
|
await cleanup()
|
|
}
|
|
})
|
|
|
|
it.skipIf(process.platform === 'win32')('kills the whole gate process tree when the abort signal fires', async () => {
|
|
const { writes, write } = captureStreamedOutput()
|
|
const controller = new AbortController()
|
|
let promise: Promise<GateResult> | undefined
|
|
try {
|
|
const script = [
|
|
"const { spawn } = require('node:child_process')",
|
|
// Detached, so the grandchild leads its own process group: the gate
|
|
// group signal cannot reach it, and only the descendant enumeration in
|
|
// treeKill does — the shape of a nested run-gates' leaf gates.
|
|
"const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true })",
|
|
"process.stdout.write('grandchild:' + grandchild.pid + '\\n')",
|
|
'setInterval(() => {}, 1000)',
|
|
].join(';')
|
|
promise = runGate(gate('tree', { args: ['-e', script], streamOutput: true }), controller.signal)
|
|
const deadline = Date.now() + 5000
|
|
let pid: number | undefined
|
|
while (pid === undefined && Date.now() < deadline) {
|
|
const match = writes.join('').match(/grandchild:(\d+)/)
|
|
if (match !== null) pid = Number(match[1])
|
|
else await new Promise(resolve => setTimeout(resolve, 20))
|
|
}
|
|
expect(pid ?? 0).toBeGreaterThan(0)
|
|
controller.abort()
|
|
const result = await promise
|
|
expect(result.status).toBe('failed')
|
|
// The descendant enumeration signals the detached grandchild at the same
|
|
// time as the group signal reaches the direct child; the direct child's
|
|
// own death closes the gate pipes, so poll for the grandchild to stop
|
|
// executing rather than asserting on a fixed instant.
|
|
const stopDeadline = Date.now() + 5000
|
|
while (!procStopped(pid!) && Date.now() < stopDeadline) {
|
|
await new Promise(resolve => setTimeout(resolve, 20))
|
|
}
|
|
expect(procStopped(pid!)).toBe(true)
|
|
} finally {
|
|
// A failed wait or assertion must not leave the forever-looping detached
|
|
// grandchild behind on the host: abort the gate and wait for the
|
|
// process tree to settle before restoring the spy.
|
|
controller.abort()
|
|
await promise
|
|
write.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('forwards host interruption signals to the abort path', async () => {
|
|
const slow = gate('slow')
|
|
const sibling = gate('sibling')
|
|
const execute = vi.fn(async (subject: Gate, signal?: AbortSignal) => {
|
|
if (subject.id === 'slow') {
|
|
return new Promise<GateResult>((resolve) => {
|
|
signal?.addEventListener('abort', () => {
|
|
// A child can trap the signal and exit zero; the drain must still
|
|
// record the gate skipped so the interrupted run fails.
|
|
resolve({ ...resultFor(subject, 'passed'), aborted: true })
|
|
}, { once: true })
|
|
})
|
|
}
|
|
return resultFor(subject)
|
|
})
|
|
|
|
const promise = runGates([slow, sibling], 1, execute, () => {}, { failFast: true, forwardProcessSignals: true })
|
|
// The first loop iteration starts `slow` synchronously, so its abort
|
|
// listener is registered before the signal is emitted.
|
|
process.emit('SIGTERM')
|
|
const results = await promise
|
|
|
|
expect(execute).toHaveBeenCalledOnce()
|
|
expect(results.map(result => result.status)).toEqual(['skipped', 'skipped'])
|
|
expect(results[0]).toMatchObject({
|
|
status: 'skipped',
|
|
error: 'aborted by fail-fast: host interruption',
|
|
})
|
|
})
|
|
|
|
it('pairs host signal forwarding with fail-fast at the CLI entrypoint', () => {
|
|
expect(cliGateOptions(true)).toEqual({ failFast: true, forwardProcessSignals: true })
|
|
expect(cliGateOptions(false)).toEqual({ failFast: false, forwardProcessSignals: false })
|
|
})
|
|
|
|
it('rejects host signal forwarding without fail-fast', async () => {
|
|
const execute = vi.fn(async (subject: Gate) => resultFor(subject))
|
|
|
|
await expect(runGates([gate('subject')], 1, execute, () => {}, { forwardProcessSignals: true }))
|
|
.rejects.toThrow('forwardProcessSignals requires failFast')
|
|
expect(execute).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('leaves an un-aborted child running to completion', async () => {
|
|
const result = await runGate(gate('settles', { args: ['-e', ''] }), new AbortController().signal)
|
|
|
|
expect(result.status).toBe('passed')
|
|
expect(result.aborted).toBe(false)
|
|
})
|
|
|
|
it.skipIf(process.platform === 'win32')('kills a detached descendant that outlived the child when the abort arrives later', async () => {
|
|
const writes: string[] = []
|
|
const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
|
writes.push(String(chunk))
|
|
return true
|
|
})
|
|
const controller = new AbortController()
|
|
let promise: Promise<GateResult> | undefined
|
|
try {
|
|
const script = [
|
|
"const { spawn } = require('node:child_process')",
|
|
// Detached with inherited stdio: the grandchild leads its own process
|
|
// group (the gate group signal misses it) and holds the gate's
|
|
// stdout write end (so `close` stays pending past the child exit).
|
|
"const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'inherit' })",
|
|
"process.stdout.write('grandchild:' + grandchild.pid + '\\n')",
|
|
// Outlive the first descendant-sampler tick with margin so the cache
|
|
// holds the grandchild even on a loaded runner, then exit normally
|
|
// before the abort arrives.
|
|
"setTimeout(() => { process.stdout.write('child-exit\\n'); process.exit(0) }, 8000)",
|
|
].join(';')
|
|
promise = runGate(gate('late-abort', { args: ['-e', script], streamOutput: true }), controller.signal)
|
|
const pid = await waitForGrandchildPid(writes, 'child-exit', Date.now() + 10000)
|
|
// terminate must not re-enumerate over the sampler cache now that the
|
|
// child is gone; the detached grandchild is killed from the cached list.
|
|
await abortAndExpectTreeStopped(promise, controller, pid)
|
|
} finally {
|
|
// A failed wait or assertion must not leave the forever-looping detached
|
|
// grandchild behind on the host: abort the gate and wait for the
|
|
// process tree to settle before restoring the spy.
|
|
controller.abort()
|
|
await promise
|
|
write.mockRestore()
|
|
}
|
|
}, 20000)
|
|
|
|
it.skipIf(process.platform === 'win32')('keeps a reparented detached descendant tracked across a sampler tick', async () => {
|
|
const { writes, write } = captureStreamedOutput()
|
|
const controller = new AbortController()
|
|
let promise: Promise<GateResult> | undefined
|
|
try {
|
|
const script = [
|
|
"const { spawn } = require('node:child_process')",
|
|
// Wrapper spawns a detached grandchild with inherited stdio (its own
|
|
// process group, holding the gate's stdout write end), prints the pid,
|
|
// then exits after 7 seconds — after the first sampler tick, before
|
|
// the second. From then on the grandchild is reparented and
|
|
// unreachable by parent id.
|
|
"const wrapper = spawn(process.execPath, ['-e', \"const { spawn } = require('node:child_process'); const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { detached: true, stdio: 'inherit' }); process.stdout.write('grandchild:' + grandchild.pid + '\\\\n'); setTimeout(() => process.exit(0), 7000)\"], { stdio: 'inherit' })",
|
|
"wrapper.on('exit', () => process.stdout.write('wrapper-exited\\n'))",
|
|
// Keep the root child alive past the abort with a heartbeat so the
|
|
// test can abort while it is still running.
|
|
"setInterval(() => process.stdout.write('hb\\n'), 1000)",
|
|
].join(';')
|
|
promise = runGate(gate('sampler-merge', { args: ['-e', script], streamOutput: true }), controller.signal)
|
|
const pid = await waitForGrandchildPid(writes, 'wrapper-exited', Date.now() + 15000)
|
|
// Wait past the second sampler tick (t=10) with margin: a replacing tick
|
|
// would drop the reparented grandchild from the cache, after which the
|
|
// abort cannot reach it. The root child keeps running throughout.
|
|
const tickDeadline = Date.now() + 10000
|
|
const wrapperExitedAt = Date.now()
|
|
while (Date.now() - wrapperExitedAt < 5000 && Date.now() < tickDeadline) {
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
}
|
|
expect(Date.now() - wrapperExitedAt).toBeGreaterThanOrEqual(5000)
|
|
await abortAndExpectTreeStopped(promise, controller, pid)
|
|
} finally {
|
|
// A failed wait or assertion must not leave the forever-looping detached
|
|
// grandchild behind on the host: abort the gate and wait for the
|
|
// process tree to settle before restoring the spy.
|
|
controller.abort()
|
|
await promise
|
|
write.mockRestore()
|
|
}
|
|
}, 30000)
|
|
})
|
|
|
|
describe('process-table parsing', () => {
|
|
it('excludes the root when a parent link returns to it', () => {
|
|
expect(collectDescendants(100, [[200, 100], [100, 200], [300, 200]]))
|
|
.toEqual([200, 300])
|
|
})
|
|
|
|
it('visits duplicate and cyclic descendant links only once', () => {
|
|
expect(collectDescendants(100, [
|
|
[200, 100], [200, 100], [300, 100], [200, 200], [400, 200], [200, 400], [500, 300], [900, 800],
|
|
])).toEqual([200, 300, 400, 500])
|
|
})
|
|
|
|
it('returns no descendants for an isolated or self-parented root', () => {
|
|
expect(collectDescendants(100, [])).toEqual([])
|
|
expect(collectDescendants(100, [[100, 100]])).toEqual([])
|
|
})
|
|
|
|
it('walks a wide child set without spreading it into call arguments', () => {
|
|
const children = Array.from({ length: 150_000 }, (_, i): [number, number] => [i + 3, 2])
|
|
const descendants = collectDescendants(1, [[2, 1], ...children])
|
|
expect(descendants).toHaveLength(children.length + 1)
|
|
expect(descendants[0]).toBe(2)
|
|
expect(descendants.at(-1)).toBe(150_002)
|
|
})
|
|
|
|
it('parses `pid ppid` rows from a POSIX ps dump', () => {
|
|
expect(parsePidPpidLines(' 123 1\n456 123\n 789 456\n')).toEqual([[123, 1], [456, 123], [789, 456]])
|
|
})
|
|
|
|
it('parses Windows PowerShell Get-CimInstance output of the same shape', () => {
|
|
expect(parsePidPpidLines(' 123 1\r\n456 123\r\n')).toEqual([[123, 1], [456, 123]])
|
|
})
|
|
|
|
it('drops blank and malformed lines', () => {
|
|
expect(parsePidPpidLines(' 123 1\n\ncommand not found\n999 abc\n')).toEqual([[123, 1]])
|
|
})
|
|
})
|
|
|
|
describe('Windows tree termination', () => {
|
|
it('targets the root first and each captured descendant after it', () => {
|
|
expect(taskkillArgs(100, [201, 302, 403])).toEqual([
|
|
['/PID', '100', '/T', '/F'],
|
|
['/PID', '201', '/T', '/F'],
|
|
['/PID', '302', '/T', '/F'],
|
|
['/PID', '403', '/T', '/F'],
|
|
])
|
|
})
|
|
|
|
it('terminates the root alone when no descendant was captured', () => {
|
|
expect(taskkillArgs(100, [])).toEqual([['/PID', '100', '/T', '/F']])
|
|
})
|
|
})
|