@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.
- package/README.it.md +142 -29
- package/README.md +141 -28
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/isolation-answer-docs.test.js +41 -0
- package/bin/isolation-copy-docs.test.js +28 -0
- package/bin/lib/codex-projection.js +17 -10
- package/bin/lib/codex-projection.test.js +80 -11
- package/bin/lib/install-verify.js +10 -5
- package/bin/lib/install-verify.test.js +51 -0
- package/bin/report-status-docs.test.js +195 -0
- package/bin/resolve-helpers-docs.test.js +36 -0
- package/bin/scope-confirm-skip-docs.test.js +70 -0
- package/bin/trailhead.js +40 -28
- package/bin/trailhead.test.js +503 -6
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +4 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
- package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
- package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
- package/plugins/trailhead/agents/trailhead-debug.md +20 -1
- package/plugins/trailhead/agents/trailhead-executor.md +29 -2
- package/plugins/trailhead/agents/trailhead-fix.md +29 -2
- package/plugins/trailhead/agents/trailhead-plan.md +20 -1
- package/plugins/trailhead/agents/trailhead-research.md +22 -3
- package/plugins/trailhead/agents/trailhead-verify.md +15 -2
- package/plugins/trailhead/commands/panel.md +10 -0
- package/plugins/trailhead/hooks/hooks.json +3 -18
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
- package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
- package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
- package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
- package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
- package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
- package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
- package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
- package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
- package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +532 -65
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +434 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
- package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +297 -0
- package/plugins/trailhead/hooks/mod/README.md +219 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +606 -0
- package/plugins/trailhead/hooks/mod/map-pane.tsx +832 -0
- package/plugins/trailhead/hooks/mod/register.tsx +293 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
- package/plugins/trailhead/hooks/mod/work-state-view.ts +110 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
- package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
- package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
- package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
- package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-install-guard.js +187 -42
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +182 -2
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
- package/plugins/trailhead/hooks/trailhead-search-guard.js +59 -13
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +68 -3
- package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
- package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
- package/plugins/trailhead/skills/_shared/choices.md +7 -3
- package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
- package/plugins/trailhead/skills/_shared/load-first.md +3 -3
- package/plugins/trailhead/skills/_shared/multi-map.md +3 -3
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
- package/plugins/trailhead/skills/_shared/substrate.md +6 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +17 -6
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +12 -2
- package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +16 -13
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +6 -4
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +28 -10
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
- package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
- package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +217 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1921 -0
- package/plugins/trailhead/tests/map-pane-view.test.ts +853 -0
- package/plugins/trailhead/tests/mod.test.ts +14 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
- package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
- package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
- package/plugins/trailhead/tests/work-state-view.test.ts +110 -0
- 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`);
|