forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +18 -7
- package/CHANGELOG.md +79 -1
- package/CLAUDE.md +0 -12
- package/CODING_STANDARDS.md +72 -0
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +28 -375
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +8 -5
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/INSTALL.md +4 -0
- package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/github-accounts.md +134 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +114 -35
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +359 -144
- package/lib/audit-evidence.js +71 -110
- package/lib/base-remote.js +138 -0
- package/lib/beta5-compatibility-evidence.js +1093 -0
- package/lib/bun-lockfile-proof.js +413 -0
- package/lib/bun-workflow-pins.js +461 -0
- package/lib/capabilities/index.js +9 -0
- package/lib/capabilities/model.js +141 -0
- package/lib/capabilities/probes.js +347 -0
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/codex-skills.js +2 -2
- package/lib/commands/_manifest.js +1 -0
- package/lib/commands/_registry.js +50 -20
- package/lib/commands/clean.js +252 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/doctor.js +37 -6
- package/lib/commands/gate.js +197 -27
- package/lib/commands/github.js +215 -0
- package/lib/commands/hooks.js +276 -30
- package/lib/commands/insights.js +8 -3
- package/lib/commands/memory.js +66 -2
- package/lib/commands/merge.js +1265 -58
- package/lib/commands/plan.js +33 -2
- package/lib/commands/pr.js +3 -1
- package/lib/commands/preflight.js +21 -4
- package/lib/commands/prime.js +21 -8
- package/lib/commands/push.js +146 -54
- package/lib/commands/recall.js +127 -49
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +39 -3
- package/lib/commands/remember.js +28 -4
- package/lib/commands/serve.js +26 -9
- package/lib/commands/setup.js +323 -98
- package/lib/commands/shepherd.js +591 -73
- package/lib/commands/ship.js +36 -91
- package/lib/commands/skill.js +127 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/team.js +47 -8
- package/lib/commands/test.js +187 -38
- package/lib/commands/validate.js +65 -21
- package/lib/commands/worktree.js +359 -45
- package/lib/core/runtime-graph.js +1 -1
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
- package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
- package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
- package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
- package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
- package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
- package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
- package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +134 -32
- package/lib/gate-events.js +98 -10
- package/lib/git-defaults.js +56 -0
- package/lib/github-context.js +308 -0
- package/lib/global-flags.js +1 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +122 -5
- package/lib/insights.js +96 -80
- package/lib/issue-render.js +19 -0
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +739 -31
- package/lib/kernel/claim-reconciler.js +238 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/lease-enforcer.js +9 -4
- package/lib/kernel/legacy-claim-repair.js +442 -0
- package/lib/kernel/live-claim-projection.js +26 -0
- package/lib/kernel/migrations.js +118 -3
- package/lib/kernel/readiness-model.js +184 -12
- package/lib/kernel/schema.js +49 -1
- package/lib/kernel/sqlite-driver.js +3435 -172
- package/lib/kernel/taxonomy-validator.js +4 -1
- package/lib/kernel/windows-private-acl.js +239 -0
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/hygiene.js +191 -0
- package/lib/memory/router.js +110 -28
- package/lib/memory/usage-evidence.js +4 -0
- package/lib/memory-digest.js +106 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +71 -10
- package/lib/merge-rules.js +143 -21
- package/lib/npm-publish-workflow.js +465 -0
- package/lib/orientation.js +68 -43
- package/lib/package-root.js +2 -0
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/auto-actions.js +169 -28
- package/lib/pr-monitor/differ.js +110 -4
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/flow-monitor.js +1424 -0
- package/lib/pr-monitor/gather.js +251 -44
- package/lib/pr-monitor/journal.js +18 -39
- package/lib/pr-monitor/monitor.js +117 -10
- package/lib/pr-monitor/process-identity.js +117 -0
- package/lib/pr-monitor/reconcile-executor.js +1129 -470
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +293 -0
- package/lib/pr-monitor/review-preflight.js +269 -0
- package/lib/pr-monitor/shepherd-lease.js +38 -20
- package/lib/pr-monitor/verdict.js +438 -0
- package/lib/pr-monitor/watch-lifecycle.js +145 -27
- package/lib/pr-monitor/watch-owner.js +1414 -0
- package/lib/pr-monitor/watch.js +129 -58
- package/lib/pr-pull.js +33 -14
- package/lib/pr-shepherd.js +51 -11
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +178 -4
- package/lib/protected-state-authority.js +1100 -0
- package/lib/protected-state-surfaces.js +243 -45
- package/lib/release-readiness.js +53 -7
- package/lib/review-adapter.js +65 -0
- package/lib/shell-utils.js +1 -1
- package/lib/skills-sync.js +71 -35
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/lib/validation/risk-manifest.js +339 -0
- package/lib/workflow/enforce-stage.js +44 -0
- package/lib/workflow/plan-authority.js +225 -0
- package/package.json +12 -9
- package/scripts/commitlint.js +13 -15
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/generate-risk-manifest.js +91 -0
- package/scripts/github-context-bridge.sh +10 -0
- package/scripts/legacy-claim-repair.js +145 -0
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +457 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +773 -0
- package/scripts/protected-state-check.js +479 -31
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-agent-skills.js +333 -34
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +935 -37
- package/scripts/test-profile.js +13 -3
- package/scripts/test.js +271 -57
- package/skills/coverage.json +1 -0
- package/skills/review/SKILL.md +6 -11
- package/skills/review/evals/scorecard.json +4 -4
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/setup/SKILL.md +18 -0
- package/skills/setup/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +39 -16
- package/skills/shepherd/evals/scorecard.json +4 -4
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/validate/SKILL.md +3 -0
- package/skills/validate/evals/scorecard.json +1 -1
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
|
@@ -5,22 +5,58 @@ const fs = require('node:fs');
|
|
|
5
5
|
const os = require('node:os');
|
|
6
6
|
const path = require('node:path');
|
|
7
7
|
const { spawn: defaultSpawn } = require('node:child_process');
|
|
8
|
+
const { stripVTControlCharacters } = require('node:util');
|
|
8
9
|
|
|
9
10
|
const {
|
|
10
11
|
createDurationMap,
|
|
11
12
|
getShardPlan,
|
|
12
13
|
getUnitTestRoots,
|
|
14
|
+
normalizePath,
|
|
13
15
|
readNewestProfile,
|
|
14
16
|
walkTests,
|
|
15
17
|
} = require('./test-ci-shard');
|
|
18
|
+
const {
|
|
19
|
+
buildProfile,
|
|
20
|
+
parseJUnitFiles,
|
|
21
|
+
parseJUnitTestcases,
|
|
22
|
+
walk: walkProfileFiles,
|
|
23
|
+
} = require('./test-profile');
|
|
24
|
+
const { createProcessTree, signalExitCode } = require('./process-tree');
|
|
25
|
+
const { stripGitHookEnv } = require('./test');
|
|
26
|
+
const { redact } = require('../lib/audit-evidence');
|
|
16
27
|
|
|
17
28
|
const rootDir = path.join(__dirname, '..');
|
|
18
29
|
const reportDir = path.join(rootDir, 'test-results');
|
|
30
|
+
const RESOURCE_LANES = new Set(['unit', 'subprocess', 'exclusive']);
|
|
31
|
+
const RESOURCE_LANE_RANK = new Map([
|
|
32
|
+
['unit', 0],
|
|
33
|
+
['subprocess', 1],
|
|
34
|
+
['exclusive', 2],
|
|
35
|
+
]);
|
|
36
|
+
const DEFAULT_SHARD_TIMEOUT_MS = 30000;
|
|
37
|
+
const STDERR_TAIL_LIMIT = 4096;
|
|
38
|
+
const STDERR_OMITTED_LINE = '[stderr line omitted]\n';
|
|
39
|
+
const JS_FAMILY_EXTENSIONS = ['.js', '.cjs', '.mjs', '.jsx', '.ts', '.cts', '.mts', '.tsx'];
|
|
40
|
+
const FORGE_COORDINATION_ENV = new Set([
|
|
41
|
+
'FORGE_ACTOR',
|
|
42
|
+
'FORGE_SESSION_ID',
|
|
43
|
+
'FORGE_WORKTREE_ID',
|
|
44
|
+
'FORGE_LEASE_TTL_MS',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
function stripFullSuiteChildEnv(env) {
|
|
48
|
+
const childEnv = stripGitHookEnv(env);
|
|
49
|
+
for (const key of Object.keys(childEnv)) {
|
|
50
|
+
if (FORGE_COORDINATION_ENV.has(key.toUpperCase())) delete childEnv[key];
|
|
51
|
+
}
|
|
52
|
+
return childEnv;
|
|
53
|
+
}
|
|
19
54
|
|
|
20
55
|
function parseArgs(argv) {
|
|
21
56
|
const args = {
|
|
22
57
|
labelPrefix: 'local-full',
|
|
23
58
|
shards: null,
|
|
59
|
+
timeoutMs: DEFAULT_SHARD_TIMEOUT_MS,
|
|
24
60
|
};
|
|
25
61
|
|
|
26
62
|
for (let index = 0; index < argv.length; index += 1) {
|
|
@@ -29,11 +65,20 @@ function parseArgs(argv) {
|
|
|
29
65
|
|
|
30
66
|
if (current === '--label-prefix') args.labelPrefix = next;
|
|
31
67
|
if (current === '--shards') args.shards = Number.parseInt(next, 10);
|
|
68
|
+
if (current === '--timeout') args.timeoutMs = parseTimeoutMs(next);
|
|
32
69
|
}
|
|
33
70
|
|
|
34
71
|
return args;
|
|
35
72
|
}
|
|
36
73
|
|
|
74
|
+
function parseTimeoutMs(value) {
|
|
75
|
+
const timeoutMs = Number(value);
|
|
76
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
77
|
+
throw new Error('--timeout must be a positive integer');
|
|
78
|
+
}
|
|
79
|
+
return timeoutMs;
|
|
80
|
+
}
|
|
81
|
+
|
|
37
82
|
function getDefaultShardCount(cpuCount = os.cpus().length) {
|
|
38
83
|
if (!Number.isInteger(cpuCount) || cpuCount <= 1) return 1;
|
|
39
84
|
return Math.max(2, Math.min(4, cpuCount - 1));
|
|
@@ -81,6 +126,29 @@ function walkAllTests(dir) {
|
|
|
81
126
|
return results;
|
|
82
127
|
}
|
|
83
128
|
|
|
129
|
+
function assertExactShardAssignment(allTests, shardSpecs) {
|
|
130
|
+
const expectedFiles = new Set(allTests);
|
|
131
|
+
const assignedFiles = new Set();
|
|
132
|
+
|
|
133
|
+
for (const shard of shardSpecs) {
|
|
134
|
+
for (const file of shard.files) {
|
|
135
|
+
if (!expectedFiles.has(file)) {
|
|
136
|
+
throw new Error(`Test file ${file} is not part of the full suite`);
|
|
137
|
+
}
|
|
138
|
+
if (assignedFiles.has(file)) {
|
|
139
|
+
throw new Error(`Test file ${file} belongs to more than exactly one shard`);
|
|
140
|
+
}
|
|
141
|
+
assignedFiles.add(file);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const file of expectedFiles) {
|
|
146
|
+
if (!assignedFiles.has(file)) {
|
|
147
|
+
throw new Error(`Test file ${file} was omitted from the shard assignment`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
84
152
|
function buildShardSpecs(allTests, shardTotal, durationMap = new Map()) {
|
|
85
153
|
const specs = [];
|
|
86
154
|
for (let shardIndex = 0; shardIndex < shardTotal; shardIndex += 1) {
|
|
@@ -100,63 +168,881 @@ function buildShardSpecs(allTests, shardTotal, durationMap = new Map()) {
|
|
|
100
168
|
source: plan.source,
|
|
101
169
|
});
|
|
102
170
|
}
|
|
171
|
+
assertExactShardAssignment(allTests, specs);
|
|
103
172
|
return specs;
|
|
104
173
|
}
|
|
105
174
|
|
|
175
|
+
function strongestResourceLane(left, right) {
|
|
176
|
+
return RESOURCE_LANE_RANK.get(left) >= RESOURCE_LANE_RANK.get(right) ? left : right;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function tokenizeResourceSyntax(source) {
|
|
180
|
+
const tokens = [];
|
|
181
|
+
let index = 0;
|
|
182
|
+
while (index < source.length) {
|
|
183
|
+
const current = source[index];
|
|
184
|
+
if (/\s/.test(current)) {
|
|
185
|
+
index += 1;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (current === '/' && source[index + 1] === '/') {
|
|
189
|
+
index = source.indexOf('\n', index + 2);
|
|
190
|
+
if (index === -1) break;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (current === '/' && source[index + 1] === '*') {
|
|
194
|
+
const end = source.indexOf('*/', index + 2);
|
|
195
|
+
index = end === -1 ? source.length : end + 2;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (current === '"' || current === "'") {
|
|
199
|
+
const quote = current;
|
|
200
|
+
let value = '';
|
|
201
|
+
index += 1;
|
|
202
|
+
while (index < source.length && source[index] !== quote) {
|
|
203
|
+
if (source[index] === '\\' && index + 1 < source.length) index += 1;
|
|
204
|
+
value += source[index];
|
|
205
|
+
index += 1;
|
|
206
|
+
}
|
|
207
|
+
index += 1;
|
|
208
|
+
tokens.push({ type: 'string', value });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (current === '`') {
|
|
212
|
+
let dynamic = false;
|
|
213
|
+
let value = '';
|
|
214
|
+
index += 1;
|
|
215
|
+
while (index < source.length && source[index] !== '`') {
|
|
216
|
+
if (source[index] === '\\' && index + 1 < source.length) {
|
|
217
|
+
index += 1;
|
|
218
|
+
} else if (source[index] === '$' && source[index + 1] === '{') {
|
|
219
|
+
dynamic = true;
|
|
220
|
+
}
|
|
221
|
+
value += source[index];
|
|
222
|
+
index += 1;
|
|
223
|
+
}
|
|
224
|
+
index += 1;
|
|
225
|
+
tokens.push({ type: dynamic ? 'dynamic-string' : 'string', value });
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (/[A-Za-z_$]/.test(current)) {
|
|
229
|
+
const start = index;
|
|
230
|
+
index += 1;
|
|
231
|
+
while (index < source.length && /[A-Za-z0-9_$]/.test(source[index])) index += 1;
|
|
232
|
+
tokens.push({ type: 'identifier', value: source.slice(start, index) });
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
tokens.push({ type: 'punctuator', value: current });
|
|
236
|
+
index += 1;
|
|
237
|
+
}
|
|
238
|
+
return tokens;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function inspectTestResourceSource(source, file, classifySource) {
|
|
242
|
+
const marker = source.match(/^\s*\/\/\s*forge-test-resource:\s*([^\s]+)\s*$/m);
|
|
243
|
+
let resource = 'unit';
|
|
244
|
+
if (marker) {
|
|
245
|
+
if (!RESOURCE_LANES.has(marker[1])) {
|
|
246
|
+
throw new Error(`Unknown full-suite resource lane ${marker[1]} in ${file}`);
|
|
247
|
+
}
|
|
248
|
+
resource = marker[1];
|
|
249
|
+
}
|
|
250
|
+
if (classifySource) {
|
|
251
|
+
const classified = classifySource(source, file);
|
|
252
|
+
if (!RESOURCE_LANES.has(classified)) {
|
|
253
|
+
throw new Error(`Unknown full-suite resource lane ${classified} in ${file}`);
|
|
254
|
+
}
|
|
255
|
+
resource = strongestResourceLane(resource, classified);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const imports = [];
|
|
259
|
+
const recordImport = (specifier) => {
|
|
260
|
+
if (specifier === 'child_process' || specifier === 'node:child_process') {
|
|
261
|
+
resource = strongestResourceLane(resource, 'subprocess');
|
|
262
|
+
} else if (specifier.startsWith('.')) {
|
|
263
|
+
imports.push(specifier);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
const tokens = tokenizeResourceSyntax(source);
|
|
267
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
268
|
+
const token = tokens[index];
|
|
269
|
+
const previous = tokens[index - 1];
|
|
270
|
+
const next = tokens[index + 1];
|
|
271
|
+
if (token.value === 'Bun'
|
|
272
|
+
&& next?.value === '.'
|
|
273
|
+
&& (tokens[index + 2]?.value === 'spawn' || tokens[index + 2]?.value === 'spawnSync')
|
|
274
|
+
&& tokens[index + 3]?.value === '(') {
|
|
275
|
+
resource = strongestResourceLane(resource, 'subprocess');
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if ((token.value === 'require' || token.value === 'import')
|
|
279
|
+
&& previous?.value !== '.'
|
|
280
|
+
&& next?.value === '(') {
|
|
281
|
+
const argument = tokens[index + 2];
|
|
282
|
+
if (argument?.type === 'string' && tokens[index + 3]?.value === ')') {
|
|
283
|
+
recordImport(argument.value);
|
|
284
|
+
} else {
|
|
285
|
+
resource = strongestResourceLane(resource, 'subprocess');
|
|
286
|
+
}
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (token.value !== 'import' && token.value !== 'export') continue;
|
|
290
|
+
if (next?.type === 'string') {
|
|
291
|
+
recordImport(next.value);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
for (let cursor = index + 1; cursor < tokens.length && tokens[cursor].value !== ';'; cursor += 1) {
|
|
295
|
+
if (tokens[cursor].value === 'from' && tokens[cursor + 1]?.type === 'string') {
|
|
296
|
+
recordImport(tokens[cursor + 1].value);
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return { imports, resource };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function isWithinRoot(root, target) {
|
|
305
|
+
const relative = path.relative(root, target);
|
|
306
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function createTestResourceClassifier(options = {}) {
|
|
310
|
+
const root = path.resolve(options.root || rootDir);
|
|
311
|
+
const realRoot = fs.realpathSync(root);
|
|
312
|
+
const readFile = options.readFile || ((target) => fs.readFileSync(target, 'utf8'));
|
|
313
|
+
const moduleCache = new Map();
|
|
314
|
+
const resolutionCache = new Map();
|
|
315
|
+
const resultCache = new Map();
|
|
316
|
+
|
|
317
|
+
const resolveLocalImport = (fromFile, specifier) => {
|
|
318
|
+
const cacheKey = `${fromFile}\0${specifier}`;
|
|
319
|
+
if (resolutionCache.has(cacheKey)) return resolutionCache.get(cacheKey);
|
|
320
|
+
const base = path.resolve(path.dirname(fromFile), specifier);
|
|
321
|
+
if (!isWithinRoot(realRoot, base)) {
|
|
322
|
+
resolutionCache.set(cacheKey, null);
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
const candidates = [
|
|
326
|
+
base,
|
|
327
|
+
...JS_FAMILY_EXTENSIONS.map((extension) => base + extension),
|
|
328
|
+
...JS_FAMILY_EXTENSIONS.map((extension) => path.join(base, `index${extension}`)),
|
|
329
|
+
];
|
|
330
|
+
for (const candidate of candidates) {
|
|
331
|
+
let stats;
|
|
332
|
+
try {
|
|
333
|
+
stats = fs.statSync(candidate);
|
|
334
|
+
} catch {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (!stats.isFile()) continue;
|
|
338
|
+
const resolved = fs.realpathSync(candidate);
|
|
339
|
+
if (!isWithinRoot(realRoot, resolved)) {
|
|
340
|
+
resolutionCache.set(cacheKey, null);
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
resolutionCache.set(cacheKey, resolved);
|
|
344
|
+
return resolved;
|
|
345
|
+
}
|
|
346
|
+
resolutionCache.set(cacheKey, null);
|
|
347
|
+
return null;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const inspectModule = (absoluteFile) => {
|
|
351
|
+
if (moduleCache.has(absoluteFile)) return moduleCache.get(absoluteFile);
|
|
352
|
+
let inspected;
|
|
353
|
+
try {
|
|
354
|
+
const source = readFile(absoluteFile);
|
|
355
|
+
if (typeof source !== 'string') throw new TypeError('resource source reader must return a string');
|
|
356
|
+
inspected = inspectTestResourceSource(source, path.relative(root, absoluteFile), options.classifySource);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (error && /Unknown full-suite resource lane/.test(error.message)) throw error;
|
|
359
|
+
inspected = { imports: [], resource: 'subprocess' };
|
|
360
|
+
}
|
|
361
|
+
moduleCache.set(absoluteFile, inspected);
|
|
362
|
+
return inspected;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const classifyModule = (absoluteFile, visiting) => {
|
|
366
|
+
if (resultCache.has(absoluteFile)) {
|
|
367
|
+
return { complete: true, resource: resultCache.get(absoluteFile) };
|
|
368
|
+
}
|
|
369
|
+
if (visiting.has(absoluteFile)) return { complete: false, resource: 'unit' };
|
|
370
|
+
|
|
371
|
+
visiting.add(absoluteFile);
|
|
372
|
+
const inspected = inspectModule(absoluteFile);
|
|
373
|
+
let resource = inspected.resource;
|
|
374
|
+
let complete = true;
|
|
375
|
+
if (resource !== 'exclusive') {
|
|
376
|
+
for (const specifier of inspected.imports) {
|
|
377
|
+
const resolved = resolveLocalImport(absoluteFile, specifier);
|
|
378
|
+
if (!resolved) {
|
|
379
|
+
resource = strongestResourceLane(resource, 'subprocess');
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const dependency = classifyModule(resolved, visiting);
|
|
383
|
+
resource = strongestResourceLane(resource, dependency.resource);
|
|
384
|
+
complete = complete && dependency.complete;
|
|
385
|
+
if (resource === 'exclusive') {
|
|
386
|
+
complete = true;
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
visiting.delete(absoluteFile);
|
|
392
|
+
if (complete) resultCache.set(absoluteFile, resource);
|
|
393
|
+
return { complete, resource };
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
return (file) => {
|
|
397
|
+
const absoluteFile = path.resolve(root, file);
|
|
398
|
+
if (!isWithinRoot(root, absoluteFile)) return 'subprocess';
|
|
399
|
+
let realFile;
|
|
400
|
+
try {
|
|
401
|
+
realFile = fs.realpathSync(absoluteFile);
|
|
402
|
+
} catch {
|
|
403
|
+
return 'subprocess';
|
|
404
|
+
}
|
|
405
|
+
if (!isWithinRoot(realRoot, realFile)) return 'subprocess';
|
|
406
|
+
return classifyModule(realFile, new Set()).resource;
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function classifyTestResource(file, options = {}) {
|
|
411
|
+
return createTestResourceClassifier(options)(file);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function loadTestResourceMap(allTests, options = {}) {
|
|
415
|
+
const classify = createTestResourceClassifier(options);
|
|
416
|
+
const uniqueFiles = [...new Set(allTests)];
|
|
417
|
+
const entries = uniqueFiles.map((file) => [file, classify(file)]);
|
|
418
|
+
return new Map(entries);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function buildResourceLanePlan(allTests, shardTotal, durationMap = new Map(), options = {}) {
|
|
422
|
+
const classify = options.classify || ((file) => classifyTestResource(file, options));
|
|
423
|
+
const subprocessShardTotal = Number.isInteger(options.subprocessShardTotal)
|
|
424
|
+
&& options.subprocessShardTotal > 0
|
|
425
|
+
? options.subprocessShardTotal
|
|
426
|
+
: shardTotal;
|
|
427
|
+
const buckets = {
|
|
428
|
+
exclusive: [],
|
|
429
|
+
subprocess: [],
|
|
430
|
+
unit: [],
|
|
431
|
+
};
|
|
432
|
+
for (const file of allTests) {
|
|
433
|
+
const resource = classify(file);
|
|
434
|
+
if (!RESOURCE_LANES.has(resource)) {
|
|
435
|
+
throw new Error(`Unknown full-suite resource lane ${resource} for ${file}`);
|
|
436
|
+
}
|
|
437
|
+
buckets[resource].push(file);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const lanes = [];
|
|
441
|
+
const addShardedLane = (name, shardCap, concurrencyCap = shardCap) => {
|
|
442
|
+
if (buckets[name].length === 0) return;
|
|
443
|
+
const shardCount = Math.min(shardCap, shardTotal, buckets[name].length);
|
|
444
|
+
lanes.push({
|
|
445
|
+
concurrency: Math.min(concurrencyCap, shardCount),
|
|
446
|
+
name,
|
|
447
|
+
shards: buildShardSpecs(buckets[name], shardCount, durationMap),
|
|
448
|
+
});
|
|
449
|
+
};
|
|
450
|
+
addShardedLane('unit', 4);
|
|
451
|
+
if (buckets.subprocess.length > 0) {
|
|
452
|
+
const shardCount = Math.min(subprocessShardTotal, buckets.subprocess.length);
|
|
453
|
+
lanes.push({
|
|
454
|
+
concurrency: Math.min(3, shardCount),
|
|
455
|
+
name: 'subprocess',
|
|
456
|
+
// Extra shards improve tail balancing without increasing the worker budget.
|
|
457
|
+
shards: buildShardSpecs(buckets.subprocess, shardCount, durationMap),
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
if (buckets.exclusive.length > 0) {
|
|
461
|
+
lanes.push({
|
|
462
|
+
concurrency: 1,
|
|
463
|
+
name: 'exclusive',
|
|
464
|
+
shards: buckets.exclusive.map((file) => ({ files: [file], source: 'exclusive' })),
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
let nextIndex = 0;
|
|
469
|
+
for (const lane of lanes) {
|
|
470
|
+
for (const shard of lane.shards) {
|
|
471
|
+
shard.index = nextIndex;
|
|
472
|
+
nextIndex += 1;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
assertExactShardAssignment(allTests, lanes.flatMap((lane) => lane.shards));
|
|
476
|
+
return lanes;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// A subprocess-lane worker owns two OS processes on Windows: the shard's own bun
|
|
480
|
+
// runtime plus the bun.exe grandchild its tests spawn. Counting such a worker as
|
|
481
|
+
// one budget unit oversubscribes small runners (3 workers is ~6 processes on 4
|
|
482
|
+
// vCPU), so weight the grant by real process cost instead of by worker count.
|
|
483
|
+
function laneWorkerCost(laneName, platform = process.platform) {
|
|
484
|
+
if (platform !== 'win32') return 1;
|
|
485
|
+
return laneName === 'unit' ? 1 : 2;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function computeLaneGrants(lanes, options = {}) {
|
|
489
|
+
const platform = options.platform || process.platform;
|
|
490
|
+
const workerBudget = Number.isInteger(options.workerBudget) && options.workerBudget > 0
|
|
491
|
+
? options.workerBudget
|
|
492
|
+
: null;
|
|
493
|
+
const sharedLanes = lanes.filter((lane) => lane.name !== 'exclusive');
|
|
494
|
+
const overlapsSubprocess = sharedLanes.some((lane) => lane.name === 'subprocess');
|
|
495
|
+
const grants = new Map();
|
|
496
|
+
for (const lane of lanes) {
|
|
497
|
+
grants.set(lane, {
|
|
498
|
+
cost: laneWorkerCost(lane.name, platform),
|
|
499
|
+
deferred: false,
|
|
500
|
+
granted: lane.concurrency,
|
|
501
|
+
workerBudget,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
if (workerBudget === null) {
|
|
505
|
+
for (const lane of sharedLanes) {
|
|
506
|
+
// One unit worker fills otherwise-idle CPU without recreating broad process pressure.
|
|
507
|
+
grants.get(lane).granted = overlapsSubprocess && lane.name === 'unit' ? 1 : lane.concurrency;
|
|
508
|
+
}
|
|
509
|
+
return grants;
|
|
510
|
+
}
|
|
511
|
+
// An explicit shard count is an operator-imposed cap on total concurrent
|
|
512
|
+
// children; reserve the budget for heavier subprocess workers first and
|
|
513
|
+
// defer leftover lanes until capacity frees instead of exceeding it.
|
|
514
|
+
const ordered = [...sharedLanes].sort(
|
|
515
|
+
(left, right) => (left.name === 'subprocess' ? 0 : 1) - (right.name === 'subprocess' ? 0 : 1),
|
|
516
|
+
);
|
|
517
|
+
let reservedCost = 0;
|
|
518
|
+
for (const [position, lane] of ordered.entries()) {
|
|
519
|
+
const entry = grants.get(lane);
|
|
520
|
+
const want = overlapsSubprocess && lane.name === 'unit' ? 1 : lane.concurrency;
|
|
521
|
+
const affordable = Math.floor(Math.max(0, workerBudget - reservedCost) / entry.cost);
|
|
522
|
+
// The strongest lane always keeps one worker so the suite can never stall at zero.
|
|
523
|
+
const granted = position === 0
|
|
524
|
+
? Math.max(1, Math.min(want, affordable))
|
|
525
|
+
: Math.min(want, affordable);
|
|
526
|
+
entry.granted = granted;
|
|
527
|
+
entry.deferred = granted === 0;
|
|
528
|
+
if (entry.deferred) {
|
|
529
|
+
entry.deferredConcurrency = Math.max(
|
|
530
|
+
1,
|
|
531
|
+
Math.min(lane.concurrency, Math.floor(workerBudget / entry.cost)),
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
reservedCost += granted * entry.cost;
|
|
535
|
+
}
|
|
536
|
+
return grants;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function runLaneSchedule(lanes, execute, cancel = () => {}, options = {}) {
|
|
540
|
+
const runLane = async (lane, concurrency = lane.concurrency, schedule = { stopped: false }) => {
|
|
541
|
+
const laneResults = new Array(lane.shards.length);
|
|
542
|
+
let nextIndex = 0;
|
|
543
|
+
let stopped = false;
|
|
544
|
+
const worker = async () => {
|
|
545
|
+
while (!stopped && !schedule.stopped) {
|
|
546
|
+
const index = nextIndex;
|
|
547
|
+
nextIndex += 1;
|
|
548
|
+
if (index >= lane.shards.length) return;
|
|
549
|
+
try {
|
|
550
|
+
laneResults[index] = await execute(lane.shards[index], lane);
|
|
551
|
+
} catch (error) {
|
|
552
|
+
stopped = true;
|
|
553
|
+
if (!schedule.stopped) {
|
|
554
|
+
schedule.stopped = true;
|
|
555
|
+
cancel(error);
|
|
556
|
+
}
|
|
557
|
+
throw error;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
const workers = Array.from(
|
|
562
|
+
{ length: Math.min(concurrency, lane.shards.length) },
|
|
563
|
+
() => worker(),
|
|
564
|
+
);
|
|
565
|
+
const settled = await Promise.allSettled(workers);
|
|
566
|
+
const failure = settled.find((result) => result.status === 'rejected');
|
|
567
|
+
if (failure) throw failure.reason;
|
|
568
|
+
return laneResults;
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const resultsByLane = new Map();
|
|
572
|
+
const sharedLanes = lanes.filter((lane) => lane.name !== 'exclusive');
|
|
573
|
+
const sharedSchedule = { stopped: false };
|
|
574
|
+
const grants = options.grants || computeLaneGrants(lanes, options);
|
|
575
|
+
const deferredLanes = sharedLanes.filter((lane) => grants.get(lane).deferred);
|
|
576
|
+
const immediateLanes = sharedLanes.filter((lane) => !grants.get(lane).deferred);
|
|
577
|
+
const settledSharedLanes = await Promise.allSettled(immediateLanes.map(async (lane) => {
|
|
578
|
+
resultsByLane.set(lane, await runLane(lane, grants.get(lane).granted, sharedSchedule));
|
|
579
|
+
}));
|
|
580
|
+
const sharedFailure = settledSharedLanes.find((result) => result.status === 'rejected');
|
|
581
|
+
if (sharedFailure) throw sharedFailure.reason;
|
|
582
|
+
// Budget-exhausted lanes start only after reserved capacity has been released.
|
|
583
|
+
const settledDeferredLanes = await Promise.allSettled(deferredLanes.map(async (lane) => {
|
|
584
|
+
resultsByLane.set(lane, await runLane(lane, grants.get(lane).deferredConcurrency, sharedSchedule));
|
|
585
|
+
}));
|
|
586
|
+
const deferredFailure = settledDeferredLanes.find((result) => result.status === 'rejected');
|
|
587
|
+
if (deferredFailure) throw deferredFailure.reason;
|
|
588
|
+
|
|
589
|
+
for (const lane of lanes.filter((candidate) => candidate.name === 'exclusive')) {
|
|
590
|
+
resultsByLane.set(lane, await runLane(lane));
|
|
591
|
+
}
|
|
592
|
+
return lanes.flatMap((lane) => {
|
|
593
|
+
const laneResults = resultsByLane.get(lane);
|
|
594
|
+
if (!laneResults) throw new Error(`Missing results for resource lane ${lane.name}`);
|
|
595
|
+
return laneResults;
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function buildShardTestArgs({
|
|
600
|
+
junitPath,
|
|
601
|
+
files,
|
|
602
|
+
root = rootDir,
|
|
603
|
+
timeoutMs = DEFAULT_SHARD_TIMEOUT_MS,
|
|
604
|
+
}) {
|
|
605
|
+
return [
|
|
606
|
+
'test',
|
|
607
|
+
'--timeout',
|
|
608
|
+
String(parseTimeoutMs(timeoutMs)),
|
|
609
|
+
'--reporter=junit',
|
|
610
|
+
'--reporter-outfile',
|
|
611
|
+
junitPath,
|
|
612
|
+
...files.map((file) => path.resolve(root, file)),
|
|
613
|
+
];
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function parseShardReceipt(output) {
|
|
617
|
+
if (typeof output !== 'string') return null;
|
|
618
|
+
const root = output.match(/^\s*(?:<\?xml\b[^?]*\?>\s*)?<testsuites\b([^>]*)>[\s\S]*<\/testsuites\s*>\s*$/);
|
|
619
|
+
const openingTags = output.match(/<testsuites\b/g) || [];
|
|
620
|
+
const closingTags = output.match(/<\/testsuites\s*>/g) || [];
|
|
621
|
+
if (!root || openingTags.length !== 1 || closingTags.length !== 1) return null;
|
|
622
|
+
|
|
623
|
+
const readAttribute = (name) => root[1].match(new RegExp('\\b' + name + '="(\\d+)"'));
|
|
624
|
+
const requiredAttributes = ['tests', 'assertions', 'failures', 'skipped'];
|
|
625
|
+
const values = requiredAttributes.map(readAttribute);
|
|
626
|
+
const hasInvalidRequiredAttribute = requiredAttributes.some((name, index) => {
|
|
627
|
+
const occurrences = root[1].match(new RegExp('\\b' + name + '\\s*=', 'g')) || [];
|
|
628
|
+
return occurrences.length !== 1 || !values[index];
|
|
629
|
+
});
|
|
630
|
+
const errorsOccurrences = root[1].match(/\berrors\s*=/g) || [];
|
|
631
|
+
const errorsAttribute = readAttribute('errors');
|
|
632
|
+
if (hasInvalidRequiredAttribute
|
|
633
|
+
|| errorsOccurrences.length > 1
|
|
634
|
+
|| (errorsOccurrences.length === 1 && !errorsAttribute)) return null;
|
|
635
|
+
|
|
636
|
+
const tests = Number.parseInt(values[0][1], 10);
|
|
637
|
+
const assertions = Number.parseInt(values[1][1], 10);
|
|
638
|
+
const failed = Number.parseInt(values[2][1], 10);
|
|
639
|
+
const errors = Number.parseInt(errorsAttribute?.[1] || '0', 10);
|
|
640
|
+
const skipped = Number.parseInt(values[3][1], 10);
|
|
641
|
+
const passed = tests - failed - errors - skipped;
|
|
642
|
+
if (tests === 0 || passed < 0) return null;
|
|
643
|
+
return { assertions, errors, failed, passed, skipped, tests };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function extractFailedTestCases(output, limit = 20) {
|
|
647
|
+
const failures = [];
|
|
648
|
+
for (const { attrs, body } of parseJUnitTestcases(output)) {
|
|
649
|
+
const type = body.match(/<(failure|error)\b/)?.[1];
|
|
650
|
+
if (!type) continue;
|
|
651
|
+
failures.push({
|
|
652
|
+
file: attrs.file || attrs.classname || 'unknown',
|
|
653
|
+
line: attrs.line || '',
|
|
654
|
+
name: attrs.name || 'unknown',
|
|
655
|
+
type,
|
|
656
|
+
});
|
|
657
|
+
if (failures.length >= limit) break;
|
|
658
|
+
}
|
|
659
|
+
return failures;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function classifyShardFailure(result) {
|
|
663
|
+
if (result?.signal) return 'signal';
|
|
664
|
+
const output = typeof result?.output === 'string' ? result.output : '';
|
|
665
|
+
const stderrTail = typeof result?.stderrTail === 'string' ? result.stderrTail : '';
|
|
666
|
+
const parsed = parseShardReceipt(output);
|
|
667
|
+
const failedReceipt = parsed && (parsed.failed > 0 || parsed.errors > 0);
|
|
668
|
+
const hasTimeoutDiagnostic = (value) => /\b(?:etimedout|timed?\s*out|timeout\s*(?:error|exception))\b/i.test(value);
|
|
669
|
+
const failedOutput = failedReceipt
|
|
670
|
+
? parseJUnitTestcases(output)
|
|
671
|
+
.map(({ body }) => body)
|
|
672
|
+
.filter(body => /<(?:failure|error)\b/.test(body))
|
|
673
|
+
.join('\n')
|
|
674
|
+
: '';
|
|
675
|
+
const stderrDiagnostics = stripVTControlCharacters(stderrTail).split(/\r?\n/)
|
|
676
|
+
.filter(line => !/^\s*\((?:pass|fail|skip|todo)\)(?:\s|$)/i.test(line))
|
|
677
|
+
.join('\n');
|
|
678
|
+
if (hasTimeoutDiagnostic(failedOutput) || hasTimeoutDiagnostic(stderrDiagnostics)) return 'test-timeout';
|
|
679
|
+
if (failedReceipt) return 'test-failure';
|
|
680
|
+
if (parsed) return 'post-junit-exit';
|
|
681
|
+
return 'incomplete-receipt';
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function createStderrTailCollector() {
|
|
685
|
+
let tail = '';
|
|
686
|
+
let pending = '';
|
|
687
|
+
let discardingLine = false;
|
|
688
|
+
const append = (value) => {
|
|
689
|
+
tail = (tail + value).slice(-STDERR_TAIL_LIMIT);
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
return {
|
|
693
|
+
write(chunk) {
|
|
694
|
+
let text = String(chunk);
|
|
695
|
+
if (discardingLine) {
|
|
696
|
+
const newline = text.indexOf('\n');
|
|
697
|
+
if (newline === -1) return;
|
|
698
|
+
text = text.slice(newline + 1);
|
|
699
|
+
discardingLine = false;
|
|
700
|
+
}
|
|
701
|
+
pending += text;
|
|
702
|
+
while (pending) {
|
|
703
|
+
const newline = pending.indexOf('\n');
|
|
704
|
+
if (newline !== -1) {
|
|
705
|
+
const line = pending.slice(0, newline + 1);
|
|
706
|
+
pending = pending.slice(newline + 1);
|
|
707
|
+
append(line.length > STDERR_TAIL_LIMIT ? STDERR_OMITTED_LINE : redact(line));
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
if (pending.length > STDERR_TAIL_LIMIT) {
|
|
711
|
+
pending = '';
|
|
712
|
+
discardingLine = true;
|
|
713
|
+
append(STDERR_OMITTED_LINE);
|
|
714
|
+
}
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
717
|
+
},
|
|
718
|
+
value() {
|
|
719
|
+
if (!discardingLine && pending) append(redact(pending));
|
|
720
|
+
pending = '';
|
|
721
|
+
return tail;
|
|
722
|
+
},
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function writeDurationProfile({ allTests, label, outputPath, runReportDir }) {
|
|
727
|
+
const files = walkProfileFiles(runReportDir, '.xml');
|
|
728
|
+
if (files.length === 0) return false;
|
|
729
|
+
const metrics = parseJUnitFiles(files);
|
|
730
|
+
const measured = new Set(metrics.allFileDurations.map((entry) => normalizePath(entry.file)));
|
|
731
|
+
if (!allTests.every((file) => measured.has(normalizePath(file)))) return false;
|
|
732
|
+
|
|
733
|
+
const profile = buildProfile({ integrationSkipped: false, label: label || 'local-full' }, metrics);
|
|
734
|
+
const target = path.resolve(outputPath);
|
|
735
|
+
const temporary = `${target}.tmp-${process.pid}`;
|
|
736
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
737
|
+
try {
|
|
738
|
+
fs.writeFileSync(temporary, JSON.stringify(profile, null, 2));
|
|
739
|
+
fs.renameSync(temporary, target);
|
|
740
|
+
} finally {
|
|
741
|
+
fs.rmSync(temporary, { force: true });
|
|
742
|
+
}
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function aggregateShardReceipts(receipts, expectedCount) {
|
|
747
|
+
const totals = {
|
|
748
|
+
assertions: 0,
|
|
749
|
+
errors: 0,
|
|
750
|
+
failed: 0,
|
|
751
|
+
passed: 0,
|
|
752
|
+
skipped: 0,
|
|
753
|
+
tests: 0,
|
|
754
|
+
};
|
|
755
|
+
const seen = new Set();
|
|
756
|
+
let incomplete = receipts.length !== expectedCount;
|
|
757
|
+
let failedProcess = false;
|
|
758
|
+
|
|
759
|
+
for (const receipt of receipts) {
|
|
760
|
+
if (receipt === null || typeof receipt !== 'object'
|
|
761
|
+
|| !Number.isInteger(receipt.index)
|
|
762
|
+
|| receipt.index < 0
|
|
763
|
+
|| receipt.index >= expectedCount
|
|
764
|
+
|| seen.has(receipt.index)) {
|
|
765
|
+
incomplete = true;
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
seen.add(receipt.index);
|
|
769
|
+
if (!Number.isInteger(receipt.code)) {
|
|
770
|
+
incomplete = true;
|
|
771
|
+
} else {
|
|
772
|
+
failedProcess ||= receipt.code !== 0;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const parsed = parseShardReceipt(receipt.output);
|
|
776
|
+
if (!parsed) {
|
|
777
|
+
incomplete = true;
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
for (const key of Object.keys(totals)) totals[key] += parsed[key];
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
incomplete ||= seen.size !== expectedCount;
|
|
784
|
+
const status = incomplete
|
|
785
|
+
? 'INCOMPLETE'
|
|
786
|
+
: (failedProcess || totals.failed > 0 || totals.errors > 0 ? 'FAIL' : 'PASS');
|
|
787
|
+
return { ...totals, exitCode: status === 'PASS' ? 0 : 1, status };
|
|
788
|
+
}
|
|
789
|
+
|
|
106
790
|
function spawnShard(shard, options = {}) {
|
|
107
791
|
const spawn = options.spawn || defaultSpawn;
|
|
108
792
|
const env = options.env || process.env;
|
|
109
793
|
const bunCommand = options.bunCommand || env.BUN_EXE || process.env.BUN_EXE || 'bun';
|
|
110
794
|
const labelPrefix = options.labelPrefix || 'local-full';
|
|
111
|
-
|
|
112
|
-
const
|
|
795
|
+
const targetReportDir = options.reportDirectory || reportDir;
|
|
796
|
+
const platform = options.platform || process.platform;
|
|
797
|
+
const processTree = options.processTree || createProcessTree({ env, platform });
|
|
798
|
+
const stderrStream = options.stderrStream || process.stderr;
|
|
799
|
+
const resolvedReportDir = path.resolve(targetReportDir);
|
|
800
|
+
const junitPath = path.resolve(resolvedReportDir, `${labelPrefix}-shard-${shard.index}.xml`);
|
|
801
|
+
if (path.dirname(junitPath) !== resolvedReportDir) {
|
|
802
|
+
return Promise.reject(new Error('label prefix must produce a receipt directly inside test-results'));
|
|
803
|
+
}
|
|
804
|
+
fs.mkdirSync(resolvedReportDir, { recursive: true });
|
|
113
805
|
|
|
114
806
|
return new Promise((resolve, reject) => {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
'
|
|
118
|
-
|
|
119
|
-
'--reporter=junit',
|
|
120
|
-
'--reporter-outfile',
|
|
121
|
-
junitPath,
|
|
122
|
-
...shard.files,
|
|
123
|
-
], {
|
|
124
|
-
cwd: rootDir,
|
|
125
|
-
env,
|
|
126
|
-
shell: false,
|
|
127
|
-
stdio: 'inherit',
|
|
807
|
+
const reservation = processTree.reserveChild({
|
|
808
|
+
command: bunCommand,
|
|
809
|
+
kind: 'test-shard',
|
|
810
|
+
label: `${labelPrefix}-shard-${shard.index}`,
|
|
128
811
|
});
|
|
812
|
+
if (!reservation) {
|
|
813
|
+
reject(new Error('test shard ownership manifest is unavailable'));
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
129
816
|
|
|
130
|
-
child
|
|
131
|
-
|
|
132
|
-
|
|
817
|
+
let child;
|
|
818
|
+
let settled = false;
|
|
819
|
+
let stderrDrainListener = null;
|
|
820
|
+
const stderrTail = createStderrTailCollector();
|
|
821
|
+
const clearStderrDrainListener = () => {
|
|
822
|
+
if (!stderrDrainListener) return;
|
|
823
|
+
stderrStream.off('drain', stderrDrainListener);
|
|
824
|
+
stderrDrainListener = null;
|
|
825
|
+
};
|
|
826
|
+
const finish = (code, output, signal) => {
|
|
827
|
+
if (settled) return;
|
|
828
|
+
settled = true;
|
|
829
|
+
clearStderrDrainListener();
|
|
830
|
+
processTree.unregisterChild(reservation);
|
|
831
|
+
const result = { code, index: shard.index, output };
|
|
832
|
+
if (code !== 0) {
|
|
833
|
+
if (signal) result.signal = signal;
|
|
834
|
+
const retainedStderr = stderrTail.value();
|
|
835
|
+
if (retainedStderr) result.stderrTail = retainedStderr;
|
|
836
|
+
}
|
|
837
|
+
resolve(result);
|
|
838
|
+
};
|
|
839
|
+
try {
|
|
840
|
+
fs.rmSync(junitPath, { force: true });
|
|
841
|
+
child = spawn(bunCommand, buildShardTestArgs({
|
|
842
|
+
junitPath,
|
|
843
|
+
files: shard.files,
|
|
844
|
+
timeoutMs: options.timeoutMs,
|
|
845
|
+
}), {
|
|
846
|
+
cwd: rootDir,
|
|
847
|
+
env,
|
|
848
|
+
shell: false,
|
|
849
|
+
stdio: ['inherit', 'inherit', 'pipe'],
|
|
850
|
+
detached: platform !== 'win32',
|
|
851
|
+
windowsHide: true,
|
|
852
|
+
});
|
|
853
|
+
child.on('error', (error) => {
|
|
854
|
+
if (settled) return;
|
|
855
|
+
settled = true;
|
|
856
|
+
clearStderrDrainListener();
|
|
857
|
+
processTree.unregisterChild(reservation);
|
|
858
|
+
reject(error);
|
|
859
|
+
});
|
|
860
|
+
if (!processTree.registerChild(reservation, child)) {
|
|
861
|
+
settled = true;
|
|
862
|
+
if (typeof processTree.abortChild === 'function') {
|
|
863
|
+
processTree.abortChild(reservation, child);
|
|
864
|
+
} else {
|
|
865
|
+
try {
|
|
866
|
+
child.kill?.('SIGKILL');
|
|
867
|
+
} finally {
|
|
868
|
+
processTree.cleanup?.('SIGKILL');
|
|
869
|
+
processTree.unregisterChild(reservation);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
reject(new Error('test shard process could not be registered'));
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
child.stderr?.on('data', (chunk) => {
|
|
876
|
+
const canContinue = stderrStream.write(chunk);
|
|
877
|
+
stderrTail.write(chunk);
|
|
878
|
+
if (
|
|
879
|
+
canContinue === false
|
|
880
|
+
&& !stderrDrainListener
|
|
881
|
+
&& typeof child.stderr.pause === 'function'
|
|
882
|
+
&& typeof child.stderr.resume === 'function'
|
|
883
|
+
&& typeof stderrStream.once === 'function'
|
|
884
|
+
&& typeof stderrStream.off === 'function'
|
|
885
|
+
) {
|
|
886
|
+
child.stderr.pause();
|
|
887
|
+
stderrDrainListener = () => {
|
|
888
|
+
stderrDrainListener = null;
|
|
889
|
+
if (!settled) child.stderr.resume();
|
|
890
|
+
};
|
|
891
|
+
stderrStream.once('drain', stderrDrainListener);
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
} catch (error) {
|
|
895
|
+
if (!settled) processTree.unregisterChild(reservation);
|
|
896
|
+
reject(error);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
child.on('close', (code, signal) => {
|
|
901
|
+
let output = null;
|
|
902
|
+
try {
|
|
903
|
+
output = fs.readFileSync(junitPath, 'utf8');
|
|
904
|
+
} catch {}
|
|
905
|
+
finish(code ?? 1, output, signal);
|
|
133
906
|
});
|
|
134
907
|
});
|
|
135
908
|
}
|
|
136
909
|
|
|
137
910
|
async function runFullSuiteInParallel(args = {}, deps = {}) {
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
911
|
+
const env = deps.env || process.env;
|
|
912
|
+
const platform = deps.platform || process.platform;
|
|
913
|
+
const processTree = deps.processTree || createProcessTree({ env, platform });
|
|
914
|
+
let signal = null;
|
|
915
|
+
let completed = false;
|
|
916
|
+
let scheduleCancelled = false;
|
|
917
|
+
const removeSignalHandlers = processTree.installSignalHandlers((received) => {
|
|
918
|
+
signal = received;
|
|
919
|
+
});
|
|
145
920
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
921
|
+
try {
|
|
922
|
+
const allTests = deps.allTests || listAllFullSuiteTests();
|
|
923
|
+
const shardTotal = Number.isInteger(args.shards) && args.shards > 0
|
|
924
|
+
? args.shards
|
|
925
|
+
: getDefaultShardCount(deps.cpuCount);
|
|
926
|
+
const subprocessShardTotal = Number.isInteger(args.shards) && args.shards > 0
|
|
927
|
+
? shardTotal
|
|
928
|
+
: Math.max(6, shardTotal);
|
|
929
|
+
const profile = deps.profile || readNewestProfile(reportDir);
|
|
930
|
+
const durationMap = deps.durationMap || createDurationMap(profile);
|
|
931
|
+
const resourceMap = deps.classify
|
|
932
|
+
? null
|
|
933
|
+
: await loadTestResourceMap(allTests, {
|
|
934
|
+
classifySource: deps.classifySource,
|
|
935
|
+
readFile: deps.readFile,
|
|
936
|
+
root: deps.root || rootDir,
|
|
937
|
+
});
|
|
938
|
+
const lanePlan = buildResourceLanePlan(allTests, shardTotal, durationMap, {
|
|
939
|
+
classify: deps.classify || ((file) => resourceMap.get(file)),
|
|
940
|
+
subprocessShardTotal,
|
|
941
|
+
});
|
|
942
|
+
const shardSpecs = lanePlan.flatMap((lane) => lane.shards);
|
|
943
|
+
|
|
944
|
+
if (shardSpecs.length === 0) {
|
|
945
|
+
const exitCode = signal ? signalExitCode(signal) : 1;
|
|
946
|
+
console.log('Full suite aggregate: status=INCOMPLETE tests=0 assertions=0 passed=0 failed=0 errors=0 skipped=0');
|
|
947
|
+
console.log('Full suite exit: ' + exitCode);
|
|
948
|
+
completed = true;
|
|
949
|
+
return exitCode;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
fs.mkdirSync(reportDir, { recursive: true });
|
|
953
|
+
const runReportDir = fs.mkdtempSync(path.join(reportDir, 'full-suite-'));
|
|
150
954
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
955
|
+
console.log(`Running local full suite in ${shardSpecs.length} shard(s)`);
|
|
956
|
+
const laneGrants = computeLaneGrants(lanePlan, { platform, workerBudget: shardTotal });
|
|
957
|
+
for (const lane of lanePlan) {
|
|
958
|
+
const grant = laneGrants.get(lane);
|
|
959
|
+
const granted = grant.deferred ? grant.deferredConcurrency : grant.granted;
|
|
960
|
+
const files = lane.shards.reduce((total, shard) => total + shard.files.length, 0);
|
|
961
|
+
console.log(`Resource lane ${lane.name}: files=${files} shards=${lane.shards.length} concurrency=${granted} (nominal=${lane.concurrency} cost=${grant.cost} budget=${shardTotal}${grant.deferred ? ' deferred' : ''})`);
|
|
962
|
+
}
|
|
963
|
+
const childEnv = stripFullSuiteChildEnv(
|
|
964
|
+
typeof processTree.envFor === 'function' ? processTree.envFor(env) : env,
|
|
965
|
+
);
|
|
966
|
+
let results;
|
|
967
|
+
try {
|
|
968
|
+
const nodeExecutable = deps.nodeExecutable ?? (
|
|
969
|
+
process.versions.bun ? globalThis.Bun?.which?.('node') : process.execPath
|
|
970
|
+
);
|
|
971
|
+
if (typeof nodeExecutable !== 'string' || !path.isAbsolute(nodeExecutable)) {
|
|
972
|
+
throw new Error('Full suite requires an absolute Node executable');
|
|
973
|
+
}
|
|
974
|
+
childEnv.FORGE_TEST_NODE_EXECUTABLE = nodeExecutable;
|
|
975
|
+
results = await runLaneSchedule(lanePlan, async (shard, lane) => ({
|
|
976
|
+
...await spawnShard(shard, {
|
|
977
|
+
bunCommand: deps.bunCommand,
|
|
978
|
+
env: childEnv,
|
|
979
|
+
labelPrefix: args.labelPrefix,
|
|
980
|
+
reportDirectory: runReportDir,
|
|
981
|
+
spawn: deps.spawn,
|
|
982
|
+
platform,
|
|
983
|
+
processTree,
|
|
984
|
+
stderrStream: deps.stderrStream,
|
|
985
|
+
timeoutMs: args.timeoutMs,
|
|
986
|
+
}),
|
|
987
|
+
resource: lane.name,
|
|
988
|
+
}), () => {
|
|
989
|
+
if (scheduleCancelled) return;
|
|
990
|
+
scheduleCancelled = true;
|
|
991
|
+
processTree.cleanup('SIGKILL');
|
|
992
|
+
}, {
|
|
993
|
+
grants: laneGrants,
|
|
994
|
+
platform,
|
|
995
|
+
workerBudget: shardTotal,
|
|
996
|
+
});
|
|
997
|
+
} catch (error) {
|
|
998
|
+
console.error('Full suite shard execution failed:', error);
|
|
999
|
+
const exitCode = signal ? signalExitCode(signal) : 1;
|
|
1000
|
+
console.log('Full suite aggregate: status=INCOMPLETE tests=0 assertions=0 passed=0 failed=0 errors=0 skipped=0');
|
|
1001
|
+
console.log('Full suite exit: ' + exitCode);
|
|
1002
|
+
return exitCode;
|
|
1003
|
+
}
|
|
158
1004
|
|
|
159
|
-
|
|
1005
|
+
const nonzeroShards = results.filter((result) => result.code !== 0);
|
|
1006
|
+
if (nonzeroShards.length > 0) {
|
|
1007
|
+
console.error(`Full suite non-zero shards: ${nonzeroShards.map((result) => {
|
|
1008
|
+
let diagnostic = `${result.index}:${result.resource}:exit=${result.code} cause=${classifyShardFailure(result)}`;
|
|
1009
|
+
if (result.signal) diagnostic += ` signal=${result.signal}`;
|
|
1010
|
+
const stderrTail = result.stderrTail?.trim();
|
|
1011
|
+
if (stderrTail) diagnostic += ` stderr=${JSON.stringify(stderrTail)}`;
|
|
1012
|
+
return diagnostic;
|
|
1013
|
+
}).join(', ')}`);
|
|
1014
|
+
const failedTests = nonzeroShards.flatMap((result) => extractFailedTestCases(result.output));
|
|
1015
|
+
if (failedTests.length > 0) {
|
|
1016
|
+
console.error(`Full suite failing tests: ${failedTests.map((failure) => `${failure.file}${failure.line ? `:${failure.line}` : ''} (${failure.name})`).join(', ')}`);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
const aggregate = aggregateShardReceipts(results, shardSpecs.length);
|
|
1020
|
+
const exitCode = signal ? signalExitCode(signal) : aggregate.exitCode;
|
|
1021
|
+
if (signal) aggregate.status = 'INCOMPLETE';
|
|
1022
|
+
if (aggregate.status !== 'INCOMPLETE') {
|
|
1023
|
+
const labelPrefix = args.labelPrefix || 'local-full';
|
|
1024
|
+
try {
|
|
1025
|
+
(deps.writeDurationProfile || writeDurationProfile)({
|
|
1026
|
+
allTests,
|
|
1027
|
+
label: labelPrefix,
|
|
1028
|
+
outputPath: deps.profileOutputPath || path.join(reportDir, `${labelPrefix}.profile.json`),
|
|
1029
|
+
runReportDir,
|
|
1030
|
+
});
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
console.warn(`Full suite profile was not updated: ${error.message}`);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
if (aggregate.status === 'PASS' && exitCode === 0) {
|
|
1036
|
+
fs.rmSync(runReportDir, { force: true, recursive: true });
|
|
1037
|
+
}
|
|
1038
|
+
console.log(`Full suite aggregate: status=${aggregate.status} tests=${aggregate.tests} assertions=${aggregate.assertions} passed=${aggregate.passed} failed=${aggregate.failed} errors=${aggregate.errors} skipped=${aggregate.skipped}`);
|
|
1039
|
+
console.log(`Full suite exit: ${exitCode}`);
|
|
1040
|
+
completed = true;
|
|
1041
|
+
return exitCode;
|
|
1042
|
+
} finally {
|
|
1043
|
+
removeSignalHandlers();
|
|
1044
|
+
if (!scheduleCancelled) processTree.cleanup(signal || !completed ? 'SIGKILL' : 'SIGTERM');
|
|
1045
|
+
}
|
|
160
1046
|
}
|
|
161
1047
|
|
|
162
1048
|
async function main(argv = process.argv.slice(2), deps = {}) {
|
|
@@ -175,12 +1061,24 @@ if (require.main === module) {
|
|
|
175
1061
|
}
|
|
176
1062
|
|
|
177
1063
|
module.exports = {
|
|
1064
|
+
aggregateShardReceipts,
|
|
1065
|
+
assertExactShardAssignment,
|
|
1066
|
+
buildResourceLanePlan,
|
|
1067
|
+
buildShardTestArgs,
|
|
178
1068
|
buildShardSpecs,
|
|
1069
|
+
classifyShardFailure,
|
|
1070
|
+
classifyTestResource,
|
|
1071
|
+
computeLaneGrants,
|
|
1072
|
+
extractFailedTestCases,
|
|
1073
|
+
laneWorkerCost,
|
|
179
1074
|
getDefaultShardCount,
|
|
180
1075
|
listAllFullSuiteTests,
|
|
1076
|
+
loadTestResourceMap,
|
|
181
1077
|
main,
|
|
182
1078
|
parseArgs,
|
|
1079
|
+
runLaneSchedule,
|
|
183
1080
|
runFullSuiteInParallel,
|
|
184
1081
|
spawnShard,
|
|
185
1082
|
walkAllTests,
|
|
1083
|
+
writeDurationProfile,
|
|
186
1084
|
};
|