|
|
@@ -10,6 +10,12 @@ import { availableParallelism } from 'node:os'
|
|
|
import { resolve } from 'node:path'
|
|
|
import { performance } from 'node:perf_hooks'
|
|
|
import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './coverage-exempt.ts'
|
|
|
+import {
|
|
|
+ COVERAGE_PARTITIONS_ENV,
|
|
|
+ COVERAGE_TEST_TIMEOUT_ENV,
|
|
|
+ coverageTestTimeoutArgs,
|
|
|
+ parseCoveragePartitionCount,
|
|
|
+} from './coverage-partitions.ts'
|
|
|
|
|
|
/** A named aggregate exposed by the gate runner. */
|
|
|
export type Mode =
|
|
|
@@ -39,8 +45,13 @@ export interface Gate {
|
|
|
command: string
|
|
|
args: string[]
|
|
|
needs?: string[]
|
|
|
+ /** Gate ids that must settle, regardless of outcome, before this gate starts. */
|
|
|
+ after?: string[]
|
|
|
env?: Record<string, string | undefined>
|
|
|
+ /** Keep a failure visible without failing the aggregate. */
|
|
|
allowFailure?: boolean
|
|
|
+ /** Write child output as it arrives instead of buffering it until completion. */
|
|
|
+ streamOutput?: boolean
|
|
|
}
|
|
|
|
|
|
/** The observed outcome of one gate process. */
|
|
|
@@ -395,7 +406,7 @@ function ciConsumerGates(): Gate[] {
|
|
|
pnpmScript('build', 'build'),
|
|
|
pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }),
|
|
|
pnpmScript('publint', 'publint', { needs: builtTree }),
|
|
|
- builtPackageInvariantsGate(['publint']),
|
|
|
+ builtPackageInvariantsGate(builtTree),
|
|
|
pnpmScript('lint-and-duplication', 'check:ci:lint:contracts-ready', {
|
|
|
label: 'lint and duplication',
|
|
|
needs: validatedBuild,
|
|
|
@@ -415,6 +426,20 @@ function ciConsumerGates(): Gate[] {
|
|
|
}
|
|
|
|
|
|
function webSnapshotGate(needs: string[]): Gate {
|
|
|
+ const workerRaw = process.env.DSH_WEB_SNAPSHOT_WORKERS
|
|
|
+ if (workerRaw !== undefined && workerRaw !== '') {
|
|
|
+ const workers = Number.parseInt(workerRaw, 10)
|
|
|
+ if (!Number.isSafeInteger(workers) || workers < 2 || String(workers) !== workerRaw) {
|
|
|
+ throw new Error(`run-gates: DSH_WEB_SNAPSHOT_WORKERS must be an integer greater than 1, got ${JSON.stringify(workerRaw)}.`)
|
|
|
+ }
|
|
|
+ return pnpmScript('web-snapshot', 'test:web:ci', {
|
|
|
+ label: 'web browser snapshot',
|
|
|
+ displayCommand: `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=${workers} pnpm run test:web:ci`,
|
|
|
+ env: { DSH_SNAPSHOT: 'replay' },
|
|
|
+ needs,
|
|
|
+ streamOutput: true,
|
|
|
+ })
|
|
|
+ }
|
|
|
return pnpmScript('web-snapshot', 'test:web:built', {
|
|
|
label: 'web browser snapshot',
|
|
|
displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
|
|
|
@@ -431,15 +456,23 @@ function ciWindowsBlockingGates(): Gate[] {
|
|
|
}
|
|
|
|
|
|
function ciWindowsCompleteGates(): Gate[] {
|
|
|
+ const coverage = coverageGates().map(gate => gate.id === 'coverage-exempt-heavy'
|
|
|
+ ? { ...gate, needs: [...new Set(['build', ...(gate.needs ?? [])])] }
|
|
|
+ : gate)
|
|
|
+ const coverageAfter = coverage.map(gate => gate.id)
|
|
|
const observational = ciWindowsObservationalGates()
|
|
|
// The required production site replaces the observational MPA build; both
|
|
|
// VitePress modes write the same output directory and cannot overlap.
|
|
|
.filter(gate => gate.id !== 'build' && gate.id !== 'docs-site-build')
|
|
|
- .map(gate => ({ ...gate, allowFailure: true }))
|
|
|
+ .map(gate => ({
|
|
|
+ ...gate,
|
|
|
+ allowFailure: true,
|
|
|
+ after: [...new Set([...coverageAfter, ...(gate.after ?? [])])],
|
|
|
+ }))
|
|
|
return [
|
|
|
pnpmScript('build', 'build'),
|
|
|
pnpmScript('windows-site', 'docs:build', { label: 'production site' }),
|
|
|
- ...coverageGates(),
|
|
|
+ ...coverage,
|
|
|
...observational,
|
|
|
]
|
|
|
}
|
|
|
@@ -479,13 +512,14 @@ function lintGate(options: { needs?: string[] } = {}): Gate {
|
|
|
// under v8 instrumentation while contributing nothing the thresholds need
|
|
|
// (membership rules in scripts/coverage-exempt.ts).
|
|
|
//
|
|
|
-// DSH_COVERAGE_MAX_WORKERS is the lane's worker budget, so the two parallel
|
|
|
-// gates split it instead of each claiming it whole (the failover pool's
|
|
|
-// 8 x 6-instance bound assumes one lane never exceeds its value). The exempt
|
|
|
+// DSH_COVERAGE_MAX_WORKERS is the ordinary lane's worker budget, so the two
|
|
|
+// parallel gates split it instead of each claiming it whole. When
|
|
|
+// DSH_COVERAGE_PARTITIONS is set, its single-worker processes replace the
|
|
|
+// instrumented share while this budget still sizes the exempt gate. The exempt
|
|
|
// gate's wall clock is dominated by its longest single file, so it takes the
|
|
|
-// small share. A budget of 1 gives each gate 1 worker; lanes that need a
|
|
|
-// strict total of one (the serial reference jobs) also set
|
|
|
-// DSH_GATE_CONCURRENCY=1, which keeps the gates from overlapping at all.
|
|
|
+// small share. A budget of 1 gives each gate 1 worker; lanes that need a strict
|
|
|
+// total of one (the serial reference jobs) also set DSH_GATE_CONCURRENCY=1,
|
|
|
+// which keeps the gates from overlapping at all.
|
|
|
// DSH_COVERAGE_TEST_TIMEOUT_MS raises Vitest's per-test and expect.poll
|
|
|
// defaults together for instrumented lanes whose scheduling overhead exceeds
|
|
|
// those defaults. Explicit fixture timeouts remain authoritative.
|
|
|
@@ -501,18 +535,12 @@ function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function coverageTimeoutArgs(): string[] {
|
|
|
- return [
|
|
|
- ...positiveIntArg('DSH_COVERAGE_TEST_TIMEOUT_MS', '--testTimeout'),
|
|
|
- ...positiveIntArg('DSH_COVERAGE_TEST_TIMEOUT_MS', '--expect.poll.timeout'),
|
|
|
- ]
|
|
|
-}
|
|
|
-
|
|
|
function coverageGates(): Gate[] {
|
|
|
const workers = coverageWorkerArgs()
|
|
|
- const timeouts = coverageTimeoutArgs()
|
|
|
- return [
|
|
|
- pnpmExec('coverage', [
|
|
|
+ const timeouts = coverageTestTimeoutArgs(process.env[COVERAGE_TEST_TIMEOUT_ENV])
|
|
|
+ const partitions = parseCoveragePartitionCount(process.env[COVERAGE_PARTITIONS_ENV])
|
|
|
+ const instrumented = partitions === undefined
|
|
|
+ ? pnpmExec('coverage', [
|
|
|
'vitest',
|
|
|
'run',
|
|
|
'--coverage',
|
|
|
@@ -521,7 +549,15 @@ function coverageGates(): Gate[] {
|
|
|
], {
|
|
|
label: 'test:coverage',
|
|
|
env: { [COVERAGE_EXEMPT_ENV]: '1' },
|
|
|
- }),
|
|
|
+ })
|
|
|
+ : pnpmScript('coverage', 'test:coverage:partitioned', {
|
|
|
+ label: 'test:coverage',
|
|
|
+ displayCommand: `${COVERAGE_PARTITIONS_ENV}=${partitions} pnpm run test:coverage:partitioned`,
|
|
|
+ env: { [COVERAGE_EXEMPT_ENV]: '1' },
|
|
|
+ streamOutput: true,
|
|
|
+ })
|
|
|
+ return [
|
|
|
+ instrumented,
|
|
|
pnpmExec('coverage-exempt-heavy', [
|
|
|
'vitest',
|
|
|
'run',
|
|
|
@@ -681,6 +717,11 @@ function validateGateGraph(gates: readonly Gate[]): void {
|
|
|
throw new Error(`run-gates: gate ${JSON.stringify(gate.id)} depends on unknown gate ${JSON.stringify(dependency)}.`)
|
|
|
}
|
|
|
}
|
|
|
+ for (const predecessor of gate.after ?? []) {
|
|
|
+ if (!ids.has(predecessor)) {
|
|
|
+ throw new Error(`run-gates: gate ${JSON.stringify(gate.id)} waits for unknown gate ${JSON.stringify(predecessor)}.`)
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
const cycle = findDependencyCycle(gates)
|
|
|
@@ -702,8 +743,8 @@ function findDependencyCycle(gates: readonly Gate[]): string[] | undefined {
|
|
|
|
|
|
active.set(id, path.length)
|
|
|
path.push(id)
|
|
|
- for (const dependency of gate.needs ?? []) {
|
|
|
- const cycle = visit(dependency)
|
|
|
+ for (const predecessor of [...(gate.needs ?? []), ...(gate.after ?? [])]) {
|
|
|
+ const cycle = visit(predecessor)
|
|
|
if (cycle !== undefined) return cycle
|
|
|
}
|
|
|
path.pop()
|
|
|
@@ -744,7 +785,7 @@ export async function runGates(
|
|
|
for (;;) {
|
|
|
let madeProgress = false
|
|
|
while (running.length < maxActive) {
|
|
|
- const ready = gates.find(gate => states.get(gate.id) === 'pending' && dependenciesPassed(gate, states))
|
|
|
+ const ready = gates.find(gate => states.get(gate.id) === 'pending' && predecessorsReady(gate, states))
|
|
|
if (ready === undefined) break
|
|
|
states.set(ready.id, 'running')
|
|
|
running.push({ gate: ready, promise: execute(ready) })
|
|
|
@@ -753,32 +794,24 @@ export async function runGates(
|
|
|
}
|
|
|
|
|
|
if (running.length === 0) {
|
|
|
- let pending = gates.filter(gate => states.get(gate.id) === 'pending')
|
|
|
- while (pending.length > 0) {
|
|
|
- const gate = pending.find(item => (item.needs ?? []).some((id) => {
|
|
|
- const state = states.get(id)
|
|
|
- return state === 'failed' || state === 'skipped'
|
|
|
- }))
|
|
|
- if (gate === undefined) throw new Error('run-gates: validated graph stalled without a failed dependency.')
|
|
|
- const failedDeps = (gate.needs ?? []).filter((id) => {
|
|
|
- const state = states.get(id)
|
|
|
- return state === 'failed' || state === 'skipped'
|
|
|
- })
|
|
|
- const result: GateResult = {
|
|
|
- gate,
|
|
|
- status: 'skipped',
|
|
|
- durationMs: 0,
|
|
|
- output: [],
|
|
|
- exitCode: null,
|
|
|
- signalCode: null,
|
|
|
- error: `dependency failed or skipped: ${failedDeps.join(', ')}`,
|
|
|
- }
|
|
|
- states.set(gate.id, 'skipped')
|
|
|
- results.set(gate.id, result)
|
|
|
- observe(result)
|
|
|
- pending = pending.filter(item => item !== gate)
|
|
|
+ const pending = gates.filter(gate => states.get(gate.id) === 'pending')
|
|
|
+ if (pending.length === 0) break
|
|
|
+ const gate = pending.find(item => (item.needs ?? []).some(id => gateFailed(states.get(id))))
|
|
|
+ if (gate === undefined) throw new Error('run-gates: validated graph stalled without a failed dependency.')
|
|
|
+ const failedDeps = (gate.needs ?? []).filter(id => gateFailed(states.get(id)))
|
|
|
+ const result: GateResult = {
|
|
|
+ gate,
|
|
|
+ status: 'skipped',
|
|
|
+ durationMs: 0,
|
|
|
+ output: [],
|
|
|
+ exitCode: null,
|
|
|
+ signalCode: null,
|
|
|
+ error: `dependency failed or skipped: ${failedDeps.join(', ')}`,
|
|
|
}
|
|
|
- break
|
|
|
+ states.set(gate.id, 'skipped')
|
|
|
+ results.set(gate.id, result)
|
|
|
+ observe(result)
|
|
|
+ continue
|
|
|
}
|
|
|
|
|
|
if (!madeProgress) {
|
|
|
@@ -797,8 +830,17 @@ export async function runGates(
|
|
|
})
|
|
|
}
|
|
|
|
|
|
-function dependenciesPassed(gate: Gate, states: Map<string, GateState>): boolean {
|
|
|
+function predecessorsReady(gate: Gate, states: Map<string, GateState>): boolean {
|
|
|
return (gate.needs ?? []).every(id => states.get(id) === 'passed')
|
|
|
+ && (gate.after ?? []).every(id => gateSettled(states.get(id)))
|
|
|
+}
|
|
|
+
|
|
|
+function gateSettled(state: GateState | undefined): boolean {
|
|
|
+ return state === 'passed' || state === 'failed' || state === 'skipped'
|
|
|
+}
|
|
|
+
|
|
|
+function gateFailed(state: GateState | undefined): boolean {
|
|
|
+ return state === 'failed' || state === 'skipped'
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -823,10 +865,12 @@ export async function runGate(gate: Gate): Promise<GateResult> {
|
|
|
child.stdout.setEncoding('utf8')
|
|
|
child.stderr.setEncoding('utf8')
|
|
|
child.stdout.on('data', (chunk: string) => {
|
|
|
- output.push({ stream: 'stdout', text: chunk })
|
|
|
+ if (gate.streamOutput === true) process.stdout.write(chunk)
|
|
|
+ else output.push({ stream: 'stdout', text: chunk })
|
|
|
})
|
|
|
child.stderr.on('data', (chunk: string) => {
|
|
|
- output.push({ stream: 'stderr', text: chunk })
|
|
|
+ if (gate.streamOutput === true) process.stderr.write(chunk)
|
|
|
+ else output.push({ stream: 'stderr', text: chunk })
|
|
|
})
|
|
|
child.on('error', (error) => {
|
|
|
spawnError = `failed to start command: ${error.message}`
|
|
|
@@ -880,7 +924,7 @@ function printResult(result: GateResult): void {
|
|
|
console.error(`command: ${result.gate.displayCommand}`)
|
|
|
console.error(`outcome: ${formatGateResultReason(result)}`)
|
|
|
}
|
|
|
- printOutput(result.output)
|
|
|
+ if (result.gate.streamOutput !== true) printOutput(result.output)
|
|
|
}
|
|
|
|
|
|
function printSummary(results: GateResult[], durationMs: number): void {
|