@marcomigozzi/trailhead 0.14.0 → 0.16.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 (156) hide show
  1. package/README.it.md +158 -34
  2. package/README.md +147 -33
  3. package/bin/auto-intro-docs.test.js +72 -0
  4. package/bin/auto-merge-docs.test.js +33 -0
  5. package/bin/auto-merge-ticket-docs.test.js +32 -0
  6. package/bin/auto-review-rounds-docs.test.js +45 -0
  7. package/bin/auto-run-docs.test.js +60 -0
  8. package/bin/auto-scope-docs.test.js +33 -0
  9. package/bin/auto-slots-docs.test.js +45 -0
  10. package/bin/auto-uat-docs.test.js +47 -0
  11. package/bin/base-preflight-docs.test.js +65 -0
  12. package/bin/execute-split-docs.test.js +146 -0
  13. package/bin/grilling-holistic-review-docs.test.js +90 -0
  14. package/bin/isolation-answer-docs.test.js +41 -0
  15. package/bin/isolation-copy-docs.test.js +28 -0
  16. package/bin/lib/codex-projection.js +6 -6
  17. package/bin/lib/codex-projection.test.js +43 -2
  18. package/bin/needs-you-docs.test.js +41 -0
  19. package/bin/pick-stress-test-docs.test.js +105 -0
  20. package/bin/report-status-docs.test.js +195 -0
  21. package/bin/resolve-helpers-docs.test.js +36 -0
  22. package/bin/scope-confirm-skip-docs.test.js +70 -0
  23. package/bin/trailhead.js +6 -6
  24. package/bin/trailhead.test.js +355 -16
  25. package/bin/uat-setup-docs.test.js +65 -0
  26. package/package.json +1 -1
  27. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  28. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  29. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  30. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  31. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  32. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  33. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  34. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  35. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  36. package/plugins/trailhead/commands/auto.md +2 -2
  37. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  38. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  39. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  40. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  41. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  42. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  43. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  44. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  45. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  46. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  47. package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
  48. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  49. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  50. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  51. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  52. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  53. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  54. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  55. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  56. package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
  57. package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
  58. package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
  59. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  60. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  61. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  62. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  63. package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
  64. package/plugins/trailhead/hooks/mod/README.md +209 -36
  65. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  66. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  67. package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
  68. package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
  69. package/plugins/trailhead/hooks/mod/register.tsx +312 -40
  70. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  71. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  72. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  73. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  74. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  75. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  76. package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
  77. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  78. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  79. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  80. package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
  81. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
  82. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  83. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  84. package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
  85. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
  86. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
  87. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
  88. package/plugins/trailhead/skills/_shared/choices.md +11 -4
  89. package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
  90. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  91. package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
  92. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  93. package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
  94. package/plugins/trailhead/skills/_shared/substrate.md +9 -4
  95. package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
  96. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
  97. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  98. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  99. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  100. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  101. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  102. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  103. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  104. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  105. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  106. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  107. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
  108. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  109. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  110. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  111. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  112. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  113. package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
  114. package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
  115. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  116. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  117. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
  118. package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
  119. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  120. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  121. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  122. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  123. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  124. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  125. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  126. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  127. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  128. package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
  129. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  130. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  131. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  132. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  133. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  134. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  135. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  136. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  137. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  138. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  139. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  140. package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
  141. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  142. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  143. package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
  144. package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
  145. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  146. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  147. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  148. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  149. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  150. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  151. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  152. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  153. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  154. package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
  155. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  156. package/plugins/trailhead/types/index.d.ts +62 -3
