@marcomigozzi/trailhead 0.16.1 → 0.18.0
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/README.it.md +82 -25
- package/README.md +82 -25
- package/bin/base-preflight-docs.test.js +4 -1
- package/bin/bulk-cross-reference-docs.test.js +36 -0
- package/bin/capture-action.test.js +167 -0
- package/bin/capture-docs.test.js +32 -0
- package/bin/engine-route-docs.test.js +155 -0
- package/bin/facet-stamp-docs.test.js +79 -0
- package/bin/forced-mutation-docs.test.js +38 -0
- package/bin/grill-model-docs.test.js +155 -0
- package/bin/grilling-holistic-review-docs.test.js +7 -3
- package/bin/guard-axis-docs.test.js +69 -0
- package/bin/inbox-needs-info-docs.test.js +68 -0
- package/bin/lib/codex-projection.js +28 -19
- package/bin/lib/codex-projection.test.js +77 -20
- package/bin/lib/host-descriptor.js +1 -1
- package/bin/lib/host-descriptor.test.js +6 -0
- package/bin/needs-you-docs.test.js +9 -2
- package/bin/options-in-chat-docs.test.js +65 -0
- package/bin/pick-stress-test-docs.test.js +1 -1
- package/bin/pipeline-review-docs.test.js +118 -0
- package/bin/plan-review-budget-docs.test.js +63 -0
- package/bin/repo-target-docs.test.js +41 -0
- package/bin/report-status-docs.test.js +10 -2
- package/bin/seam-coverage-docs.test.js +46 -0
- package/bin/session-trust-docs.test.js +37 -0
- package/bin/standards-brief-docs.test.js +112 -0
- package/bin/surfaced-scope-docs.test.js +57 -0
- package/bin/test-once-docs.test.js +101 -0
- package/bin/test-run-docs.test.js +47 -0
- package/bin/trailhead.js +15 -8
- package/bin/trailhead.test.js +71 -17
- package/bin/unblock-docs.test.js +56 -0
- package/bin/yes-accepts-recommended-docs.test.js +54 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
- package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
- package/plugins/trailhead/agents/trailhead-grill.md +41 -0
- package/plugins/trailhead/agents/trailhead-plan.md +1 -1
- package/plugins/trailhead/agents/trailhead-verify.md +1 -0
- package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
- package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
- package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
- package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
- package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
- package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
- package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
- package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
- package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
- package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
- package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
- package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
- package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +165 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +22 -1
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +64 -1
- package/plugins/trailhead/hooks/lib/model-route.js +207 -0
- package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
- package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +56 -1
- package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
- package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
- package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
- package/plugins/trailhead/hooks/mod/README.md +42 -17
- package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
- package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
- package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
- package/plugins/trailhead/hooks/mod/map-pane.tsx +204 -49
- package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
- package/plugins/trailhead/hooks/mod/repo-pin.ts +113 -9
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
- package/plugins/trailhead/hooks/trailhead-check-update.js +20 -2
- package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
- package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
- package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
- package/plugins/trailhead/skills/_shared/capture-core.md +123 -0
- package/plugins/trailhead/skills/_shared/choices.md +2 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +30 -16
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/load-first.md +2 -0
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +19 -6
- package/plugins/trailhead/skills/_shared/substrate.md +4 -3
- package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
- package/plugins/trailhead/skills/_shared/teamwork.md +11 -5
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +22 -6
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +6 -3
- package/plugins/trailhead/skills/_shared/techniques/debug.md +5 -2
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +28 -3
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +5 -1
- package/plugins/trailhead/skills/_shared/techniques.md +8 -6
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +11 -6
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +18 -3
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +12 -9
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +3 -1
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +13 -13
- package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
- package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
- package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
- package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
- package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
- package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
- package/plugins/trailhead/templates/trailhead-capture.js +419 -0
- package/plugins/trailhead/templates/trailhead-checkout-find.js +257 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +78 -21
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
- package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
- package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
- package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
- package/plugins/trailhead/templates/trailhead-issue-body.js +161 -12
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
- package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
- package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +57 -9
- package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
- package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
- package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
- package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
- package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
- package/plugins/trailhead/tests/git-env.test.ts +20 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +386 -50
- package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
- package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
- package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
- package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
- package/plugins/trailhead/tests/unscrub.ts +8 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
- package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
- package/plugins/trailhead/types/index.d.ts +7 -0
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for templates/trailhead-test-run.js (#362). Run: node test-run.test.js
|
|
3
|
+
// No framework: plain asserts, same style as isolation-answer.test.js.
|
|
4
|
+
//
|
|
5
|
+
// Layers covered:
|
|
6
|
+
// 1. Pure: config pick, {files} quoting, cache key, nice/ionice prefix, args.
|
|
7
|
+
// 2. Tree snapshot in a mkdtemp git repo (clean vs dirty).
|
|
8
|
+
// 3. End-to-end: the script spawned for real, with TRAILHEAD_CACHE_DIR in a
|
|
9
|
+
// mkdtemp dir (never the real ~/.cache), a short poll and a short grace.
|
|
10
|
+
const assert = require('assert');
|
|
11
|
+
const { spawn, spawnSync } = require('child_process');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
|
|
16
|
+
const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-test-run.js');
|
|
17
|
+
const lib = require(scriptPath);
|
|
18
|
+
|
|
19
|
+
let passed = 0;
|
|
20
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
21
|
+
|
|
22
|
+
const tmpDirs = [];
|
|
23
|
+
const children = new Set();
|
|
24
|
+
function mktmp() {
|
|
25
|
+
const d = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-test-run-test-')));
|
|
26
|
+
tmpDirs.push(d);
|
|
27
|
+
return d;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
31
|
+
async function until(cond, ms = 8000) {
|
|
32
|
+
const t0 = Date.now();
|
|
33
|
+
while (Date.now() - t0 < ms) {
|
|
34
|
+
if (cond()) return true;
|
|
35
|
+
await sleep(25);
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// --- git fixtures ------------------------------------------------------------------
|
|
41
|
+
const GIT_ENV = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' };
|
|
42
|
+
for (const k of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE']) delete GIT_ENV[k];
|
|
43
|
+
function git(dir, ...args) {
|
|
44
|
+
const r = spawnSync('git', ['-c', 'protocol.file.allow=always', '-C', dir, ...args], { encoding: 'utf8', env: GIT_ENV });
|
|
45
|
+
assert.strictEqual(r.status, 0, `git ${args.join(' ')} failed: ${r.stderr}`);
|
|
46
|
+
return r.stdout.trim();
|
|
47
|
+
}
|
|
48
|
+
function mkrepo(files = { 'a.txt': 'one\n' }) {
|
|
49
|
+
const dir = mktmp();
|
|
50
|
+
git(dir, 'init', '-q', '-b', 'main');
|
|
51
|
+
for (const [name, text] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), text);
|
|
52
|
+
git(dir, 'add', '-A');
|
|
53
|
+
git(dir, 'commit', '-qm', 'init');
|
|
54
|
+
return dir;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A config root (separate from the repo, so the repo stays clean).
|
|
58
|
+
function mkcfg(testing, flat = false) {
|
|
59
|
+
const dir = mktmp();
|
|
60
|
+
fs.mkdirSync(path.join(dir, '.trailhead'));
|
|
61
|
+
const cfg = flat ? Object.fromEntries(Object.entries(testing).map(([k, v]) => [`testing.${k}`, v])) : { testing };
|
|
62
|
+
fs.writeFileSync(path.join(dir, '.trailhead', 'config.json'), JSON.stringify(cfg));
|
|
63
|
+
return dir;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Records "start <label> <ms>" / "end <label> <ms>" lines around a sleep.
|
|
67
|
+
const recJs = path.join(mktmp(), 'rec.js');
|
|
68
|
+
fs.writeFileSync(recJs, `const fs = require('fs');
|
|
69
|
+
const [log, label, ms] = process.argv.slice(2);
|
|
70
|
+
fs.appendFileSync(log, 'start ' + label + ' ' + Date.now() + '\\n');
|
|
71
|
+
setTimeout(() => { fs.appendFileSync(log, 'end ' + label + ' ' + Date.now() + '\\n'); }, Number(ms));
|
|
72
|
+
`);
|
|
73
|
+
const rec = (log, label, ms) => `node ${recJs} ${log} ${label} ${ms}`;
|
|
74
|
+
const recLines = (log) => (fs.existsSync(log) ? fs.readFileSync(log, 'utf8').split('\n').filter(Boolean).map((l) => l.split(' ')) : []);
|
|
75
|
+
const interval = (log, label) => {
|
|
76
|
+
const rows = recLines(log).filter((r) => r[1] === label);
|
|
77
|
+
const s = rows.find((r) => r[0] === 'start');
|
|
78
|
+
const e = rows.find((r) => r[0] === 'end');
|
|
79
|
+
return s && e ? { start: Number(s[2]), end: Number(e[2]) } : null;
|
|
80
|
+
};
|
|
81
|
+
const disjoint = (a, b) => a.end <= b.start || b.end <= a.start;
|
|
82
|
+
const starts = (log) => recLines(log).filter((r) => r[0] === 'start').length;
|
|
83
|
+
|
|
84
|
+
// --- running the helper ---------------------------------------------------------------
|
|
85
|
+
function baseEnv(cache, extra = {}) {
|
|
86
|
+
return {
|
|
87
|
+
...process.env,
|
|
88
|
+
TRAILHEAD_CACHE_DIR: cache,
|
|
89
|
+
CLAUDE_CONFIG_DIR: path.join(cache, 'no-global-config'),
|
|
90
|
+
TRAILHEAD_TEST_POLL_MS: '50',
|
|
91
|
+
TRAILHEAD_TEST_GRACE_MS: '300',
|
|
92
|
+
...extra,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Starts the helper; { child, done } where done resolves { code, signal, lines, out, err }.
|
|
97
|
+
function start(args, env) {
|
|
98
|
+
const child = spawn(process.execPath, [scriptPath, 'run', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
99
|
+
children.add(child);
|
|
100
|
+
let out = '';
|
|
101
|
+
let err = '';
|
|
102
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
103
|
+
child.stderr.on('data', (d) => { err += d; });
|
|
104
|
+
const done = new Promise((resolve) => {
|
|
105
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); }, 30000);
|
|
106
|
+
child.on('close', (code, signal) => {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
children.delete(child);
|
|
109
|
+
const lines = out.split('\n').filter(Boolean);
|
|
110
|
+
resolve({ code, signal, lines, out, err, json: lines.length === 1 ? JSON.parse(lines[0]) : null });
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
return { child, done, stderr: () => err };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const runHelper = (args, env) => start(args, env).done;
|
|
117
|
+
const resultsOf = (cache) => (fs.existsSync(path.join(cache, 'results'))
|
|
118
|
+
? fs.readdirSync(path.join(cache, 'results')).filter((n) => n.endsWith('.json')) : []);
|
|
119
|
+
const queueOf = (cache) => (fs.existsSync(path.join(cache, 'queue')) ? fs.readdirSync(path.join(cache, 'queue')) : []);
|
|
120
|
+
const holdersOf = (cache) => queueOf(cache).filter((n) => /^holder-.*\.json$/.test(n));
|
|
121
|
+
const deadPid = () => spawnSync(process.execPath, ['-e', '0']).pid;
|
|
122
|
+
|
|
123
|
+
// --- 1. pure layer -----------------------------------------------------------------------
|
|
124
|
+
{
|
|
125
|
+
const f = 'cfg.json';
|
|
126
|
+
ok('pick nested', lib.pick({ testing: { full: 'x' } }, 'full', f).value === 'x');
|
|
127
|
+
ok('pick flat', lib.pick({ 'testing.full': 'y' }, 'full', f).value === 'y');
|
|
128
|
+
const both = lib.pick({ testing: { full: 'x' }, 'testing.full': 'y' }, 'full', f);
|
|
129
|
+
ok('nested wins with a warning', both.value === 'x' && /nested form wins/.test(both.warning));
|
|
130
|
+
ok('pick absent', lib.pick({ testing: { webapp: true } }, 'full', f).found === false);
|
|
131
|
+
|
|
132
|
+
const w = [];
|
|
133
|
+
const dir = mkcfg({ full: 'make test', targeted: 'make t F={files}', maxParallel: 2, webapp: true, url: 'http://x' });
|
|
134
|
+
const cfg = lib.readConfig(dir, { CLAUDE_CONFIG_DIR: path.join(dir, 'nope') }, fs, w);
|
|
135
|
+
ok('readConfig nested', cfg.full === 'make test' && cfg.targeted === 'make t F={files}' && cfg.maxParallel === 2);
|
|
136
|
+
const flat = lib.readConfig(mkcfg({ full: 'f', maxParallel: '3' }, true), { CLAUDE_CONFIG_DIR: dir }, fs, []);
|
|
137
|
+
ok('readConfig flat, numeric string', flat.full === 'f' && flat.maxParallel === 3);
|
|
138
|
+
|
|
139
|
+
const glob = mktmp();
|
|
140
|
+
fs.mkdirSync(path.join(glob, 'trailhead'));
|
|
141
|
+
fs.writeFileSync(path.join(glob, 'trailhead', 'config.json'), JSON.stringify({ testing: { full: 'global-full', maxParallel: 4 } }));
|
|
142
|
+
const merged = lib.readConfig(mkcfg({ full: 'project-full' }), { CLAUDE_CONFIG_DIR: glob }, fs, []);
|
|
143
|
+
ok('project replaces global key by key', merged.full === 'project-full' && merged.maxParallel === 4);
|
|
144
|
+
|
|
145
|
+
for (const [name, testing] of [['maxParallel 0', { maxParallel: 0 }], ['maxParallel text', { maxParallel: 'many' }],
|
|
146
|
+
['targeted without {files}', { targeted: 'make t' }], ['full empty', { full: ' ' }], ['full number', { full: 3 }]]) {
|
|
147
|
+
ok(`invalid config: ${name}`, typeof lib.readConfig(mkcfg(testing), { CLAUDE_CONFIG_DIR: dir }, fs, []).error === 'string');
|
|
148
|
+
}
|
|
149
|
+
const bad = mktmp();
|
|
150
|
+
fs.mkdirSync(path.join(bad, '.trailhead'));
|
|
151
|
+
fs.writeFileSync(path.join(bad, '.trailhead', 'config.json'), '{nope');
|
|
152
|
+
ok('invalid JSON config', /not valid JSON/.test(lib.readConfig(bad, { CLAUDE_CONFIG_DIR: dir }, fs, []).error));
|
|
153
|
+
|
|
154
|
+
ok('shQuote plain', lib.shQuote('a b') === "'a b'");
|
|
155
|
+
ok('shQuote single quote', lib.shQuote("it's") === `'it'\\''s'`);
|
|
156
|
+
ok('{files} substitution', lib.substituteFiles('t {files} --x', ['a.js', "b c.js"]) === "t 'a.js' 'b c.js' --x");
|
|
157
|
+
|
|
158
|
+
const k = lib.cacheKey('T', 'cmd', '/r/.git');
|
|
159
|
+
ok('cache key is sha256 hex', /^[0-9a-f]{64}$/.test(k));
|
|
160
|
+
ok('cache key varies on tree, command and repo',
|
|
161
|
+
k !== lib.cacheKey('T2', 'cmd', '/r/.git') && k !== lib.cacheKey('T', 'cmd2', '/r/.git') && k !== lib.cacheKey('T', 'cmd', '/q/.git'));
|
|
162
|
+
ok('cache key is stable', k === lib.cacheKey('T', 'cmd', '/r/.git'));
|
|
163
|
+
|
|
164
|
+
const bin = mktmp();
|
|
165
|
+
const empty = mktmp();
|
|
166
|
+
for (const b of ['nice', 'ionice']) { fs.writeFileSync(path.join(bin, b), '#!/bin/sh\n'); fs.chmodSync(path.join(bin, b), 0o755); }
|
|
167
|
+
ok('prefix with both on PATH', lib.niceArgs(bin).join(' ') === 'nice -n 10 ionice -c 3');
|
|
168
|
+
ok('prefix with none on PATH', lib.niceArgs(empty).length === 0);
|
|
169
|
+
|
|
170
|
+
// W2: each present binary is probed; a failing ionice is dropped with a warning.
|
|
171
|
+
const failBin = mktmp();
|
|
172
|
+
fs.writeFileSync(path.join(failBin, 'nice'), '#!/bin/sh\nexit 0\n');
|
|
173
|
+
fs.writeFileSync(path.join(failBin, 'ionice'), '#!/bin/sh\nexit 1\n');
|
|
174
|
+
for (const b of ['nice', 'ionice']) fs.chmodSync(path.join(failBin, b), 0o755);
|
|
175
|
+
const prio = lib.priorityPrefix(failBin);
|
|
176
|
+
ok('probe keeps nice, drops a failing ionice', prio.args.join(' ') === 'nice -n 10');
|
|
177
|
+
ok('probe warns priority-unavailable', prio.warnings.length === 1 && /^priority-unavailable: ionice/.test(prio.warnings[0]));
|
|
178
|
+
const bothOk = lib.priorityPrefix(bin);
|
|
179
|
+
ok('probe keeps both when they work', bothOk.args.join(" ") === "nice -n 10 ionice -c 3" && bothOk.warnings.length === 0);
|
|
180
|
+
ok('probe with none on PATH', lib.priorityPrefix(empty).args.length === 0 && lib.priorityPrefix(empty).warnings.length === 0);
|
|
181
|
+
|
|
182
|
+
ok('args: defaults', lib.parseArgs(['run', '--repo', '/r']).kind === 'full');
|
|
183
|
+
ok('args: targeted needs files', /--files/.test(lib.parseArgs(['run', '--repo', '/r', '--kind', 'targeted']).error));
|
|
184
|
+
ok('args: unknown flag', /unknown/.test(lib.parseArgs(['run', '--repo', '/r', '--zzz']).error));
|
|
185
|
+
ok('args: no repo', /--repo/.test(lib.parseArgs(['run']).error));
|
|
186
|
+
ok('args: bad verb', /expected "run"/.test(lib.parseArgs(['go']).error));
|
|
187
|
+
ok('args: files split', lib.parseArgs(['run', '--repo', '/r', '--kind', 'targeted', '--files', 'a,b']).files.join('|') === 'a|b');
|
|
188
|
+
|
|
189
|
+
ok('submodule modified content is dirty', lib.hasDirtySubmodule('1 .M SCM. 160000 160000 160000 aaa bbb sub'));
|
|
190
|
+
ok('submodule untracked is dirty', lib.hasDirtySubmodule('1 .M S..U 160000 160000 160000 aaa bbb sub'));
|
|
191
|
+
ok('submodule new commit only is not dirty', !lib.hasDirtySubmodule('1 .M SC.. 160000 160000 160000 aaa bbb sub'));
|
|
192
|
+
ok('plain file is not a submodule', !lib.hasDirtySubmodule('1 .M N... 100644 100644 100644 aaa bbb f.txt'));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- 2. tree snapshot -----------------------------------------------------------------------
|
|
196
|
+
{
|
|
197
|
+
const repo = mkrepo();
|
|
198
|
+
const clean = lib.snapshotTree(repo, process.env);
|
|
199
|
+
ok('clean tree equals HEAD^{tree}', clean.tree === git(repo, 'rev-parse', 'HEAD^{tree}'));
|
|
200
|
+
fs.writeFileSync(path.join(repo, 'a.txt'), 'edited\n');
|
|
201
|
+
const dirty = lib.snapshotTree(repo, process.env);
|
|
202
|
+
ok('dirty tree differs from HEAD tree', dirty.tree && dirty.tree !== clean.tree);
|
|
203
|
+
ok('dirty tree is stable', lib.snapshotTree(repo, process.env).tree === dirty.tree);
|
|
204
|
+
fs.writeFileSync(path.join(repo, 'new.txt'), 'untracked\n');
|
|
205
|
+
ok('untracked file changes the tree', lib.snapshotTree(repo, process.env).tree !== dirty.tree);
|
|
206
|
+
git(repo, 'add', '-A');
|
|
207
|
+
git(repo, 'commit', '-qm', 'second');
|
|
208
|
+
ok('committing the same files lands on the same tree',
|
|
209
|
+
lib.snapshotTree(repo, process.env).tree === git(repo, 'rev-parse', 'HEAD^{tree}'));
|
|
210
|
+
// I1: the dirty snapshot works from the real index, in a linked worktree too.
|
|
211
|
+
const wtDir = path.join(mktmp(), 'wt');
|
|
212
|
+
git(repo, 'worktree', 'add', '-q', '-b', 'snapwt', wtDir);
|
|
213
|
+
const wtClean = lib.snapshotTree(wtDir, process.env).tree;
|
|
214
|
+
fs.writeFileSync(path.join(wtDir, 'a.txt'), 'worktree edit\n');
|
|
215
|
+
const wtDirty = lib.snapshotTree(wtDir, process.env);
|
|
216
|
+
ok('dirty snapshot in a linked worktree', wtDirty.tree && wtDirty.tree !== wtClean);
|
|
217
|
+
git(wtDir, 'add', 'a.txt');
|
|
218
|
+
ok('staged and unstaged edits give the same tree', lib.snapshotTree(wtDir, process.env).tree === wtDirty.tree);
|
|
219
|
+
fs.rmSync(path.join(wtDir, 'a.txt'));
|
|
220
|
+
ok('a deleted tracked file changes the tree', lib.snapshotTree(wtDir, process.env).tree !== wtDirty.tree);
|
|
221
|
+
const plain = mktmp();
|
|
222
|
+
ok('not a git repo -> tree null', lib.snapshotTree(plain, process.env).tree === null);
|
|
223
|
+
ok('index of the repo untouched by snapshot', git(repo, 'status', '--porcelain') === '');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
(async () => {
|
|
227
|
+
// --- W1: a live gate swapped in after the dead-gate read is never displaced -----------------------
|
|
228
|
+
{
|
|
229
|
+
const q = mktmp();
|
|
230
|
+
const dirs = { queue: q };
|
|
231
|
+
const gate = path.join(q, '.gate');
|
|
232
|
+
fs.writeFileSync(gate, JSON.stringify({ pid: deadPid(), token: 'dead' }));
|
|
233
|
+
const liveText = JSON.stringify({ pid: process.pid, token: 'live' });
|
|
234
|
+
const r = lib.acquireGate(dirs, Date.now(), {
|
|
235
|
+
afterRead: () => {
|
|
236
|
+
// Another reaper reaped the dead gate and linked its own live one.
|
|
237
|
+
fs.unlinkSync(gate);
|
|
238
|
+
fs.writeFileSync(gate, liveText);
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
ok('(W1) the racing reaper does not get the gate', r.held === false && r.pid === process.pid);
|
|
242
|
+
ok('(W1) the live gate is still in place, unchanged', fs.readFileSync(gate, 'utf8') === liveText);
|
|
243
|
+
ok('(W1) no stale copy left behind', fs.readdirSync(q).every((n) => !n.startsWith('.gate.stale-')));
|
|
244
|
+
fs.unlinkSync(gate);
|
|
245
|
+
fs.writeFileSync(gate, JSON.stringify({ pid: deadPid(), token: 'dead2' }));
|
|
246
|
+
const r2 = lib.acquireGate(dirs, Date.now());
|
|
247
|
+
ok('(W1) an undisturbed dead gate is still reaped and taken', r2.held === true);
|
|
248
|
+
lib.releaseGate(r2);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- W1 root: admission is correct even when the gate fails (bakery doorway) -----------------------------
|
|
252
|
+
{
|
|
253
|
+
const holders = (dir) => fs.readdirSync(dir).filter((n) => /^holder-.*\.json$/.test(n));
|
|
254
|
+
const meOf = (key, limit, label) => ({ key, limit, repo: '/r', tree: 't', command: label });
|
|
255
|
+
const forge = { forgeGate: true };
|
|
256
|
+
const NOW = () => Date.now();
|
|
257
|
+
const POLL = 10;
|
|
258
|
+
const admit = (q, key, limit, hooks = {}) => lib.tryAdmit({ queue: q }, meOf(key, limit, key), NOW(), { ...forge, ...hooks }, POLL);
|
|
259
|
+
|
|
260
|
+
// The stamp counterexample: A stalls between the doorway steps while B tries to admit.
|
|
261
|
+
// B must wait for A to take a number, then both agree on (number, token): one admitted.
|
|
262
|
+
let q = mktmp();
|
|
263
|
+
let bPromise;
|
|
264
|
+
let bDoneEarly = false;
|
|
265
|
+
const a1 = await admit(q, 'kA', 1, {
|
|
266
|
+
afterChoosingWrite: async () => {
|
|
267
|
+
bPromise = admit(q, 'kB', 1, { skipPrecheck: true }).then((r) => { bDoneEarly = true; return r; });
|
|
268
|
+
await sleep(200);
|
|
269
|
+
ok('(W1c) B waits while A is still choosing', bDoneEarly === false);
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
const b1 = await bPromise;
|
|
273
|
+
ok('(W1c) limit 1: exactly one of two overlapping doorways is admitted', a1.admitted !== b1.admitted);
|
|
274
|
+
ok('(W1c) exactly one holder file remains, the admitted one',
|
|
275
|
+
holders(q).length === 1 && holders(q)[0] === path.basename((a1.admitted ? a1 : b1).holderFile));
|
|
276
|
+
|
|
277
|
+
// Sequential: B finishes its doorway first, A's number is larger, A defers.
|
|
278
|
+
q = mktmp();
|
|
279
|
+
const first = await admit(q, 'k1', 1);
|
|
280
|
+
const late = await admit(q, 'k2', 1, { skipPrecheck: true });
|
|
281
|
+
ok('(W1c) the earlier holder stays admitted, the later one withdraws', first.admitted === true && late.admitted === false);
|
|
282
|
+
ok('(W1c) only the earlier holder file remains', holders(q).length === 1 && holders(q)[0] === path.basename(first.holderFile));
|
|
283
|
+
|
|
284
|
+
// Same key, limit 2, overlapping: only one runs.
|
|
285
|
+
q = mktmp();
|
|
286
|
+
let other;
|
|
287
|
+
const s1 = await admit(q, 'kS', 2, {
|
|
288
|
+
afterChoosingWrite: async () => { other = admit(q, 'kS', 2, { skipPrecheck: true }); await sleep(50); },
|
|
289
|
+
});
|
|
290
|
+
const s2 = await other;
|
|
291
|
+
ok('(W1c) same key, limit 2: only one admitted', s1.admitted !== s2.admitted && holders(q).length === 1);
|
|
292
|
+
|
|
293
|
+
// Strictest limit at or ahead wins.
|
|
294
|
+
q = mktmp();
|
|
295
|
+
const e = await admit(q, 'kE', 2);
|
|
296
|
+
const l = await admit(q, 'kL', 1, { skipPrecheck: true });
|
|
297
|
+
ok('(W1c) an N=1 latecomer withdraws beside a live N=2 holder', e.admitted === true && l.admitted === false);
|
|
298
|
+
const l2 = await admit(q, 'kM', 2, { skipPrecheck: true });
|
|
299
|
+
ok('(W1c) an N=2 latecomer is admitted as the second place', l2.admitted === true && holders(q).length === 2);
|
|
300
|
+
|
|
301
|
+
// A dead-pid chooser is reaped and does not block.
|
|
302
|
+
q = mktmp();
|
|
303
|
+
const deadHolder = path.join(q, 'holder-1-dead.json');
|
|
304
|
+
const dp = deadPid();
|
|
305
|
+
fs.writeFileSync(deadHolder, JSON.stringify({ pid: dp, pgid: dp, limit: 1, key: 'kd', choosing: true, token: 'dead' }));
|
|
306
|
+
const afterDead = await admit(q, 'kN', 1, { skipPrecheck: true });
|
|
307
|
+
ok('(W1c) a dead chooser does not block and is reaped', afterDead.admitted === true && !fs.existsSync(deadHolder));
|
|
308
|
+
|
|
309
|
+
// A live chooser is waited on.
|
|
310
|
+
q = mktmp();
|
|
311
|
+
const liveHolder = path.join(q, 'holder-2-live.json');
|
|
312
|
+
fs.writeFileSync(liveHolder, JSON.stringify({ pid: process.pid, pgid: null, limit: 1, key: 'kl', choosing: true, token: 'zzz' }));
|
|
313
|
+
let waited = true;
|
|
314
|
+
const pending = admit(q, 'kW', 1, { skipPrecheck: true }).then((r) => { waited = false; return r; });
|
|
315
|
+
await sleep(250);
|
|
316
|
+
ok('(W1c) a live chooser is waited on', waited === true);
|
|
317
|
+
fs.writeFileSync(liveHolder, JSON.stringify({ pid: process.pid, pgid: null, limit: 1, key: 'kl', choosing: false, number: 9, token: 'zzz' }));
|
|
318
|
+
const waitedRes = await pending;
|
|
319
|
+
ok('(W1c) it proceeds once the chooser has a number (and sorts ahead of it)', waitedRes.admitted === true);
|
|
320
|
+
|
|
321
|
+
// Undisturbed real admission (real gate) still works.
|
|
322
|
+
const real = await lib.tryAdmit({ queue: mktmp() }, meOf('k', 1, 'X'), NOW(), {}, POLL);
|
|
323
|
+
ok('(W1c) real admission with the real gate works', real.admitted === true);
|
|
324
|
+
|
|
325
|
+
// A live gate swapped in after the dead-gate read is never displaced by the reaper.
|
|
326
|
+
const gq = mktmp();
|
|
327
|
+
const gate = path.join(gq, '.gate');
|
|
328
|
+
fs.writeFileSync(gate, JSON.stringify({ pid: deadPid(), token: 'dead' }));
|
|
329
|
+
const liveText = JSON.stringify({ pid: process.pid, token: 'live' });
|
|
330
|
+
const gr = lib.acquireGate({ queue: gq }, NOW(), { afterRead: () => { fs.unlinkSync(gate); fs.writeFileSync(gate, liveText); } });
|
|
331
|
+
ok('(W1c) the reaper does not displace a live gate', gr.held === false && fs.readFileSync(gate, 'utf8') === liveText);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// --- I4: a failing pgid record tears the launched group down -----------------------------------------------
|
|
335
|
+
{
|
|
336
|
+
const repo = mkrepo();
|
|
337
|
+
const cache = mktmp();
|
|
338
|
+
const marker = path.join(mktmp(), 'marker');
|
|
339
|
+
const cfg = mkcfg({ full: `touch ${marker}; sleep 30` });
|
|
340
|
+
const r = await runHelper(['--repo', repo, '--config-root', cfg], baseEnv(cache, { TRAILHEAD_TEST_FAIL_PGID_UPDATE: '1' }));
|
|
341
|
+
ok('(I4) status error, exit 3', r.code === 3 && r.json && r.json.status === 'error');
|
|
342
|
+
ok('(I4) the suite never ran and no holder is left', !fs.existsSync(marker) && holdersOf(cache).length === 0);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// --- (a) serialisation: maxParallel 1, two different commands ---------------------------------
|
|
346
|
+
{
|
|
347
|
+
const repo = mkrepo();
|
|
348
|
+
const cache = mktmp();
|
|
349
|
+
const log = path.join(mktmp(), 'log');
|
|
350
|
+
const cfgA = mkcfg({ full: rec(log, 'A', 400) });
|
|
351
|
+
const cfgB = mkcfg({ full: rec(log, 'B', 400) });
|
|
352
|
+
const [ra, rb] = await Promise.all([
|
|
353
|
+
runHelper(['--repo', repo, '--config-root', cfgA], baseEnv(cache)),
|
|
354
|
+
runHelper(['--repo', repo, '--config-root', cfgB], baseEnv(cache)),
|
|
355
|
+
]);
|
|
356
|
+
ok('(a) one JSON line each', ra.json && rb.json && ra.lines.length === 1 && rb.lines.length === 1);
|
|
357
|
+
ok('(a) both passed and executed', ra.code === 0 && rb.code === 0 && ra.json.source === 'run' && rb.json.source === 'run');
|
|
358
|
+
const ia = interval(log, 'A');
|
|
359
|
+
const ib = interval(log, 'B');
|
|
360
|
+
ok('(a) both intervals recorded', ia && ib);
|
|
361
|
+
ok('(a) intervals do not overlap', disjoint(ia, ib));
|
|
362
|
+
ok('(a) holders cleaned up', holdersOf(cache).length === 0);
|
|
363
|
+
ok('(a) the waiting run names what it was queued behind',
|
|
364
|
+
/queued behind node .* on .* \(tree [0-9a-f]{12}\), pid \d+/.test(ra.err + rb.err));
|
|
365
|
+
ok('(a) log file written', ra.json.log && fs.existsSync(ra.json.log));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// --- (b) queue then cache ------------------------------------------------------------------------
|
|
369
|
+
{
|
|
370
|
+
const repo = mkrepo();
|
|
371
|
+
const cache = mktmp();
|
|
372
|
+
const log = path.join(mktmp(), 'log');
|
|
373
|
+
const cfg = mkcfg({ full: rec(log, 'X', 400) });
|
|
374
|
+
const [r1, r2] = await Promise.all([
|
|
375
|
+
runHelper(['--repo', repo, '--config-root', cfg], baseEnv(cache)),
|
|
376
|
+
runHelper(['--repo', repo, '--config-root', cfg], baseEnv(cache)),
|
|
377
|
+
]);
|
|
378
|
+
ok('(b) one JSON line each', r1.json && r2.json);
|
|
379
|
+
ok('(b) command executed exactly once', starts(log) === 1);
|
|
380
|
+
const sources = [r1.json.source, r2.json.source].sort();
|
|
381
|
+
ok('(b) one run, one cache', sources[0] === 'cache' && sources[1] === 'run');
|
|
382
|
+
const second = r1.json.source === 'cache' ? r1 : r2;
|
|
383
|
+
ok('(b) cached result is cached:true and passed', second.json.cached === true && second.json.status === 'passed' && second.code === 0);
|
|
384
|
+
ok('(b) the second named what it was queued behind', /queued behind .*pid \d+/.test(second.err));
|
|
385
|
+
ok('(b) one result entry', resultsOf(cache).length === 1);
|
|
386
|
+
const third = await runHelper(['--repo', repo, '--config-root', cfg], baseEnv(cache));
|
|
387
|
+
ok('(b) later run is a pure cache hit, no queue', third.json.source === 'cache' && starts(log) === 1 && !/queued/.test(third.err));
|
|
388
|
+
const forced = await runHelper(['--repo', repo, '--config-root', cfg, '--no-cache'], baseEnv(cache));
|
|
389
|
+
ok('(b) --no-cache forces a run', forced.json.source === 'run' && starts(log) === 2);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// --- (c) maxParallel 2 overlaps -----------------------------------------------------------------
|
|
393
|
+
{
|
|
394
|
+
const repo = mkrepo();
|
|
395
|
+
const cache = mktmp();
|
|
396
|
+
const log = path.join(mktmp(), 'log');
|
|
397
|
+
const cfgA = mkcfg({ full: rec(log, 'A', 1200), maxParallel: 2 });
|
|
398
|
+
const cfgB = mkcfg({ full: rec(log, 'B', 1200), maxParallel: 2 });
|
|
399
|
+
await Promise.all([
|
|
400
|
+
runHelper(['--repo', repo, '--config-root', cfgA], baseEnv(cache)),
|
|
401
|
+
runHelper(['--repo', repo, '--config-root', cfgB], baseEnv(cache)),
|
|
402
|
+
]);
|
|
403
|
+
const ia = interval(log, 'A');
|
|
404
|
+
const ib = interval(log, 'B');
|
|
405
|
+
ok('(c) maxParallel 2: intervals overlap', ia && ib && !disjoint(ia, ib));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// --- (m) same tree + command, maxParallel 2 -> once --------------------------------------------
|
|
409
|
+
{
|
|
410
|
+
const repo = mkrepo();
|
|
411
|
+
const cache = mktmp();
|
|
412
|
+
const log = path.join(mktmp(), 'log');
|
|
413
|
+
const cfg = mkcfg({ full: rec(log, 'X', 400), maxParallel: 2 });
|
|
414
|
+
const [r1, r2] = await Promise.all([
|
|
415
|
+
runHelper(['--repo', repo, '--config-root', cfg], baseEnv(cache)),
|
|
416
|
+
runHelper(['--repo', repo, '--config-root', cfg], baseEnv(cache)),
|
|
417
|
+
]);
|
|
418
|
+
ok('(m) executed once despite free capacity', starts(log) === 1);
|
|
419
|
+
ok('(m) the second reports cache', [r1.json.source, r2.json.source].sort().join() === 'cache,run');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// --- (d) mixed limits: an N=1 run is live, an N=2 caller waits -------------------------------
|
|
423
|
+
{
|
|
424
|
+
const repo = mkrepo();
|
|
425
|
+
const cache = mktmp();
|
|
426
|
+
const log = path.join(mktmp(), 'log');
|
|
427
|
+
const cfgA = mkcfg({ full: rec(log, 'A', 1000), maxParallel: 1 });
|
|
428
|
+
const cfgB = mkcfg({ full: rec(log, 'B', 100), maxParallel: 2 });
|
|
429
|
+
const a = start(['--repo', repo, '--config-root', cfgA], baseEnv(cache));
|
|
430
|
+
ok('(d) the N=1 holder appears', await until(() => holdersOf(cache).length === 1));
|
|
431
|
+
const b = start(['--repo', repo, '--config-root', cfgB], baseEnv(cache));
|
|
432
|
+
const [ra, rb] = await Promise.all([a.done, b.done]);
|
|
433
|
+
ok('(d) both passed', ra.code === 0 && rb.code === 0);
|
|
434
|
+
ok('(d) the N=2 caller did not run beside the N=1 run', disjoint(interval(log, 'A'), interval(log, 'B')));
|
|
435
|
+
ok('(d) it waited behind it', /queued behind/.test(rb.err));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// --- (e) reaping ----------------------------------------------------------------------------------
|
|
439
|
+
{
|
|
440
|
+
const repo = mkrepo();
|
|
441
|
+
const cache = mktmp();
|
|
442
|
+
const log = path.join(mktmp(), 'log');
|
|
443
|
+
const cfg = mkcfg({ full: rec(log, 'X', 50) });
|
|
444
|
+
fs.mkdirSync(path.join(cache, 'queue'), { recursive: true });
|
|
445
|
+
const dead = deadPid();
|
|
446
|
+
const holder = path.join(cache, 'queue', 'holder-1-dead.json');
|
|
447
|
+
fs.writeFileSync(holder, JSON.stringify({ pid: dead, pgid: dead, limit: 1, key: 'k', repo: '/x', tree: 't', command: 'c', startedAt: new Date().toISOString() }));
|
|
448
|
+
const r = await runHelper(['--repo', repo, '--config-root', cfg], baseEnv(cache));
|
|
449
|
+
ok('(e) a dead holder does not block', r.code === 0 && r.json.source === 'run' && !/queued/.test(r.err));
|
|
450
|
+
ok('(e) the dead holder was reaped', !fs.existsSync(holder));
|
|
451
|
+
|
|
452
|
+
const live = path.join(cache, 'queue', 'holder-2-live.json');
|
|
453
|
+
fs.writeFileSync(live, JSON.stringify({ pid: process.pid, pgid: null, limit: 1, key: 'k2', repo: '/x', tree: 't', command: 'old run', startedAt: '2000-01-01T00:00:00.000Z' }));
|
|
454
|
+
const old = new Date('2000-01-01T00:00:00Z');
|
|
455
|
+
fs.utimesSync(live, old, old);
|
|
456
|
+
const cfg2 = mkcfg({ full: rec(log, 'Y', 50) });
|
|
457
|
+
const h = start(['--repo', repo, '--config-root', cfg2], baseEnv(cache));
|
|
458
|
+
await sleep(700);
|
|
459
|
+
ok('(e) a live pid is never reaped, whatever its age', fs.existsSync(live) && h.child.exitCode === null);
|
|
460
|
+
ok('(e) the note says it is long-running', /long-running/.test(h.stderr()));
|
|
461
|
+
fs.unlinkSync(live);
|
|
462
|
+
const done = await h.done;
|
|
463
|
+
ok('(e) it runs once the holder is gone', done.code === 0 && done.json.source === 'run');
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// --- (f) leftover processes -----------------------------------------------------------------------
|
|
467
|
+
{
|
|
468
|
+
const repo = mkrepo();
|
|
469
|
+
const cache = mktmp();
|
|
470
|
+
const pidFile = path.join(mktmp(), 'pid');
|
|
471
|
+
const cfg = mkcfg({ full: `sleep 30 & echo $! > ${pidFile}` });
|
|
472
|
+
const r = await runHelper(['--repo', repo, '--config-root', cfg], baseEnv(cache));
|
|
473
|
+
const pid = Number(fs.readFileSync(pidFile, 'utf8'));
|
|
474
|
+
ok('(f) exit code kept', r.code === 0 && r.json.status === 'passed');
|
|
475
|
+
ok('(f) leftover-processes warning', (r.json.warnings || []).some((w) => /^leftover-processes/.test(w)));
|
|
476
|
+
ok('(f) no live descendant after the helper returns', await until(() => !lib.pidAlive(pid) || spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { encoding: 'utf8' }).stdout.trim().startsWith('Z'), 3000));
|
|
477
|
+
ok('(f) not cached', resultsOf(cache).length === 0);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// --- (g) failed run -----------------------------------------------------------------------------
|
|
481
|
+
{
|
|
482
|
+
const repo = mkrepo();
|
|
483
|
+
const cache = mktmp();
|
|
484
|
+
const log = path.join(mktmp(), 'log');
|
|
485
|
+
const cfg = mkcfg({ full: `echo x >> ${log}; exit 3` });
|
|
486
|
+
const r1 = await runHelper(['--repo', repo, '--config-root', cfg], baseEnv(cache));
|
|
487
|
+
ok('(g) failed: status failed, exit 1, command code reported', r1.code === 1 && r1.json.status === 'failed' && r1.json.exitCode === 3);
|
|
488
|
+
const r2 = await runHelper(['--repo', repo, '--config-root', cfg], baseEnv(cache));
|
|
489
|
+
ok('(g) a failure is not cached: it runs again', r2.json.source === 'run' && fs.readFileSync(log, 'utf8').split('\n').filter(Boolean).length === 2);
|
|
490
|
+
ok('(g) no result entry', resultsOf(cache).length === 0);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// --- (h) dirty tree has a different key ----------------------------------------------------------
|
|
494
|
+
{
|
|
495
|
+
const repo = mkrepo();
|
|
496
|
+
const cache = mktmp();
|
|
497
|
+
const log = path.join(mktmp(), 'log');
|
|
498
|
+
const cfg = mkcfg({ full: rec(log, 'X', 50) });
|
|
499
|
+
const r1 = await runHelper(['--repo', repo, '--config-root', cfg], baseEnv(cache));
|
|
500
|
+
fs.writeFileSync(path.join(repo, 'a.txt'), 'uncommitted\n');
|
|
501
|
+
const r2 = await runHelper(['--repo', repo, '--config-root', cfg], baseEnv(cache));
|
|
502
|
+
ok('(h) uncommitted edit does not reuse the HEAD result', r2.json.source === 'run' && r2.json.tree !== r1.json.tree && starts(log) === 2);
|
|
503
|
+
const r3 = await runHelper(['--repo', repo, '--config-root', cfg], baseEnv(cache));
|
|
504
|
+
ok('(h) the dirty tree is cached under its own key', r3.json.source === 'cache' && r3.json.tree === r2.json.tree && starts(log) === 2);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// --- (i) internal error ----------------------------------------------------------------------------
|
|
508
|
+
{
|
|
509
|
+
const repo = mkrepo();
|
|
510
|
+
const cacheFile = path.join(mktmp(), 'a-file');
|
|
511
|
+
fs.writeFileSync(cacheFile, 'not a dir');
|
|
512
|
+
const cfg = mkcfg({ full: 'true' });
|
|
513
|
+
const r = await runHelper(['--repo', repo, '--config-root', cfg], baseEnv(cacheFile, { CLAUDE_CONFIG_DIR: mktmp() }));
|
|
514
|
+
ok('(i) internal error: status error, exit 3, message', r.code === 3 && r.json && r.json.status === 'error' && /error/.test(r.json.message));
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// --- usage / unconfigured / invalid ------------------------------------------------------------------
|
|
518
|
+
{
|
|
519
|
+
const repo = mkrepo();
|
|
520
|
+
const cache = mktmp();
|
|
521
|
+
const u = await runHelper([], baseEnv(cache));
|
|
522
|
+
ok('usage error: exit 2, nothing on stdout', u.code === 2 && u.out === '');
|
|
523
|
+
const none = await runHelper(['--repo', repo, '--config-root', mktmp()], baseEnv(cache));
|
|
524
|
+
ok('unconfigured: exit 0 and status', none.code === 0 && none.json.status === 'unconfigured');
|
|
525
|
+
const inv = await runHelper(['--repo', repo, '--config-root', mkcfg({ full: 'true', maxParallel: 0 })], baseEnv(cache));
|
|
526
|
+
ok('invalid config: exit 2, status invalid', inv.code === 2 && inv.json.status === 'invalid');
|
|
527
|
+
const log = path.join(mktmp(), 'log');
|
|
528
|
+
const tcfg = mkcfg({ targeted: `echo {files} >> ${log}` });
|
|
529
|
+
const t = await runHelper(['--repo', repo, '--config-root', tcfg, '--kind', 'targeted', '--files', "it's.js,b.js"], baseEnv(cache));
|
|
530
|
+
ok('targeted run substitutes quoted files', t.code === 0 && fs.readFileSync(log, 'utf8').trim() === "it's.js b.js");
|
|
531
|
+
const noFull = await runHelper(['--repo', repo, '--config-root', tcfg], baseEnv(cache));
|
|
532
|
+
ok('full unconfigured while targeted is set', noFull.json.status === 'unconfigured');
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// --- (n) leftover gate temp ----------------------------------------------------------------------------
|
|
536
|
+
{
|
|
537
|
+
const repo = mkrepo();
|
|
538
|
+
const cache = mktmp();
|
|
539
|
+
const cfg = mkcfg({ full: 'true' });
|
|
540
|
+
fs.mkdirSync(path.join(cache, 'queue'), { recursive: true });
|
|
541
|
+
const leftover = path.join(cache, 'queue', `.gate.tmp-${deadPid()}-abcd`);
|
|
542
|
+
fs.writeFileSync(leftover, '{"pid":');
|
|
543
|
+
const r = await runHelper(['--repo', repo, '--config-root', cfg], baseEnv(cache));
|
|
544
|
+
ok('(n) a leftover gate temp does not block', r.code === 0 && r.json.status === 'passed');
|
|
545
|
+
ok('(n) and is swept', !fs.existsSync(leftover));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// --- (o) file edited while the command runs --------------------------------------------------------------
|
|
549
|
+
{
|
|
550
|
+
const repo = mkrepo();
|
|
551
|
+
const cache = mktmp();
|
|
552
|
+
const cfg = mkcfg({ full: `echo more >> ${path.join(repo, 'a.txt')}` });
|
|
553
|
+
const r = await runHelper(['--repo', repo, '--config-root', cfg], baseEnv(cache));
|
|
554
|
+
ok('(o) exit 0 but tree-changed warning', r.code === 0 && (r.json.warnings || []).some((w) => /^tree-changed/.test(w)));
|
|
555
|
+
ok('(o) nothing cached', resultsOf(cache).length === 0);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// --- (p) file edited while queued ---------------------------------------------------------------------------
|
|
559
|
+
{
|
|
560
|
+
const repo = mkrepo();
|
|
561
|
+
const cache = mktmp();
|
|
562
|
+
const log = path.join(mktmp(), 'log');
|
|
563
|
+
const cfgA = mkcfg({ full: rec(log, 'A', 900) });
|
|
564
|
+
const cfgB = mkcfg({ full: rec(log, 'B', 50) });
|
|
565
|
+
const a = start(['--repo', repo, '--config-root', cfgA], baseEnv(cache));
|
|
566
|
+
ok('(p) A holds the slot', await until(() => holdersOf(cache).length === 1));
|
|
567
|
+
const b = start(['--repo', repo, '--config-root', cfgB], baseEnv(cache));
|
|
568
|
+
ok('(p) B is queued', await until(() => /queued behind/.test(b.stderr())));
|
|
569
|
+
const before = lib.snapshotTree(repo, process.env).tree;
|
|
570
|
+
fs.writeFileSync(path.join(repo, 'a.txt'), 'edited while queued\n');
|
|
571
|
+
const expected = lib.snapshotTree(repo, process.env).tree;
|
|
572
|
+
const [, rb] = await Promise.all([a.done, b.done]);
|
|
573
|
+
ok('(p) B keys on the new tree', rb.json.tree === expected && expected !== before && rb.code === 0);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// --- (s) file edited between a cache hit and its return -----------------------------------------------------------
|
|
577
|
+
{
|
|
578
|
+
const repo = mkrepo();
|
|
579
|
+
const cache = mktmp();
|
|
580
|
+
const log = path.join(mktmp(), 'log');
|
|
581
|
+
const cfg = mkcfg({ full: rec(log, 'X', 50) });
|
|
582
|
+
const first = await runHelper(['--repo', repo, '--config-root', cfg], baseEnv(cache));
|
|
583
|
+
const h = start(['--repo', repo, '--config-root', cfg], baseEnv(cache, { TRAILHEAD_TEST_PAUSE_AFTER_HIT_MS: '1500' }));
|
|
584
|
+
await sleep(500);
|
|
585
|
+
fs.writeFileSync(path.join(repo, 'a.txt'), 'edited during the hit\n');
|
|
586
|
+
const r = await h.done;
|
|
587
|
+
ok('(s) the stale hit is not returned', r.json.source === 'run' && r.json.tree !== first.json.tree && starts(log) === 2);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// --- (r) two linked worktrees of one repo ------------------------------------------------------------------------------
|
|
591
|
+
{
|
|
592
|
+
const repo = mkrepo();
|
|
593
|
+
const wt = path.join(mktmp(), 'wt');
|
|
594
|
+
git(repo, 'worktree', 'add', '-q', '-b', 'wtb', wt);
|
|
595
|
+
const cache = mktmp();
|
|
596
|
+
const log = path.join(mktmp(), 'log');
|
|
597
|
+
// The config lives in the main checkout (committed so the tree stays clean).
|
|
598
|
+
fs.mkdirSync(path.join(repo, '.trailhead'));
|
|
599
|
+
fs.writeFileSync(path.join(repo, '.trailhead', 'config.json'), JSON.stringify({ testing: { full: rec(log, 'W', 500) } }));
|
|
600
|
+
git(repo, 'add', '-A');
|
|
601
|
+
git(repo, 'commit', '-qm', 'config');
|
|
602
|
+
git(wt, 'merge', '-q', 'main');
|
|
603
|
+
const [r1, r2] = await Promise.all([
|
|
604
|
+
runHelper(['--repo', repo, '--config-root', repo], baseEnv(cache)),
|
|
605
|
+
runHelper(['--repo', wt, '--config-root', repo], baseEnv(cache)),
|
|
606
|
+
]);
|
|
607
|
+
ok('(r) same commit in two worktrees: executed once', starts(log) === 1);
|
|
608
|
+
ok('(r) the other worktree reports cache', [r1.json.source, r2.json.source].sort().join() === 'cache,run');
|
|
609
|
+
|
|
610
|
+
fs.writeFileSync(path.join(wt, 'b.txt'), 'only in wt\n');
|
|
611
|
+
git(wt, 'add', '-A');
|
|
612
|
+
git(wt, 'commit', '-qm', 'diverge');
|
|
613
|
+
const cache2 = mktmp();
|
|
614
|
+
const log2 = path.join(mktmp(), 'log');
|
|
615
|
+
// Same command text as before, a fresh log: rewrite via config would dirty the tree, so reuse the file.
|
|
616
|
+
fs.writeFileSync(log2, '');
|
|
617
|
+
const [d1, d2] = await Promise.all([
|
|
618
|
+
runHelper(['--repo', repo, '--config-root', repo], baseEnv(cache2)),
|
|
619
|
+
runHelper(['--repo', wt, '--config-root', repo], baseEnv(cache2)),
|
|
620
|
+
]);
|
|
621
|
+
const rows = recLines(log).slice(2);
|
|
622
|
+
ok('(r) different commits: both executed', d1.json.source === 'run' && d2.json.source === 'run');
|
|
623
|
+
const s = rows.filter((x) => x[0] === 'start').map((x) => Number(x[2]));
|
|
624
|
+
const e = rows.filter((x) => x[0] === 'end').map((x) => Number(x[2]));
|
|
625
|
+
ok('(r) different commits: intervals do not overlap', s.length === 2 && e.length === 2 && (Math.max(...s) >= Math.min(...e)));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// --- (q) dirty submodules -------------------------------------------------------------------------------------------------
|
|
629
|
+
{
|
|
630
|
+
const mkSuper = (withNested) => {
|
|
631
|
+
const src = mkrepo({ 's.txt': 's\n' });
|
|
632
|
+
if (withNested) {
|
|
633
|
+
const inner = mkrepo({ 'i.txt': 'i\n' });
|
|
634
|
+
git(src, 'submodule', 'add', '-q', inner, 'inner');
|
|
635
|
+
git(src, 'commit', '-qm', 'inner');
|
|
636
|
+
}
|
|
637
|
+
const sup = mkrepo({ 'a.txt': 'a\n' });
|
|
638
|
+
git(sup, 'submodule', 'add', '-q', src, 'sub');
|
|
639
|
+
git(sup, 'commit', '-qm', 'sub');
|
|
640
|
+
if (withNested) git(sup, 'submodule', 'update', '--init', '--recursive', '-q');
|
|
641
|
+
return sup;
|
|
642
|
+
};
|
|
643
|
+
const scenarios = [
|
|
644
|
+
['uncommitted edit', (sup) => fs.writeFileSync(path.join(sup, 'sub', 's.txt'), 'edited\n'), true],
|
|
645
|
+
['untracked file', (sup) => fs.writeFileSync(path.join(sup, 'sub', 'new.txt'), 'new\n'), true],
|
|
646
|
+
['nested dirty submodule', (sup) => fs.writeFileSync(path.join(sup, 'sub', 'inner', 'i.txt'), 'edited\n'), true, true],
|
|
647
|
+
['hidden by submodule.<name>.ignore=all', (sup) => { git(sup, 'config', 'submodule.sub.ignore', 'all'); fs.writeFileSync(path.join(sup, 'sub', 's.txt'), 'edited\n'); }, true],
|
|
648
|
+
];
|
|
649
|
+
for (const [name, mutate, , nested] of scenarios) {
|
|
650
|
+
const sup = mkSuper(!!nested);
|
|
651
|
+
mutate(sup);
|
|
652
|
+
const cache = mktmp();
|
|
653
|
+
const log = path.join(mktmp(), 'log');
|
|
654
|
+
const cfg = mkcfg({ full: rec(log, 'Q', 50) });
|
|
655
|
+
const r1 = await runHelper(['--repo', sup, '--config-root', cfg], baseEnv(cache));
|
|
656
|
+
const r2 = await runHelper(['--repo', sup, '--config-root', cfg], baseEnv(cache));
|
|
657
|
+
ok(`(q) ${name}: runs, dirty-submodule warning`, r1.code === 0 && r1.json.source === 'run' && (r1.json.warnings || []).some((w) => /^dirty-submodule/.test(w)));
|
|
658
|
+
ok(`(q) ${name}: nothing cached, runs again`, resultsOf(cache).length === 0 && r2.json.source === 'run' && starts(log) === 2);
|
|
659
|
+
}
|
|
660
|
+
// A submodule at a different commit is covered by the tree.
|
|
661
|
+
const sup = mkSuper(false);
|
|
662
|
+
const cache = mktmp();
|
|
663
|
+
const log = path.join(mktmp(), 'log');
|
|
664
|
+
const cfg = mkcfg({ full: rec(log, 'Q', 50) });
|
|
665
|
+
const base = await runHelper(['--repo', sup, '--config-root', cfg], baseEnv(cache));
|
|
666
|
+
fs.writeFileSync(path.join(sup, 'sub', 's.txt'), 'moved\n');
|
|
667
|
+
git(path.join(sup, 'sub'), 'commit', '-qam', 'move');
|
|
668
|
+
const moved = await runHelper(['--repo', sup, '--config-root', cfg], baseEnv(cache));
|
|
669
|
+
ok('(q) a submodule at another commit changes the tree and is cacheable',
|
|
670
|
+
moved.json.tree !== base.json.tree && !(moved.json.warnings || []).length && resultsOf(cache).length === 2);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// --- (k) crash between spawn and go ------------------------------------------------------------------------------------------
|
|
674
|
+
{
|
|
675
|
+
const repo = mkrepo();
|
|
676
|
+
const cache = mktmp();
|
|
677
|
+
const marker = path.join(mktmp(), 'marker');
|
|
678
|
+
const cfg = mkcfg({ full: `touch ${marker}; sleep 30` });
|
|
679
|
+
const r = await runHelper(['--repo', repo, '--config-root', cfg], baseEnv(cache, { TRAILHEAD_TEST_CRASH_AT: 'before-go' }));
|
|
680
|
+
ok('(k) the wrapper crashed', r.code === 99);
|
|
681
|
+
const holders = holdersOf(cache);
|
|
682
|
+
const pgid = holders.length === 1 ? JSON.parse(fs.readFileSync(path.join(cache, 'queue', holders[0]), 'utf8')).pgid : null;
|
|
683
|
+
ok('(k) the group was recorded before go', Number.isInteger(pgid));
|
|
684
|
+
ok('(k) the launcher group dies without running anything', await until(() => !lib.groupAlive(pgid), 5000));
|
|
685
|
+
ok('(k) the command never ran', !fs.existsSync(marker));
|
|
686
|
+
// The orphaned holder is now dead and is reaped by the next run.
|
|
687
|
+
const r2 = await runHelper(['--repo', repo, '--config-root', mkcfg({ full: 'true' })], baseEnv(cache));
|
|
688
|
+
ok('(k) the next run reaps it and passes', r2.code === 0 && holdersOf(cache).length === 0);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// --- (l) a live gate is never taken ---------------------------------------------------------------------------------------------
|
|
692
|
+
{
|
|
693
|
+
const repo = mkrepo();
|
|
694
|
+
const cache = mktmp();
|
|
695
|
+
const cfg = mkcfg({ full: 'true' });
|
|
696
|
+
fs.mkdirSync(path.join(cache, 'queue'), { recursive: true });
|
|
697
|
+
const gate = path.join(cache, 'queue', '.gate');
|
|
698
|
+
const content = JSON.stringify({ pid: process.pid, token: 'mine' });
|
|
699
|
+
fs.writeFileSync(gate, content);
|
|
700
|
+
const old = new Date('2000-01-01T00:00:00Z');
|
|
701
|
+
fs.utimesSync(gate, old, old);
|
|
702
|
+
const h = start(['--repo', repo, '--config-root', cfg], baseEnv(cache));
|
|
703
|
+
await sleep(700);
|
|
704
|
+
ok('(l) the helper waits on a live gate, however old', h.child.exitCode === null && fs.readFileSync(gate, 'utf8') === content);
|
|
705
|
+
ok('(l) and says why', /queue gate held by pid/.test(h.stderr()));
|
|
706
|
+
fs.unlinkSync(gate);
|
|
707
|
+
const r = await h.done;
|
|
708
|
+
ok('(l) it goes once the gate is gone', r.code === 0);
|
|
709
|
+
// A dead gate is reaped.
|
|
710
|
+
fs.writeFileSync(gate, JSON.stringify({ pid: deadPid(), token: 'x' }));
|
|
711
|
+
const r2 = await runHelper(['--repo', repo, '--config-root', mkcfg({ full: 'true && true' })], baseEnv(cache));
|
|
712
|
+
ok('(l) a gate with a dead pid is reaped', r2.code === 0);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// --- (j) SIGTERM to the wrapper -------------------------------------------------------------------------------------------------------
|
|
716
|
+
{
|
|
717
|
+
const repo = mkrepo();
|
|
718
|
+
const cache = mktmp();
|
|
719
|
+
const pidFile = path.join(mktmp(), 'pid');
|
|
720
|
+
const cfg = mkcfg({ full: `trap '' TERM; echo $$ > ${pidFile}; sleep 60` });
|
|
721
|
+
const h = start(['--repo', repo, '--config-root', cfg], baseEnv(cache));
|
|
722
|
+
ok('(j) the command is running', await until(() => fs.existsSync(pidFile) && holdersOf(cache).length === 1));
|
|
723
|
+
await sleep(100);
|
|
724
|
+
const pid = Number(fs.readFileSync(pidFile, 'utf8'));
|
|
725
|
+
h.child.kill('SIGTERM');
|
|
726
|
+
const r = await h.done;
|
|
727
|
+
ok('(j) exit 143 and one interrupted line', r.code === 143 && r.json && r.json.status === 'interrupted' && r.json.signal === 'SIGTERM');
|
|
728
|
+
ok('(j) the TERM-ignoring descendant was killed', await until(() => !lib.pidAlive(pid), 3000));
|
|
729
|
+
ok('(j) the holder is gone and nothing cached', holdersOf(cache).length === 0 && resultsOf(cache).length === 0);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// --- I2: SIGHUP gets the same teardown, exit 129 ---------------------------------------------------
|
|
733
|
+
{
|
|
734
|
+
const repo = mkrepo();
|
|
735
|
+
const cache = mktmp();
|
|
736
|
+
const pidFile = path.join(mktmp(), 'pid');
|
|
737
|
+
const cfg = mkcfg({ full: `echo $$ > ${pidFile}; sleep 60` });
|
|
738
|
+
const h = start(['--repo', repo, '--config-root', cfg], baseEnv(cache));
|
|
739
|
+
ok('(I2) the command is running', await until(() => fs.existsSync(pidFile) && holdersOf(cache).length === 1));
|
|
740
|
+
await sleep(100);
|
|
741
|
+
const pid = Number(fs.readFileSync(pidFile, 'utf8'));
|
|
742
|
+
h.child.kill('SIGHUP');
|
|
743
|
+
const r = await h.done;
|
|
744
|
+
ok('(I2) exit 129 and interrupted', r.code === 129 && r.json && r.json.status === 'interrupted' && r.json.signal === 'SIGHUP');
|
|
745
|
+
ok('(I2) the command was torn down, holder gone', await until(() => !lib.pidAlive(pid), 3000) && holdersOf(cache).length === 0);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// cleanup
|
|
749
|
+
for (const c of children) c.kill('SIGKILL');
|
|
750
|
+
for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true });
|
|
751
|
+
console.log(`test-run.test.js: ${passed} checks passed`);
|
|
752
|
+
})().catch((e) => {
|
|
753
|
+
for (const c of children) c.kill('SIGKILL');
|
|
754
|
+
for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true });
|
|
755
|
+
console.error(e);
|
|
756
|
+
process.exit(1);
|
|
757
|
+
});
|