@marcomigozzi/trailhead 0.13.0 → 0.15.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.
Files changed (120) hide show
  1. package/README.it.md +142 -29
  2. package/README.md +141 -28
  3. package/bin/base-preflight-docs.test.js +65 -0
  4. package/bin/execute-split-docs.test.js +146 -0
  5. package/bin/isolation-answer-docs.test.js +41 -0
  6. package/bin/isolation-copy-docs.test.js +28 -0
  7. package/bin/lib/codex-projection.js +17 -10
  8. package/bin/lib/codex-projection.test.js +80 -11
  9. package/bin/lib/install-verify.js +10 -5
  10. package/bin/lib/install-verify.test.js +51 -0
  11. package/bin/report-status-docs.test.js +195 -0
  12. package/bin/resolve-helpers-docs.test.js +36 -0
  13. package/bin/scope-confirm-skip-docs.test.js +70 -0
  14. package/bin/trailhead.js +40 -28
  15. package/bin/trailhead.test.js +503 -6
  16. package/bin/uat-setup-docs.test.js +65 -0
  17. package/package.json +4 -1
  18. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  19. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  20. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  21. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  22. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  23. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  24. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  25. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  26. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  27. package/plugins/trailhead/commands/panel.md +10 -0
  28. package/plugins/trailhead/hooks/hooks.json +3 -18
  29. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  30. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  31. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  32. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  33. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  34. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
  36. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  37. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  38. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  39. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  40. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  41. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  42. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  43. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  44. package/plugins/trailhead/hooks/lib/shell-scan.js +532 -65
  45. package/plugins/trailhead/hooks/lib/shell-scan.test.js +434 -0
  46. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  47. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  48. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  49. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  50. package/plugins/trailhead/hooks/lib/work-state.test.js +297 -0
  51. package/plugins/trailhead/hooks/mod/README.md +219 -0
  52. package/plugins/trailhead/hooks/mod/map-pane-view.ts +606 -0
  53. package/plugins/trailhead/hooks/mod/map-pane.tsx +832 -0
  54. package/plugins/trailhead/hooks/mod/register.tsx +293 -0
  55. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  56. package/plugins/trailhead/hooks/mod/work-state-view.ts +110 -0
  57. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  58. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  59. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  60. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  61. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  62. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  63. package/plugins/trailhead/hooks/trailhead-install-guard.js +187 -42
  64. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +182 -2
  65. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  66. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
  67. package/plugins/trailhead/hooks/trailhead-search-guard.js +59 -13
  68. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +68 -3
  69. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  70. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  71. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  72. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  73. package/plugins/trailhead/skills/_shared/choices.md +7 -3
  74. package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
  75. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  76. package/plugins/trailhead/skills/_shared/multi-map.md +3 -3
  77. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  78. package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
  79. package/plugins/trailhead/skills/_shared/substrate.md +6 -4
  80. package/plugins/trailhead/skills/_shared/teamwork.md +17 -6
  81. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
  82. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  83. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  84. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  85. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  86. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  87. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  88. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  89. package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
  90. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  91. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
  92. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  93. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  94. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  95. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  96. package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
  97. package/plugins/trailhead/skills/trailhead-work/SKILL.md +16 -13
  98. package/plugins/trailhead/skills/trailhead-work/references/auto.md +6 -4
  99. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  100. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +28 -10
  101. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  102. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  103. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  104. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  105. package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
  106. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  107. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  108. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  109. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  110. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  111. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  112. package/plugins/trailhead/templates/trailhead-work-state.js +217 -0
  113. package/plugins/trailhead/tests/map-pane-ui.test.ts +1921 -0
  114. package/plugins/trailhead/tests/map-pane-view.test.ts +853 -0
  115. package/plugins/trailhead/tests/mod.test.ts +14 -0
  116. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  117. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  118. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  119. package/plugins/trailhead/tests/work-state-view.test.ts +110 -0
  120. package/plugins/trailhead/types/index.d.ts +103 -0
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-uat-setup.js. Run: node uat-setup.test.js
3
+ // No framework: plain asserts, same style as base-preflight.test.js. Real temp dirs.
4
+ const assert = require('assert');
5
+ const { execFileSync } = require('child_process');
6
+ const path = require('path');
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+
10
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-uat-setup.js');
11
+ const { main, parseArgs } = require(scriptPath);
12
+
13
+ let passed = 0;
14
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
15
+ const eq = (name, actual, expected) => {
16
+ assert.deepStrictEqual(actual, expected, `${name}: got ${JSON.stringify(actual)}, want ${JSON.stringify(expected)}`);
17
+ passed++;
18
+ };
19
+
20
+ const tmpDirs = [];
21
+ const mktmp = () => {
22
+ const d = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-uat-setup-test-')));
23
+ tmpDirs.push(d);
24
+ return d;
25
+ };
26
+ const writeConfig = (root, obj) => {
27
+ fs.mkdirSync(path.join(root, '.trailhead'), { recursive: true });
28
+ fs.writeFileSync(path.join(root, '.trailhead', 'config.json'), typeof obj === 'string' ? obj : JSON.stringify(obj));
29
+ };
30
+
31
+ function run(argv, { env = {}, cwd = '/' } = {}) {
32
+ const chunks = [];
33
+ const errChunks = [];
34
+ const realErr = process.stderr.write.bind(process.stderr);
35
+ process.stderr.write = (s) => { errChunks.push(String(s)); return true; };
36
+ let code;
37
+ try {
38
+ code = main(argv, { cwd, env: { HOME: '/nonexistent-home', ...env }, stdout: { write: (s) => chunks.push(s) } });
39
+ } finally {
40
+ process.stderr.write = realErr;
41
+ }
42
+ const lines = chunks.join('').split('\n').filter(Boolean).map((l) => JSON.parse(l));
43
+ return { code, out: lines[0], lines, err: errChunks.join('') };
44
+ }
45
+
46
+ const emptyHome = mktmp();
47
+ const envNoGlobal = { CLAUDE_CONFIG_DIR: emptyHome };
48
+ const plan = (dir, extra = [], env = {}) => run(['plan', '--repo', dir, ...extra], { env: { ...envNoGlobal, ...env } });
49
+ const go = (dir, extra = [], env = {}) => run(['run', '--repo', dir, ...extra], { env: { ...envNoGlobal, ...env } });
50
+ const proj = (cfg) => { const d = mktmp(); if (cfg !== undefined) writeConfig(d, cfg); return d; };
51
+
52
+ // --- usage ----------------------------------------------------------------------
53
+ {
54
+ ok('parseArgs: missing command', parseArgs([]).error);
55
+ ok('parseArgs: unknown command', parseArgs(['nope', '--repo', '/x']).error);
56
+ ok('parseArgs: missing --repo', parseArgs(['plan']).error);
57
+ ok('parseArgs: unknown flag', parseArgs(['plan', '--repo', '/x', '--bogus']).error);
58
+ ok('parseArgs: valueless flag', parseArgs(['plan', '--repo']).error);
59
+ ok('parseArgs: --only and --from are exclusive', parseArgs(['run', '--repo', '/x', '--only', '0', '--from', '1']).error);
60
+ ok('parseArgs: bad timeout', parseArgs(['run', '--repo', '/x', '--ready-timeout', 'abc']).error);
61
+ for (const v of ['Infinity', '1e999', '-1', '0', 'NaN']) {
62
+ ok(`parseArgs: --step-timeout ${v} rejected`, parseArgs(['run', '--repo', '/x', '--step-timeout', v]).error);
63
+ ok(`parseArgs: --ready-timeout ${v} rejected`, parseArgs(['run', '--repo', '/x', '--ready-timeout', v]).error);
64
+ }
65
+ ok('parseArgs: fractional timeout accepted', !parseArgs(['run', '--repo', '/x', '--step-timeout', '0.5']).error);
66
+ const inf = run(['run', '--repo', '/x', '--step-timeout', 'Infinity']);
67
+ eq('infinite timeout exits 2 with no stdout', [inf.code, inf.lines.length], [2, 0]);
68
+ const r = run([]);
69
+ eq('usage error exits 2', r.code, 2);
70
+ ok('usage error writes stderr, no stdout', r.err.includes('usage') && r.lines.length === 0);
71
+ }
72
+
73
+ // --- config reading -------------------------------------------------------------
74
+ {
75
+ eq('unset: unconfigured', plan(proj()).out.status, 'unconfigured');
76
+ eq('no uat key: unconfigured', plan(proj({ testing: { webapp: true } })).out.status, 'unconfigured');
77
+ const none = plan(proj({ uat: { setup: [] } }));
78
+ eq('[]: none', [none.out.status, none.code], ['none', 0]);
79
+ eq('[]: run is none too', go(proj({ uat: { setup: [] } })).out.status, 'none');
80
+ eq('unconfigured run runs nothing', go(proj()).out.status, 'unconfigured');
81
+ const nested = plan(proj({ uat: { setup: [{ name: 'a', run: 'true' }] } }));
82
+ eq('nested: ready to plan', [nested.out.status, nested.out.steps.length], ['ready', 1]);
83
+ const flat = plan(proj({ 'uat.setup': [{ name: 'a', run: 'true' }, { name: 'b', run: 'true' }] }));
84
+ eq('flat: read', flat.out.steps.length, 2);
85
+ const both = plan(proj({ uat: { setup: [{ name: 'n', run: 'true' }] }, 'uat.setup': [{ name: 'f', run: 'true' }] }));
86
+ eq('both: nested wins', both.out.steps[0].name, 'n');
87
+ ok('both: a warning', both.out.warnings.some((w) => /nested/.test(w)));
88
+ const bad = plan(proj('{not json'));
89
+ eq('unparseable file: invalid', bad.out.status, 'invalid');
90
+ }
91
+
92
+ // --- project replaces global, key by key ----------------------------------------
93
+ {
94
+ const home = mktmp();
95
+ fs.mkdirSync(path.join(home, 'trailhead'));
96
+ fs.writeFileSync(path.join(home, 'trailhead', 'config.json'), JSON.stringify({
97
+ uat: { setup: [{ name: 'global', run: 'true' }], accounts: { qa: { user: 'g@x.io', secret: '$G_PW' } } },
98
+ }));
99
+ const env = { CLAUDE_CONFIG_DIR: home };
100
+ const onlyGlobal = run(['plan', '--repo', proj()], { env });
101
+ eq('global only: setup from global', onlyGlobal.out.steps[0].name, 'global');
102
+ const p = proj({ uat: { setup: [{ name: 'project', run: 'true' }] } });
103
+ const mixed = run(['plan', '--repo', p], { env });
104
+ eq('project setup replaces global setup', mixed.out.steps.map((s) => s.name), ['project']);
105
+ eq('accounts still resolved from global', mixed.out.accounts.map((a) => a.role), ['qa']);
106
+ const empty = run(['plan', '--repo', proj({ uat: { setup: [] } })], { env });
107
+ eq('project [] replaces a global list', empty.out.status, 'none');
108
+ const wt = mktmp();
109
+ const viaRoot = run(['plan', '--repo', wt, '--config-root', p], { env });
110
+ eq('--config-root supplies the project file', viaRoot.out.steps[0].name, 'project');
111
+ }
112
+
113
+ // --- validation -----------------------------------------------------------------
114
+ {
115
+ const invalid = (name, cfg) => {
116
+ const r = go(proj(cfg));
117
+ eq(`invalid: ${name}`, [r.out.status, r.code], ['invalid', 0]);
118
+ ok(`invalid: ${name} has a message`, typeof r.out.message === 'string' && r.out.message.length > 0);
119
+ };
120
+ invalid('setup not an array', { uat: { setup: 'make up' } });
121
+ invalid('step not an object', { uat: { setup: ['make up'] } });
122
+ invalid('step without run', { uat: { setup: [{ name: 'a' }] } });
123
+ invalid('step without name', { uat: { setup: [{ run: 'true' }] } });
124
+ invalid('empty name', { uat: { setup: [{ name: ' ', run: 'true' }] } });
125
+ invalid('non-string check', { uat: { setup: [{ name: 'a', run: 'true', check: 1 }] } });
126
+ invalid('unknown key', { uat: { setup: [{ name: 'a', run: 'true', extra: 1 }] } });
127
+ invalid('accounts not an object', { uat: { accounts: [] } });
128
+ invalid('uat is a string', { uat: 'x' });
129
+ invalid('uat is an array', { uat: [] });
130
+ invalid('uat is null', { uat: null });
131
+ invalid('account without secret', { uat: { setup: [], accounts: { qa: { user: 'u' } } } });
132
+ invalid('account without user', { uat: { setup: [], accounts: { qa: { secret: '$X' } } } });
133
+ invalid('literal secret rejected', { uat: { setup: [], accounts: { qa: { user: 'u', secret: 'hunter2' } } } });
134
+ invalid('secret embedded in text rejected', { uat: { setup: [], accounts: { qa: { user: 'u', secret: 'pw-$X' } } } });
135
+ invalid('unknown account key', { uat: { setup: [], accounts: { qa: { user: 'u', secret: '$X', extra: 1 } } } });
136
+ const okAcc = plan(proj({ uat: { setup: [], accounts: { qa: { user: 'u@x.io', secret: '${QA_PW}' }, dev: { user: '$DEV_USER', secret: '$DEV_PW' } } } }), [], { QA_PW: 'sekret-1' });
137
+ eq('literal user and bare refs accepted', okAcc.out.status, 'none');
138
+ // invalid accounts must not run an otherwise good setup
139
+ const marker = path.join(mktmp(), 'ran');
140
+ const r = go(proj({ uat: { setup: [{ name: 'a', run: `touch ${marker}` }], accounts: { qa: { user: 'u', secret: 'literal' } } } }));
141
+ eq('invalid accounts: nothing runs', [r.out.status, fs.existsSync(marker)], ['invalid', false]);
142
+ }
143
+
144
+ // --- plan -----------------------------------------------------------------------
145
+ {
146
+ const d = proj({ uat: {
147
+ setup: [
148
+ { name: 'db', run: 'docker compose up -d db', check: 'true', note: 'ask before using the Pixel' },
149
+ { name: 'app', run: 'DEVICE=$ADB_DEVICE npm start --token ${API_TOKEN}' },
150
+ ],
151
+ accounts: { qa: { user: 'qa@x.io', secret: '$QA_PW' }, dev: { user: '$DEV_USER', secret: '${DEV_PW}' } },
152
+ } });
153
+ const r = plan(d, [], { ADB_DEVICE: 'emulator-5554', QA_PW: 'topsecret' });
154
+ eq('plan: steps', r.out.steps.map((s) => [s.index, s.name, s.note || null, s.check]), [[0, 'db', 'ask before using the Pixel', true], [1, 'app', null, false]]);
155
+ eq('plan: step env presence', r.out.steps[1].env, [{ name: 'ADB_DEVICE', set: true }, { name: 'API_TOKEN', set: false }]);
156
+ eq('plan: accounts', r.out.accounts, [
157
+ { role: 'qa', user: 'qa@x.io', userEnv: null, secretEnv: { name: 'QA_PW', set: true } },
158
+ { role: 'dev', user: null, userEnv: { name: 'DEV_USER', set: false }, secretEnv: { name: 'DEV_PW', set: false } },
159
+ ]);
160
+ ok('plan: never prints a value', !JSON.stringify(r.out).includes('emulator-5554') && !JSON.stringify(r.out).includes('topsecret'));
161
+ const marker = path.join(mktmp(), 'ran');
162
+ plan(proj({ uat: { setup: [{ name: 'a', run: `touch ${marker}` }] } }));
163
+ ok('plan never runs anything', !fs.existsSync(marker));
164
+ }
165
+
166
+ // --- run ------------------------------------------------------------------------
167
+ {
168
+ const dir = mktmp();
169
+ const f = (n) => path.join(dir, n);
170
+ const d = proj({ uat: { setup: [
171
+ { name: 'up', run: `touch ${f('up')}`, check: `test -f ${f('up')}` },
172
+ { name: 'always', run: `echo x >> ${f('log')}` },
173
+ ] } });
174
+ const first = go(d);
175
+ eq('run: failing check runs the step, then ready', [first.out.status, first.out.steps.map((s) => s.result)], ['ready', ['ran', 'ran']]);
176
+ const second = go(d);
177
+ eq('run: passing check skips, no check always runs', second.out.steps.map((s) => s.result), ['skipped', 'ran']);
178
+ eq('run: always step ran twice', fs.readFileSync(f('log'), 'utf8').split('\n').filter(Boolean).length, 2);
179
+ ok('run: ok steps carry no output', second.out.steps.every((s) => s.output === undefined));
180
+ }
181
+ {
182
+ const dir = mktmp();
183
+ const f = (n) => path.join(dir, n);
184
+ const d = proj({ uat: { setup: [
185
+ { name: 's0', run: `touch ${f('s0')}` },
186
+ { name: 's1', run: 'echo boom-visible; exit 3' },
187
+ { name: 's2', run: `touch ${f('s2')}` },
188
+ ] } });
189
+ const r = go(d);
190
+ eq('failed: status and failedStep', [r.out.status, r.out.failedStep], ['failed', 1]);
191
+ eq('failed: step result carries code and output', [r.out.steps[1].result, r.out.steps[1].code, r.out.steps[1].output.trim()], ['failed', 3, 'boom-visible']);
192
+ eq('failed: stops at the first failure', [fs.existsSync(f('s0')), fs.existsSync(f('s2')), r.out.steps.length], [true, false, 2]);
193
+ fs.unlinkSync(f('s0'));
194
+ const from = go(d, ['--from', '1']);
195
+ eq('--from skips the earlier steps', [from.out.steps[0].index, fs.existsSync(f('s0'))], [1, false]);
196
+ const only = go(proj({ uat: { setup: [{ name: 'a', run: `touch ${f('oa')}` }, { name: 'b', run: `touch ${f('ob')}` }] } }), ['--only', '1']);
197
+ eq('--only runs exactly one', [only.out.status, only.out.steps.map((s) => s.index), fs.existsSync(f('oa')), fs.existsSync(f('ob'))], ['ready', [1], false, true]);
198
+ eq('--only out of range: invalid', go(d, ['--only', '9']).out.status, 'invalid');
199
+ const cwdProbe = go(proj({ uat: { setup: [{ name: 'cwd', run: 'pwd > cwd.txt' }] } }), []);
200
+ ok('run cwd is --repo', cwdProbe.out.status === 'ready');
201
+ }
202
+ {
203
+ const dir = mktmp();
204
+ const marker = path.join(dir, 'ran');
205
+ const d = proj({ uat: { setup: [
206
+ { name: 'needs', run: `touch ${marker}; echo $MISSING_ONE ${'${MISSING_TWO}'}`, check: 'false' },
207
+ ] } });
208
+ const r = go(d, [], { MISSING_ONE: '' });
209
+ eq('missing-env: failed and names the variables', [r.out.status, r.out.steps[0].result, r.out.steps[0].missing], ['failed', 'missing-env', ['MISSING_ONE', 'MISSING_TWO']]);
210
+ ok('missing-env runs nothing', !fs.existsSync(marker));
211
+ }
212
+ {
213
+ // shell-local $names are not env references: single quotes and \$ escape
214
+ const marker = path.join(mktmp(), 'ran');
215
+ const pl = plan(proj({ uat: { setup: [{ name: 'local', run: "for f in a b; do echo \\$f; done; awk '{print $NF}' </dev/null; echo '$QUOTED'; echo \\$ESC; echo \"\\$ESC2\"; echo $REAL", check: "echo '${Q2}' >/dev/null" }] } }), [], { REAL: 'v' });
216
+ eq('plan: only unquoted, unescaped refs listed', pl.out.steps[0].env.map((e) => e.name), ['REAL']);
217
+ const loc = go(proj({ uat: { setup: [{ name: 'local', run: `awk '{print $NF}' </dev/null; echo '$NOPE_UNSET' >/dev/null; echo \\$NOPE_UNSET2 >/dev/null; touch ${marker}` }] } }));
218
+ eq('run: single-quoted and escaped refs need no env', [loc.out.status, loc.out.steps[0].result, fs.existsSync(marker)], ['ready', 'ran', true]);
219
+ const dq = go(proj({ uat: { setup: [{ name: 'dq', run: 'echo "$DQ_MISSING"' }] } }));
220
+ eq('double-quoted ref still required', [dq.out.steps[0].result, dq.out.steps[0].missing], ['missing-env', ['DQ_MISSING']]);
221
+ const sq = go(proj({ uat: { setup: [{ name: 'sq', run: "echo 'it''s' $SQ_MISSING" }] } }));
222
+ eq('ref after adjacent quotes still required', sq.out.steps[0].missing, ['SQ_MISSING']);
223
+ }
224
+ {
225
+ // un commento di shell non apre span tra apici e i suoi riferimenti sono ignorati
226
+ const marker = path.join(mktmp(), 'ran');
227
+ const cm = go(proj({ uat: { setup: [{ name: 'cm', run: "# don't forget\necho dev=$DEVICE_ID" }] } }));
228
+ eq('apostrophe in a comment: later ref still required', [cm.out.steps[0].result, cm.out.steps[0].missing], ['missing-env', ['DEVICE_ID']]);
229
+ const cref = go(proj({ uat: { setup: [{ name: 'cref', run: `echo hi # uses $COMMENT_ONLY\ntouch ${marker}` }] } }));
230
+ eq('ref inside a comment is not required', [cref.out.steps[0].result, fs.existsSync(marker)], ['ran', true]);
231
+ const mid = go(proj({ uat: { setup: [{ name: 'mid', run: 'echo a#b $HASH_MISSING' }] } }));
232
+ eq('# not at a word start is no comment', mid.out.steps[0].missing, ['HASH_MISSING']);
233
+ const dqh = go(proj({ uat: { setup: [{ name: 'dqh', run: 'echo "# $DQH_MISSING"' }] } }));
234
+ eq('# inside double quotes is no comment', dqh.out.steps[0].missing, ['DQH_MISSING']);
235
+ // quote aperta a fine testo: fail closed, ogni riferimento conta
236
+ const open = go(proj({ uat: { setup: [{ name: 'open', run: "echo it's $OPEN_MISSING '$OPEN_QUOTED'" }] } }));
237
+ eq('unbalanced single quote: every ref required', open.out.steps[0].missing, ['OPEN_MISSING', 'OPEN_QUOTED']);
238
+ const openD = go(proj({ uat: { setup: [{ name: 'openD', run: "echo \"x $OPEND_MISSING '$OPEND_Q'" }] } }));
239
+ eq('unbalanced double quote: every ref required', openD.out.steps[0].missing, ['OPEND_MISSING', 'OPEND_Q']);
240
+ }
241
+ {
242
+ const big = parseArgs(['run', '--repo', '/x', '--step-timeout', '86401']);
243
+ ok('parseArgs: --step-timeout above 86400 rejected', big.error);
244
+ ok('parseArgs: --ready-timeout above 86400 rejected', parseArgs(['run', '--repo', '/x', '--ready-timeout', '86401']).error);
245
+ ok('parseArgs: 86400 accepted', !parseArgs(['run', '--repo', '/x', '--step-timeout', '86400', '--ready-timeout', '86400']).error);
246
+ eq('oversized timeout exits 2', run(['run', '--repo', '/x', '--ready-timeout', '999999']).code, 2);
247
+ }
248
+ {
249
+ // a tiny fractional timeout still becomes a valid >= 1 ms spawn timeout
250
+ const r = go(proj({ uat: { setup: [{ name: 'hang', run: 'sleep 30' }] } }), ['--step-timeout', '0.0001']);
251
+ eq('sub-millisecond timeout: timeout result', r.out.steps[0].result, 'timeout');
252
+ }
253
+ {
254
+ // a mid-run exception keeps the steps already collected
255
+ const gone = mktmp();
256
+ const prev = process.env.TMPDIR;
257
+ process.env.TMPDIR = gone;
258
+ try {
259
+ const r = go(proj({ uat: { setup: [
260
+ { name: 'a', run: 'true', check: `rm -rf ${gone}` },
261
+ { name: 'b', run: 'true' },
262
+ ] } }));
263
+ eq('mid-run exception: invalid with message', [r.out.status, typeof r.out.message], ['invalid', 'string']);
264
+ eq('mid-run exception: collected steps kept', r.out.steps.map((s) => [s.index, s.result]), [[0, 'skipped']]);
265
+ } finally {
266
+ if (prev === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = prev;
267
+ }
268
+ }
269
+ {
270
+ // the poll after run caps each check at the remaining ready time
271
+ const dir = mktmp();
272
+ const t0 = Date.now();
273
+ const r = go(proj({ uat: { setup: [{ name: 'slow', run: `touch ${dir}/ran`, check: `test -f ${dir}/ran && sleep 30` }] } }), ['--ready-timeout', '1']);
274
+ eq('slow check while polling: not-ready', r.out.steps[0].result, 'not-ready');
275
+ ok('poll check capped at the remaining ready time', Date.now() - t0 < 8000);
276
+ }
277
+ {
278
+ // not-ready: the run succeeds but the check never passes
279
+ const d = proj({ uat: { setup: [{ name: 'srv', run: 'true', check: 'false' }] } });
280
+ const t0 = Date.now();
281
+ const r = go(d, ['--ready-timeout', '1']);
282
+ eq('not-ready', [r.out.status, r.out.steps[0].result], ['failed', 'not-ready']);
283
+ ok('not-ready waited for the timeout', Date.now() - t0 >= 900);
284
+ // ready after a delayed check
285
+ const dir = mktmp();
286
+ const d2 = proj({ uat: { setup: [{ name: 'srv', run: `(sleep 1; touch ${dir}/up) >/dev/null 2>&1 &`, check: `test -f ${dir}/up` }] } });
287
+ const r2 = go(d2, ['--ready-timeout', '10']);
288
+ eq('a backgrounded process is detected through check', [r2.out.status, r2.out.steps[0].result], ['ready', 'ran']);
289
+ }
290
+ {
291
+ // timeouts
292
+ const t0 = Date.now();
293
+ const r = go(proj({ uat: { setup: [{ name: 'hang', run: 'sleep 30' }] } }), ['--step-timeout', '1']);
294
+ eq('hung run: timeout', [r.out.status, r.out.steps[0].result], ['failed', 'timeout']);
295
+ ok('hung run killed promptly', Date.now() - t0 < 10000);
296
+ // a hung check counts as failing before run: the step runs
297
+ const dir = mktmp();
298
+ const r2 = go(proj({ uat: { setup: [{ name: 'c', run: `touch ${dir}/ran`, check: 'sleep 30' }] } }), ['--step-timeout', '1', '--ready-timeout', '1']);
299
+ eq('hung check before run: step runs', fs.existsSync(path.join(dir, 'ran')), true);
300
+ eq('hung check while polling: not-ready', r2.out.steps[0].result, 'not-ready');
301
+ // a backgrounded child holding stdout must not hang the helper
302
+ const t1 = Date.now();
303
+ const r3 = go(proj({ uat: { setup: [{ name: 'bg', run: 'sleep 4 &' }] } }));
304
+ eq('background child does not block', r3.out.status, 'ready');
305
+ ok('returns before the background child ends', Date.now() - t1 < 3500);
306
+ }
307
+ {
308
+ // output: bounded tail and redaction
309
+ const big = go(proj({ uat: { setup: [{ name: 'big', run: 'yes aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | head -c 20000000; exit 1' }] } }));
310
+ ok('large output stays bounded', big.out.steps[0].output.length <= 2100);
311
+ const red = go(proj({ uat: {
312
+ setup: [{ name: 'leak', run: 'echo "token=$API_TOKEN pw=$QA_PW"; exit 1' }],
313
+ accounts: { qa: { user: 'u', secret: '$QA_PW' } },
314
+ } }), [], { API_TOKEN: 'abc123', QA_PW: 'zzz-secret' });
315
+ const o = red.out.steps[0].output;
316
+ ok('output redacts referenced values to $NAME', o.includes('token=$API_TOKEN') && o.includes('pw=$QA_PW') && !o.includes('abc123') && !o.includes('zzz-secret'));
317
+ const overlap = go(proj({ uat: { setup: [{ name: 'o', run: 'echo $SHORT $LONG; exit 1' }] } }), [], { SHORT: 'abc', LONG: 'abcdef' });
318
+ const oo = overlap.out.steps[0].output;
319
+ ok('overlapping values redact whole (longest first)', oo.includes('$SHORT $LONG') && !oo.includes('def'));
320
+ }
321
+
322
+ // --- end to end -----------------------------------------------------------------
323
+ {
324
+ const d = proj({ uat: { setup: [{ name: 'a', run: 'true' }] } });
325
+ const out = execFileSync('node', [scriptPath, 'run', '--repo', d], {
326
+ env: { ...process.env, CLAUDE_CONFIG_DIR: emptyHome, HOME: '/nonexistent-home' },
327
+ }).toString();
328
+ const parsed = JSON.parse(out.trim());
329
+ eq('e2e: ready', parsed.status, 'ready');
330
+ eq('e2e: one JSON line', out.trim().split('\n').length, 1);
331
+ let code = 0;
332
+ try { execFileSync('node', [scriptPath], { stdio: 'ignore' }); } catch (err) { code = err.status; }
333
+ eq('e2e: usage error exits 2', code, 2);
334
+ ok('e2e: helper is executable', (fs.statSync(scriptPath).mode & 0o111) !== 0);
335
+ }
336
+
337
+ // --- cleanup --------------------------------------------------------------------
338
+ for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true });
339
+
340
+ console.log(`uat-setup.test.js: ${passed} checks passed`);