@@ -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`);
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-unblock.js. Run: node unblock.test.js
3
+ // No framework: plain asserts, same style as needs-you.test.js.
4
+ //
5
+ // Layers covered:
6
+ // 1. main() against a fake tracker behind an injected `gh` (multi-page reads included).
7
+ // 2. Usage errors (exit 2).
8
+ // 3. End-to-end: the script itself with a fake `gh` on PATH.
9
+ const assert = require('assert');
10
+ const { spawnSync } = require('child_process');
11
+ const path = require('path');
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+
15
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-unblock.js');
16
+ const { main } = require(scriptPath);
17
+
18
+ let passed = 0;
19
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
20
+
21
+ // --- fake tracker ------------------------------------------------------------
22
+ // dependents: [{number, state, labels}] of the closed issue (paginated output is the
23
+ // concatenation of one JSON object per line, whatever the page size).
24
+ // blockers: { [dependent]: [{number, state}] }. failOn: blocking | blocked_by | edit.
25
+ function makeTracker(init = {}) {
26
+ const t = { dependents: init.dependents || [], blockers: init.blockers || {}, failOn: init.failOn || null, badJson: !!init.badJson, calls: [], edits: [] };
27
+ const res = (stdout, status = 0) => ({ status, stdout, stderr: status ? 'forced failure' : '' });
28
+ const lines = (list) => list.map((o) => JSON.stringify(o)).join('\n') + (list.length ? '\n' : '');
29
+ t.gh = (args) => {
30
+ t.calls.push(args.join(' '));
31
+ if (args[0] === 'api') {
32
+ const url = args.find((a) => a.startsWith('repos/'));
33
+ const m = /issues\/(\d+)\/dependencies\/(blocking|blocked_by)$/.exec(url || '');
34
+ if (!m) return res('', 1);
35
+ if (t.failOn === m[2]) return res('', 1);
36
+ if (t.badJson) return res('not json\n');
37
+ if (m[2] === 'blocking') return res(lines(t.dependents));
38
+ return res(lines(t.blockers[m[1]] || []));
39
+ }
40
+ if (args[0] === 'issue' && args[1] === 'edit') {
41
+ if (t.failOn === 'edit') return res('', 1);
42
+ t.edits.push(args.join(' '));
43
+ const n = Number(args[2]);
44
+ const d = t.dependents.find((x) => x.number === n);
45
+ if (d) d.labels = d.labels.filter((l) => l !== args[args.indexOf('--remove-label') + 1]);
46
+ return res('');
47
+ }
48
+ return res('', 1);
49
+ };
50
+ return t;
51
+ }
52
+
53
+ function run(t, argv, extra = {}) {
54
+ let out = '';
55
+ let errOut = '';
56
+ const code = main(argv, { gh: t.gh, stdout: { write: (s) => { out += s; } }, stderr: { write: (s) => { errOut += s; } }, ...extra });
57
+ let json = null;
58
+ try { json = JSON.parse(out.trim()); } catch { /* not json */ }
59
+ return { code, out, json, errOut };
60
+ }
61
+
62
+ const args = ['--issue', '5', '--repo', 'o/r'];
63
+ const B = 'trailhead:blocked';
64
+
65
+ // --- last blocker closed -> label removed ------------------------------------
66
+ {
67
+ const t = makeTracker({
68
+ dependents: [{ number: 8, state: 'open', labels: [B, 'x'] }],
69
+ blockers: { 8: [{ number: 5, state: 'closed' }, { number: 6, state: 'closed' }] },
70
+ });
71
+ const r = run(t, args);
72
+ ok('unblock: exits 0 with status ok', r.code === 0 && r.json.status === 'ok');
73
+ ok('unblock: the dependent is listed', JSON.stringify(r.json.unblocked) === '[8]' && r.json.stillBlocked.length === 0);
74
+ ok('unblock: the blocked label is removed', t.edits.length === 1 && t.edits[0].includes('--remove-label trailhead:blocked') && t.edits[0].includes('-R o/r'));
75
+ ok('unblock: the reads are paginated', t.calls.filter((c) => c.startsWith('api')).every((c) => c.includes('--paginate')));
76
+ const again = run(t, args);
77
+ ok('unblock: a retry is idempotent (label already gone)', again.code === 0 && again.json.unblocked.length === 0 && t.edits.length === 1);
78
+ }
79
+
80
+ // --- another blocker still open ----------------------------------------------
81
+ {
82
+ const t = makeTracker({
83
+ dependents: [{ number: 8, state: 'open', labels: [B] }],
84
+ blockers: { 8: [{ number: 5, state: 'closed' }, { number: 6, state: 'open' }, { number: 7, state: 'open' }] },
85
+ });
86
+ const r = run(t, args);
87
+ ok('unblock: an open blocker keeps the label', r.code === 0 && r.json.unblocked.length === 0 && t.edits.length === 0);
88
+ ok('unblock: stillBlocked names the open blockers', r.json.stillBlocked.length === 1 && r.json.stillBlocked[0].issue === 8 && JSON.stringify(r.json.stillBlocked[0].openBlockers) === '[6,7]');
89
+ }
90
+
91
+ // --- skipped dependents -------------------------------------------------------
92
+ {
93
+ const t = makeTracker({
94
+ dependents: [
95
+ { number: 8, state: 'open', labels: [] },
96
+ { number: 9, state: 'closed', labels: [B] },
97
+ { number: 10, state: 'open', labels: [B] },
98
+ ],
99
+ blockers: { 10: [{ number: 5, state: 'closed' }] },
100
+ });
101
+ const r = run(t, args);
102
+ ok('unblock: a dependent without the label is skipped', !t.calls.some((c) => c.includes('issues/8/')));
103
+ ok('unblock: a closed dependent is skipped', !t.calls.some((c) => c.includes('issues/9/')));
104
+ ok('unblock: only the open labelled dependent is unblocked', JSON.stringify(r.json.unblocked) === '[10]');
105
+ }
106
+
107
+ // --- no dependents ------------------------------------------------------------
108
+ {
109
+ const r = run(makeTracker(), args);
110
+ ok('unblock: no dependents is ok and empty', r.code === 0 && r.json.status === 'ok' && r.json.unblocked.length === 0 && r.json.stillBlocked.length === 0);
111
+ }
112
+
113
+ // --- gh failures --------------------------------------------------------------
114
+ {
115
+ const t = makeTracker({ failOn: 'blocking' });
116
+ const r = run(t, args);
117
+ ok('unblock: a failed dependents read is an error', r.code === 1 && r.json.status === 'error' && /dependents|blocking/.test(r.json.reason));
118
+ ok('unblock: nothing edited after a failed read', t.edits.length === 0);
119
+ }
120
+ {
121
+ const t = makeTracker({
122
+ dependents: [{ number: 8, state: 'open', labels: [B] }, { number: 9, state: 'open', labels: [B] }],
123
+ blockers: { 8: [{ number: 5, state: 'closed' }], 9: [{ number: 5, state: 'closed' }] },
124
+ failOn: 'blocked_by',
125
+ });
126
+ const r = run(t, args);
127
+ ok('unblock: a failed blockers read is an error naming the step', r.code === 1 && r.json.status === 'error' && /blocked_by|blockers/.test(r.json.reason));
128
+ ok('unblock: nothing further edited after the failure', t.edits.length === 0 && t.calls.filter((c) => c.includes('issues/9/')).length === 0);
129
+ }
130
+ {
131
+ const t = makeTracker({
132
+ dependents: [{ number: 8, state: 'open', labels: [B] }, { number: 9, state: 'open', labels: [B] }],
133
+ blockers: { 8: [{ number: 5, state: 'closed' }], 9: [{ number: 5, state: 'closed' }] },
134
+ failOn: 'edit',
135
+ });
136
+ const r = run(t, args);
137
+ ok('unblock: a failed label removal is an error', r.code === 1 && r.json.status === 'error' && /remove/.test(r.json.reason));
138
+ ok('unblock: the next dependent is not touched after a failed edit', t.calls.filter((c) => c.startsWith('issue edit')).length === 1);
139
+ }
140
+ {
141
+ const r = run(makeTracker({ badJson: true }), args);
142
+ ok('unblock: unparseable gh output is an error', r.code === 1 && r.json.status === 'error' && /unparseable/.test(r.json.reason));
143
+ }
144
+
145
+ // --- multi-page reads ---------------------------------------------------------
146
+ // gh api --paginate with --jq prints one object per line across every page, so a
147
+ // second page is just more lines; the fake returns them concatenated, and the helper
148
+ // must read all of them (two pages of dependents, an open blocker only on page two).
149
+ {
150
+ const t = makeTracker({
151
+ dependents: [
152
+ ...Array.from({ length: 30 }, (_, i) => ({ number: 100 + i, state: 'closed', labels: [] })),
153
+ { number: 8, state: 'open', labels: [B] },
154
+ ],
155
+ blockers: {
156
+ 8: [...Array.from({ length: 30 }, (_, i) => ({ number: 200 + i, state: 'closed' })), { number: 99, state: 'open' }],
157
+ },
158
+ });
159
+ const r = run(t, args);
160
+ ok('unblock: a dependent on the second page is found', r.code === 0 && r.json.stillBlocked.length === 1 && r.json.stillBlocked[0].issue === 8);
161
+ ok('unblock: an open blocker only on the second page keeps the label', JSON.stringify(r.json.stillBlocked[0].openBlockers) === '[99]' && t.edits.length === 0);
162
+ }
163
+
164
+ // --- usage errors -------------------------------------------------------------
165
+ {
166
+ const t = makeTracker();
167
+ for (const [name, argv] of [
168
+ ['no arguments', []],
169
+ ['missing --repo', ['--issue', '5']],
170
+ ['missing --issue', ['--repo', 'o/r']],
171
+ ['bad --issue', ['--issue', 'x', '--repo', 'o/r']],
172
+ ['bad --repo', ['--issue', '5', '--repo', 'nope']],
173
+ ['unknown flag', ['--issue', '5', '--repo', 'o/r', '--force']],
174
+ ['flag without value', ['--issue']],
175
+ ]) {
176
+ const r = run(t, argv);
177
+ ok(`usage: ${name} exits 2 with stderr`, r.code === 2 && r.errOut.startsWith('usage:') && r.out === '');
178
+ }
179
+ ok('usage: no gh call on a usage error', t.calls.length === 0);
180
+ }
181
+
182
+ // --- end-to-end: the script with a fake gh on PATH -----------------------------
183
+ {
184
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'unblock-e2e-')));
185
+ const bin = path.join(dir, 'gh');
186
+ fs.writeFileSync(bin, [
187
+ '#!/bin/sh',
188
+ 'case "$*" in',
189
+ ' *dependencies/blocking*) echo \'{"number":8,"state":"open","labels":["trailhead:blocked"]}\' ;;',
190
+ ' *dependencies/blocked_by*) echo \'{"number":5,"state":"closed"}\' ;;',
191
+ ' *"issue edit"*) echo "$*" >> "' + path.join(dir, 'edits.log') + '" ;;',
192
+ ' *) exit 1 ;;',
193
+ 'esac',
194
+ '',
195
+ ].join('\n'));
196
+ fs.chmodSync(bin, 0o755);
197
+ const r = spawnSync('node', [scriptPath, '--issue', '5', '--repo', 'o/r'], { encoding: 'utf8', env: { ...process.env, PATH: `${dir}:${process.env.PATH}` } });
198
+ let j = null;
199
+ try { j = JSON.parse(r.stdout.trim()); } catch { /* not json */ }
200
+ ok('e2e: exits 0 with one JSON line', r.status === 0 && j && j.status === 'ok' && JSON.stringify(j.unblocked) === '[8]');
201
+ ok('e2e: the label edit reached gh', fs.readFileSync(path.join(dir, 'edits.log'), 'utf8').includes('--remove-label trailhead:blocked'));
202
+ const bad = spawnSync('node', [scriptPath], { encoding: 'utf8' });
203
+ ok('e2e: a usage error exits 2', bad.status === 2);
204
+ fs.rmSync(dir, { recursive: true, force: true });
205
+ }
206
+
207
+ console.log(`unblock.test.js: ${passed} checks passed`);
@@ -213,6 +213,51 @@ for (const argv of [
213
213
  ok('clear absent: absent, exit 0', r2.code === 0 && r2.lines[0].status === 'absent');
214
214
  }
215
215
 
216
+ // --- clear also removes the session-ticket marker (#253) ---------------------
217
+ {
218
+ const ticketPath = (dir) => path.join(dir, '.trailhead', 'session-ticket');
219
+ const seed = (dir, { state, marker }) => {
220
+ fs.mkdirSync(path.join(dir, '.trailhead'), { recursive: true });
221
+ if (state) fs.writeFileSync(statePath(dir), '{}');
222
+ if (marker) fs.writeFileSync(ticketPath(dir), '253\n');
223
+ };
224
+ const combos = [
225
+ [true, true, 'cleared', 'removed'],
226
+ [true, false, 'cleared', 'absent'],
227
+ [false, true, 'absent', 'removed'],
228
+ [false, false, 'absent', 'absent'],
229
+ ];
230
+ for (const [state, marker, wantStatus, wantMarker] of combos) {
231
+ const dir = mktmp();
232
+ seed(dir, { state, marker });
233
+ const r = run(['clear', '--repo', dir], { cwd: '/' });
234
+ ok(`clear state=${state} marker=${marker}: exit 0, one line`, r.code === 0 && r.lines.length === 1);
235
+ ok(`clear state=${state} marker=${marker}: status ${wantStatus}`, r.lines[0].status === wantStatus);
236
+ ok(`clear state=${state} marker=${marker}: sessionTicket ${wantMarker}`, r.lines[0].sessionTicket === wantMarker);
237
+ ok(`clear state=${state} marker=${marker}: both files gone`, !fs.existsSync(statePath(dir)) && !fs.existsSync(ticketPath(dir)));
238
+ }
239
+ // partial failure: the first removal throws, the second still runs
240
+ {
241
+ const dir = mktmp();
242
+ seed(dir, { state: true, marker: true });
243
+ const broken = { ...fs, unlinkSync: (p) => { if (p === statePath(dir)) throw new Error('boom state'); return fs.unlinkSync(p); } };
244
+ const r = run(['clear', '--repo', dir], { cwd: '/', fsMod: broken });
245
+ ok('clear partial failure: exit 0', r.code === 0);
246
+ ok('clear partial failure: state skipped with message', r.lines[0].status === 'skipped' && /boom state/.test(r.lines[0].message));
247
+ ok('clear partial failure: marker still removed', r.lines[0].sessionTicket === 'removed' && !fs.existsSync(ticketPath(dir)));
248
+ ok('clear partial failure: state file kept', fs.existsSync(statePath(dir)));
249
+ }
250
+ // the marker failing never skips the work-state removal
251
+ {
252
+ const dir = mktmp();
253
+ seed(dir, { state: true, marker: true });
254
+ const broken = { ...fs, unlinkSync: (p) => { if (p === ticketPath(dir)) throw new Error('boom marker'); return fs.unlinkSync(p); } };
255
+ const r = run(['clear', '--repo', dir], { cwd: '/', fsMod: broken });
256
+ ok('clear marker failure: state cleared, marker skipped', r.code === 0 && r.lines[0].status === 'cleared' && r.lines[0].sessionTicket === 'skipped');
257
+ ok('clear marker failure: state file gone', !fs.existsSync(statePath(dir)));
258
+ }
259
+ }
260
+
216
261
  // --- pure functions ----------------------------------------------------------
217
262
  {
218
263
  ok('sanitizeState: non-object -> {}', JSON.stringify(sanitizeState(null)) === '{}' && JSON.stringify(sanitizeState('x')) === '{}');