@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.
- package/README.it.md +158 -34
- package/README.md +147 -33
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -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 +6 -6
- package/bin/lib/codex-projection.test.js +43 -2
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -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 +6 -6
- package/bin/trailhead.test.js +355 -16
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- 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/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -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 +472 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -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/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
- package/plugins/trailhead/hooks/mod/README.md +209 -36
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
- package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
- package/plugins/trailhead/hooks/mod/register.tsx +312 -40
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
- package/plugins/trailhead/skills/_shared/choices.md +11 -4
- package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
- package/plugins/trailhead/skills/_shared/load-first.md +3 -3
- package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
- package/plugins/trailhead/skills/_shared/substrate.md +9 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
- 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/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -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 +1 -1
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- 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 +9 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
- package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -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 +360 -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-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -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-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
- package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +62 -3
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for templates/trailhead-issue-body.js. Run: node issue-body.test.js
|
|
3
|
+
// No framework: plain asserts, same style as work-state.test.js.
|
|
4
|
+
//
|
|
5
|
+
// Layers covered:
|
|
6
|
+
// 1. Pure applyOp() on fixtures (sections, preamble, placeholder, remove).
|
|
7
|
+
// 2. guardedWrite() / main() against a fake tracker behind an injected `gh`
|
|
8
|
+
// runner (the only system boundary mocked): fail-safe reads, optimistic
|
|
9
|
+
// concurrency, the post-write conflict check, the secret scan.
|
|
10
|
+
// 3. End-to-end: the script itself with a fake `gh` first on PATH.
|
|
11
|
+
const assert = require('assert');
|
|
12
|
+
const { spawnSync } = require('child_process');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
|
|
17
|
+
const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-issue-body.js');
|
|
18
|
+
const mod = require(scriptPath);
|
|
19
|
+
const { applyOp, guardedWrite, main, loadGuard } = mod;
|
|
20
|
+
|
|
21
|
+
let passed = 0;
|
|
22
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
23
|
+
|
|
24
|
+
// --- fixtures ----------------------------------------------------------------
|
|
25
|
+
const MAP = [
|
|
26
|
+
'Intro line for the map, long enough to look real.',
|
|
27
|
+
'',
|
|
28
|
+
'## Destination',
|
|
29
|
+
'Somewhere good.',
|
|
30
|
+
'',
|
|
31
|
+
'## ✅ Decisions so far',
|
|
32
|
+
'- [A thing #1](https://x/1): did it.',
|
|
33
|
+
'- [Other #2](https://x/2): did that.',
|
|
34
|
+
' continued detail for two',
|
|
35
|
+
'',
|
|
36
|
+
'## Not yet specified',
|
|
37
|
+
'- fog item',
|
|
38
|
+
'',
|
|
39
|
+
].join('\n');
|
|
40
|
+
|
|
41
|
+
const EMPTY_DECISIONS = ['Intro line for the map, long enough.', '', '## Decisions so far', '_none yet_', '', '## Fog', '- x', ''].join('\n');
|
|
42
|
+
|
|
43
|
+
// --- applyOp: append ---------------------------------------------------------
|
|
44
|
+
{
|
|
45
|
+
const r = applyOp(MAP, { kind: 'append', section: 'Decisions so far', line: '- [New #3](https://x/3): added.' });
|
|
46
|
+
ok('append: ok, changed', !r.error && r.changed === true);
|
|
47
|
+
const lines = r.body.split('\n');
|
|
48
|
+
const i = lines.indexOf(' continued detail for two');
|
|
49
|
+
ok('append: line lands after the last content line', lines[i + 1] === '- [New #3](https://x/3): added.');
|
|
50
|
+
ok('append: blank separator before next heading kept', lines[i + 2] === '' && lines[i + 3] === '## Not yet specified');
|
|
51
|
+
ok('append: emoji heading matched by plain name', /## ✅ Decisions so far/.test(r.body));
|
|
52
|
+
ok('append: rest of the body untouched', r.body.replace('- [New #3](https://x/3): added.\n', '') === MAP);
|
|
53
|
+
}
|
|
54
|
+
{
|
|
55
|
+
const r = applyOp(EMPTY_DECISIONS, { kind: 'append', section: 'Decisions so far', line: '- first' });
|
|
56
|
+
ok('append: sole placeholder is dropped', !r.error && !/none yet/.test(r.body) && /## Decisions so far\n- first\n\n## Fog/.test(r.body));
|
|
57
|
+
}
|
|
58
|
+
{
|
|
59
|
+
const body = ['Intro line for the map, long enough.', '', '## Decisions so far', '- a', '_none yet_', '', '## Fog', ''].join('\n');
|
|
60
|
+
const r = applyOp(body, { kind: 'append', section: 'Decisions so far', line: '- b' });
|
|
61
|
+
ok('append: placeholder kept when not the sole content', /_none yet_\n- b/.test(r.body));
|
|
62
|
+
}
|
|
63
|
+
{
|
|
64
|
+
const body = ['Intro line for the map, long enough.', '', '## Decisions so far', '', '## Fog', ''].join('\n');
|
|
65
|
+
const r = applyOp(body, { kind: 'append', section: 'Decisions so far', line: '- first' });
|
|
66
|
+
ok('append: empty section gets a blank line then the entry', /## Decisions so far\n\n- first\n\n## Fog/.test(r.body));
|
|
67
|
+
}
|
|
68
|
+
{
|
|
69
|
+
const body = 'Intro line long enough here.\n\n## Decisions so far\n- a';
|
|
70
|
+
const r = applyOp(body, { kind: 'append', section: 'Decisions so far', line: '- b' });
|
|
71
|
+
ok('append: last section without trailing newline', r.body === 'Intro line long enough here.\n\n## Decisions so far\n- a\n- b');
|
|
72
|
+
}
|
|
73
|
+
{
|
|
74
|
+
const crlf = MAP.replace(/\n/g, '\r\n');
|
|
75
|
+
const r = applyOp(crlf, { kind: 'append', section: 'Decisions so far', line: '- c' });
|
|
76
|
+
ok('append: CRLF body keeps CRLF', !r.error && !/[^\r]\n/.test(r.body) && r.body.includes('- c\r\n'));
|
|
77
|
+
}
|
|
78
|
+
{
|
|
79
|
+
const fenced = ['Intro line for the map, long enough.', '', '```', '## Decisions so far', '```', '', '## Decisions so far', '- a', ''].join('\n');
|
|
80
|
+
const r = applyOp(fenced, { kind: 'append', section: 'Decisions so far', line: '- b' });
|
|
81
|
+
ok('append: a heading inside a code fence is not a section', /- a\n- b\n/.test(r.body) && r.body.startsWith(fenced.slice(0, fenced.indexOf('- a'))));
|
|
82
|
+
}
|
|
83
|
+
{
|
|
84
|
+
const r = applyOp(MAP, { kind: 'append', section: 'Nope', line: '- x' });
|
|
85
|
+
ok('append: missing section is an error', typeof r.error === 'string' && /Nope/.test(r.error));
|
|
86
|
+
const h3 = applyOp('Intro line long enough.\n\n### Decisions so far\n- a\n', { kind: 'append', section: 'Decisions so far', line: '- x' });
|
|
87
|
+
ok('append: only ## headings count', typeof h3.error === 'string');
|
|
88
|
+
const multi = applyOp(MAP, { kind: 'append', section: 'Decisions so far', line: 'a\nb' });
|
|
89
|
+
ok('append: a multi-line --line is rejected', typeof multi.error === 'string');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- applyOp: preamble -------------------------------------------------------
|
|
93
|
+
{
|
|
94
|
+
const body = ['> conventions header', '> second line', '', '## Facet', '- x', ''].join('\n');
|
|
95
|
+
const r = applyOp(body, { kind: 'replace', section: ':preamble', text: '> new header\n> two\n' });
|
|
96
|
+
ok('preamble replace: swaps the text before the first heading', r.body === ['> new header', '> two', '', '## Facet', '- x', ''].join('\n'));
|
|
97
|
+
const a = applyOp(body, { kind: 'append', section: ':preamble', line: '> third' });
|
|
98
|
+
ok('preamble append: after the last preamble line', a.body.startsWith('> conventions header\n> second line\n> third\n\n## Facet'));
|
|
99
|
+
const none = applyOp('just text without headings', { kind: 'append', section: ':preamble', line: 'more' });
|
|
100
|
+
ok('preamble: whole body when there is no heading', none.body === 'just text without headings\nmore');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- applyOp: replace --------------------------------------------------------
|
|
104
|
+
{
|
|
105
|
+
const r = applyOp(MAP, { kind: 'replace', section: 'Not yet specified', text: '- new fog\n- more fog\n' });
|
|
106
|
+
ok('replace: section content swapped, heading kept', /## Not yet specified\n\n- new fog\n- more fog\n$/.test(r.body) || /## Not yet specified\n- new fog\n- more fog\n/.test(r.body));
|
|
107
|
+
ok('replace: other sections intact', r.body.includes('- [A thing #1](https://x/1): did it.') && !r.body.includes('- fog item'));
|
|
108
|
+
const mid = applyOp(MAP, { kind: 'replace', section: 'Destination', text: 'Elsewhere.' });
|
|
109
|
+
ok('replace: blank line before the next heading kept', /Elsewhere\.\n\n## ✅ Decisions so far/.test(mid.body));
|
|
110
|
+
const empty = applyOp(MAP, { kind: 'replace', section: 'Destination', text: '' });
|
|
111
|
+
ok('replace: empty text empties the section but keeps the heading', !empty.error && /## Destination\n\n## ✅ Decisions so far/.test(empty.body));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- applyOp: remove ---------------------------------------------------------
|
|
115
|
+
{
|
|
116
|
+
const r = applyOp(MAP, { kind: 'remove', section: 'Decisions so far', match: 'Other #2' });
|
|
117
|
+
ok('remove: drops the entry and its indented continuation', r.changed && !r.body.includes('Other #2') && !r.body.includes('continued detail'));
|
|
118
|
+
ok('remove: neighbouring entry and spacing kept', r.body.includes('- [A thing #1](https://x/1): did it.\n\n## Not yet specified'));
|
|
119
|
+
const none = applyOp(MAP, { kind: 'remove', section: 'Decisions so far', match: 'does not exist' });
|
|
120
|
+
ok('remove: zero matches is a reported no-op', !none.error && none.changed === false && none.body === MAP && /no entry/.test(none.summary));
|
|
121
|
+
const amb = applyOp(MAP, { kind: 'remove', section: 'Decisions so far', match: '#' });
|
|
122
|
+
ok('remove: more than one match is ambiguous', typeof amb.error === 'string' && /ambiguous/.test(amb.error));
|
|
123
|
+
const viaCont = applyOp(MAP, { kind: 'remove', section: 'Decisions so far', match: 'continued detail' });
|
|
124
|
+
ok('remove: a match inside the continuation removes the whole entry', viaCont.changed && !viaCont.body.includes('Other #2'));
|
|
125
|
+
const miss = applyOp(MAP, { kind: 'remove', section: 'Nope', match: 'x' });
|
|
126
|
+
ok('remove: missing section is an error', typeof miss.error === 'string');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- fake tracker ------------------------------------------------------------
|
|
130
|
+
function makeTracker(init = {}) {
|
|
131
|
+
const t = {
|
|
132
|
+
body: init.body === undefined ? MAP : init.body,
|
|
133
|
+
updatedAt: init.updatedAt || 'T1',
|
|
134
|
+
edits: init.edits === undefined ? 3 : init.edits,
|
|
135
|
+
writeDelta: init.writeDelta === undefined ? 1 : init.writeDelta,
|
|
136
|
+
calls: [],
|
|
137
|
+
writes: [],
|
|
138
|
+
tmpSeen: [],
|
|
139
|
+
viewFail: init.viewFail || 0, // number of leading `issue view` calls that fail
|
|
140
|
+
views: 0,
|
|
141
|
+
onView: init.onView || null, // (t, n) called before answering the n-th view
|
|
142
|
+
beforeEdit: init.beforeEdit || null,
|
|
143
|
+
onCount: init.onCount || null, // (t, n) called before answering the n-th edit-count read
|
|
144
|
+
counts: 0,
|
|
145
|
+
editFails: init.editFails || false,
|
|
146
|
+
garbleReadback: init.garbleReadback || false,
|
|
147
|
+
countFails: init.countFails || false,
|
|
148
|
+
};
|
|
149
|
+
t.externalEdit = (body) => { t.body = body; t.edits += 1; t.updatedAt = 'T' + (Number(t.updatedAt.slice(1)) + 1); };
|
|
150
|
+
t.gh = (args) => {
|
|
151
|
+
t.calls.push(args.join(' '));
|
|
152
|
+
const sub = args.slice(0, 2).join(' ');
|
|
153
|
+
if (sub === 'issue view') {
|
|
154
|
+
t.views += 1;
|
|
155
|
+
if (t.onView) t.onView(t, t.views);
|
|
156
|
+
if (t.viewFail > 0) { t.viewFail -= 1; return { status: 1, stdout: '', stderr: 'HTTP 502' }; }
|
|
157
|
+
if (t.viewRaw !== undefined) return { status: 0, stdout: t.viewRaw, stderr: '' };
|
|
158
|
+
return { status: 0, stdout: JSON.stringify({ body: t.body, updatedAt: t.updatedAt }), stderr: '' };
|
|
159
|
+
}
|
|
160
|
+
if (sub === 'issue edit') {
|
|
161
|
+
const file = args[args.indexOf('--body-file') + 1];
|
|
162
|
+
t.tmpSeen.push(file);
|
|
163
|
+
if (t.beforeEdit) t.beforeEdit(t);
|
|
164
|
+
if (t.editFails) return { status: 1, stdout: '', stderr: 'boom' };
|
|
165
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
166
|
+
t.writes.push(text);
|
|
167
|
+
t.body = t.garbleReadback ? text + '\nextra' : text;
|
|
168
|
+
t.edits += t.writeDelta;
|
|
169
|
+
t.updatedAt = 'T' + (Number(t.updatedAt.slice(1)) + 1);
|
|
170
|
+
return { status: 0, stdout: '', stderr: '' };
|
|
171
|
+
}
|
|
172
|
+
if (sub === 'api graphql') {
|
|
173
|
+
t.counts += 1;
|
|
174
|
+
if (t.onCount) t.onCount(t, t.counts);
|
|
175
|
+
if (t.countFails) return { status: 1, stdout: '', stderr: 'graphql down' };
|
|
176
|
+
return { status: 0, stdout: JSON.stringify({ data: { repository: { issue: { userContentEdits: { totalCount: t.edits } } } } }), stderr: '' };
|
|
177
|
+
}
|
|
178
|
+
if (sub === 'repo view') return { status: 0, stdout: 'o/r\n', stderr: '' };
|
|
179
|
+
return { status: 1, stdout: '', stderr: 'unexpected: ' + args.join(' ') };
|
|
180
|
+
};
|
|
181
|
+
return t;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const fakeGuard = { scan: (text) => (/ghp_[A-Za-z0-9]{20,}/.test(text) ? 'github token' : null) };
|
|
185
|
+
|
|
186
|
+
function runMain(argv, t, extra = {}) {
|
|
187
|
+
const chunks = [];
|
|
188
|
+
const errChunks = [];
|
|
189
|
+
const realErr = process.stderr.write.bind(process.stderr);
|
|
190
|
+
process.stderr.write = (s) => { errChunks.push(String(s)); return true; };
|
|
191
|
+
let code;
|
|
192
|
+
try {
|
|
193
|
+
code = main(argv, { gh: t.gh, stdout: { write: (s) => chunks.push(s) }, guard: fakeGuard, ...extra });
|
|
194
|
+
} finally {
|
|
195
|
+
process.stderr.write = realErr;
|
|
196
|
+
}
|
|
197
|
+
return { code, out: chunks.join(''), err: errChunks.join('') };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const APPEND = ['append', '--issue', '7', '--repo', 'o/r', '--section', 'Decisions so far', '--line', '- [New #3](https://x/3): added.'];
|
|
201
|
+
|
|
202
|
+
// --- happy path --------------------------------------------------------------
|
|
203
|
+
{
|
|
204
|
+
const t = makeTracker();
|
|
205
|
+
const r = runMain(APPEND, t);
|
|
206
|
+
ok('append: exit 0', r.code === 0);
|
|
207
|
+
ok('append: one line, never the body', r.out.split('\n').filter(Boolean).length === 1 && !r.out.includes('Intro line'));
|
|
208
|
+
ok('append: the summary line', r.out.trim() === 'issue #7: appended 1 line to "Decisions so far"');
|
|
209
|
+
ok('append: exactly one write, spliced', t.writes.length === 1 && t.writes[0].includes('- [New #3](https://x/3): added.'));
|
|
210
|
+
ok('append: edit passes the issue and repo', t.calls.some((c) => c.startsWith('issue edit 7 ') && c.includes('-R o/r')));
|
|
211
|
+
ok('append: temp body file removed', t.tmpSeen.length === 1 && !fs.existsSync(t.tmpSeen[0]));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// --- read retry and fail-safe reads ------------------------------------------
|
|
215
|
+
{
|
|
216
|
+
const t = makeTracker({ viewFail: 1 });
|
|
217
|
+
const r = runMain(APPEND, t);
|
|
218
|
+
ok('read: one failed read is retried', r.code === 0 && t.writes.length === 1);
|
|
219
|
+
}
|
|
220
|
+
for (const [name, init] of [
|
|
221
|
+
['non-zero exit twice', { viewFail: 5 }],
|
|
222
|
+
['empty body', { body: '' }],
|
|
223
|
+
['whitespace body', { body: ' \n\n ' }],
|
|
224
|
+
['degenerate body', { body: 'x\n## Decisions so far' }],
|
|
225
|
+
]) {
|
|
226
|
+
const t = makeTracker(init);
|
|
227
|
+
const r = runMain(APPEND, t);
|
|
228
|
+
ok(`abort: ${name}: exit 1`, r.code === 1);
|
|
229
|
+
ok(`abort: ${name}: aborted line`, /^aborted: /.test(r.out.trim()) && r.out.trim().split('\n').length === 1);
|
|
230
|
+
ok(`abort: ${name}: nothing written`, t.writes.length === 0 && !t.calls.some((c) => c.startsWith('issue edit')));
|
|
231
|
+
}
|
|
232
|
+
{
|
|
233
|
+
const t = makeTracker();
|
|
234
|
+
t.viewRaw = 'not json';
|
|
235
|
+
const r = runMain(APPEND, t);
|
|
236
|
+
ok('abort: unparseable JSON, nothing written', r.code === 1 && t.writes.length === 0 && /^aborted: /.test(r.out));
|
|
237
|
+
const t2 = makeTracker();
|
|
238
|
+
t2.viewRaw = JSON.stringify({ updatedAt: 'T1' });
|
|
239
|
+
const r2 = runMain(APPEND, t2);
|
|
240
|
+
ok('abort: JSON without a body, nothing written', r2.code === 1 && t2.writes.length === 0);
|
|
241
|
+
}
|
|
242
|
+
{
|
|
243
|
+
const t = makeTracker();
|
|
244
|
+
const r = runMain(['append', '--issue', '7', '--section', 'Missing', '--line', '- x'], t);
|
|
245
|
+
ok('abort: missing section, nothing written', r.code === 1 && /Missing/.test(r.out) && t.writes.length === 0);
|
|
246
|
+
}
|
|
247
|
+
{
|
|
248
|
+
const t = makeTracker();
|
|
249
|
+
const r = runMain(['replace', '--issue', '7', '--section', 'Destination', '--file', writeTmp('')], t);
|
|
250
|
+
ok('replace with empty text keeps a non-empty body and writes', r.code === 0 && t.writes.length === 1);
|
|
251
|
+
const t2 = makeTracker({ body: 'Intro line long enough for the check.' });
|
|
252
|
+
const r2 = runMain(['replace', '--issue', '7', '--section', ':preamble', '--file', writeTmp(' \n')], t2);
|
|
253
|
+
ok('abort: a result that would be an empty body is refused', r2.code === 1 && t2.writes.length === 0 && /empty/.test(r2.out));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function writeTmp(text) {
|
|
257
|
+
const p = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'issue-body-test-')), 'in.md');
|
|
258
|
+
fs.writeFileSync(p, text);
|
|
259
|
+
return p;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// --- optimistic concurrency --------------------------------------------------
|
|
263
|
+
{
|
|
264
|
+
// The first re-read before the write shows a moved updatedAt (n=2 is the
|
|
265
|
+
// pre-write check): the op is re-applied on the fresh body, then written.
|
|
266
|
+
const t = makeTracker({
|
|
267
|
+
onView: (tr, n) => { if (n === 2) tr.externalEdit(MAP + '\n## Extra\n- concurrent addition\n'); },
|
|
268
|
+
});
|
|
269
|
+
const r = runMain(APPEND, t);
|
|
270
|
+
ok('moved once: re-applied and written', r.code === 0 && t.writes.length === 1);
|
|
271
|
+
ok('moved once: the concurrent addition survives', t.writes[0].includes('concurrent addition') && t.writes[0].includes('- [New #3]'));
|
|
272
|
+
}
|
|
273
|
+
{
|
|
274
|
+
const t = makeTracker({
|
|
275
|
+
onView: (tr, n) => { if (n === 2 || n === 3) tr.externalEdit(tr.body + '\nmore ' + n); },
|
|
276
|
+
});
|
|
277
|
+
const r = runMain(APPEND, t);
|
|
278
|
+
ok('moved twice: abort, nothing written', r.code === 1 && t.writes.length === 0 && /changed/.test(r.out));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --- post-write conflict check -----------------------------------------------
|
|
282
|
+
{
|
|
283
|
+
// An external edit lands between the last check and our write: the final body
|
|
284
|
+
// is ours (we overwrote it), the edit count moved by one more than our write.
|
|
285
|
+
const t = makeTracker({ beforeEdit: (tr) => tr.externalEdit('someone else wrote this body, long enough') });
|
|
286
|
+
const r = runMain(APPEND, t);
|
|
287
|
+
ok('conflict: exit 1', r.code === 1);
|
|
288
|
+
ok('conflict: message names the recovery', /^aborted: conflict: a concurrent edit landed during the write, the overwritten text is in the issue's edit history/.test(r.out.trim()) && /edited/.test(r.out));
|
|
289
|
+
ok('conflict: never silent (our body was written)', t.writes.length === 1);
|
|
290
|
+
}
|
|
291
|
+
{
|
|
292
|
+
const t = makeTracker({ garbleReadback: true });
|
|
293
|
+
const r = runMain(APPEND, t);
|
|
294
|
+
ok('conflict: a read-back mismatch is a conflict', r.code === 1 && /conflict/.test(r.out));
|
|
295
|
+
}
|
|
296
|
+
{
|
|
297
|
+
// GitHub stops recording body edits once an issue holds 100: the count stays put.
|
|
298
|
+
const t = makeTracker({ edits: 100, writeDelta: 0 });
|
|
299
|
+
const r = runMain(APPEND, t);
|
|
300
|
+
ok('full edit history: a clean write is not a conflict', r.code === 0 && !/conflict/.test(r.out));
|
|
301
|
+
ok('full edit history: the output says the check was read-back only', /edit history full/.test(r.out));
|
|
302
|
+
const t2 = makeTracker({ edits: 100, writeDelta: 0, garbleReadback: true });
|
|
303
|
+
const r2 = runMain(APPEND, t2);
|
|
304
|
+
ok('full edit history: a read-back mismatch is still a conflict', r2.code === 1 && /conflict/.test(r2.out));
|
|
305
|
+
ok('full edit history: the conflict does not point at a history GitHub did not keep', !/re-apply the lost lines/.test(r2.out) || /not recorded/.test(r2.out));
|
|
306
|
+
}
|
|
307
|
+
{
|
|
308
|
+
const t = makeTracker({ writeDelta: 2, edits: 0 });
|
|
309
|
+
const r = runMain(APPEND, t);
|
|
310
|
+
ok('first edit, delta 2: accepted by default (confirmed live)', r.code === 0 && !/conflict/.test(r.out));
|
|
311
|
+
const t2 = makeTracker({ writeDelta: 2, edits: 0 });
|
|
312
|
+
const g = guardedWrite(7, (b) => b + '\n- z', { gh: t2.gh, guard: fakeGuard, repo: 'o/r', firstEditDelta2: true });
|
|
313
|
+
ok('first edit, delta 2: accepted with the explicit opt', g.ok === true);
|
|
314
|
+
const t3 = makeTracker({ writeDelta: 2, edits: 5 });
|
|
315
|
+
const g3 = guardedWrite(7, (b) => b + '\n- z', { gh: t3.gh, guard: fakeGuard, repo: 'o/r', firstEditDelta2: true });
|
|
316
|
+
ok('delta 2 on an already-edited issue stays a conflict', g3.ok === false && g3.conflict === true);
|
|
317
|
+
}
|
|
318
|
+
{
|
|
319
|
+
const t = makeTracker({ countFails: true });
|
|
320
|
+
const r = runMain(APPEND, t);
|
|
321
|
+
ok('abort: the baseline cannot be read, nothing written', r.code === 1 && t.writes.length === 0);
|
|
322
|
+
}
|
|
323
|
+
{
|
|
324
|
+
const t = makeTracker({ editFails: true });
|
|
325
|
+
const r = runMain(APPEND, t);
|
|
326
|
+
ok('abort: gh issue edit failing is reported, temp removed', r.code === 1 && /aborted: /.test(r.out) && !fs.existsSync(t.tmpSeen[0]));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// --- W1: the baseline is read BEFORE the last updatedAt check ----------------
|
|
330
|
+
{
|
|
331
|
+
const t = makeTracker();
|
|
332
|
+
runMain(APPEND, t);
|
|
333
|
+
const countIdx = t.calls.findIndex((c) => c.startsWith('api graphql'));
|
|
334
|
+
const editIdx = t.calls.findIndex((c) => c.startsWith('issue edit'));
|
|
335
|
+
const viewsBeforeEdit = t.calls.map((c, i) => (c.startsWith('issue view') && i < editIdx ? i : -1)).filter((i) => i >= 0);
|
|
336
|
+
ok('W1: the edit-count baseline precedes the final updatedAt re-read', countIdx >= 0 && countIdx < viewsBeforeEdit[viewsBeforeEdit.length - 1]);
|
|
337
|
+
}
|
|
338
|
+
{
|
|
339
|
+
// An edit landing at the baseline read must not be absorbed into the baseline.
|
|
340
|
+
const hit = (tr, n) => { if (n === 1) tr.externalEdit(MAP + '\n## Extra\n- landed at the baseline\n'); };
|
|
341
|
+
const t = makeTracker({ onCount: hit });
|
|
342
|
+
const g = guardedWrite(7, 'A whole new generated body, long enough.\n', { gh: t.gh, guard: fakeGuard, repo: 'o/r' });
|
|
343
|
+
ok('W1: an edit at the baseline read aborts a whole-body write, never clean', g.ok === false && t.writes.length === 0);
|
|
344
|
+
const t2 = makeTracker({ onCount: hit });
|
|
345
|
+
const r2 = runMain(APPEND, t2);
|
|
346
|
+
ok('W1: a splice re-applies on the fresh body, keeping the concurrent edit', r2.code === 0 && t2.writes.length === 1 && t2.writes[0].includes('landed at the baseline'));
|
|
347
|
+
ok('W1: the repo slug is resolved once per write', (() => {
|
|
348
|
+
const t3 = makeTracker();
|
|
349
|
+
guardedWrite(7, (b) => b + '\n- z', { gh: t3.gh, guard: fakeGuard });
|
|
350
|
+
return t3.calls.filter((c) => c.startsWith('repo view')).length === 1;
|
|
351
|
+
})());
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// --- I3: a temp-file fs failure keeps the one-line contract -------------------
|
|
355
|
+
{
|
|
356
|
+
const aFile = writeTmp('not a directory');
|
|
357
|
+
for (const [name, tmpdir] of [['missing tmpdir', '/nonexistent-trailhead-dir/sub'], ['tmpdir that is a file', aFile]]) {
|
|
358
|
+
const t = makeTracker();
|
|
359
|
+
const r = runMain(APPEND, t, { tmpdir });
|
|
360
|
+
ok(`I3: ${name}: exit 1, one aborted line`, r.code === 1 && /^aborted: /.test(r.out) && r.out.trim().split('\n').length === 1);
|
|
361
|
+
ok(`I3: ${name}: gh issue edit never ran, no stack on stderr`, !t.calls.some((c) => c.startsWith('issue edit')) && r.err === '');
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// --- secret scan -------------------------------------------------------------
|
|
366
|
+
{
|
|
367
|
+
const t = makeTracker();
|
|
368
|
+
const r = runMain(['append', '--issue', '7', '--section', 'Decisions so far', '--line', '- token ghp_' + 'a'.repeat(36)], t);
|
|
369
|
+
ok('secret hit: abort, nothing written', r.code === 1 && /secret/.test(r.out) && t.writes.length === 0);
|
|
370
|
+
ok('secret hit: the secret is not echoed', !r.out.includes('ghp_'));
|
|
371
|
+
}
|
|
372
|
+
{
|
|
373
|
+
const t = makeTracker();
|
|
374
|
+
const r = runMain(APPEND, t, { guard: null });
|
|
375
|
+
ok('fail closed: no guard module, abort with the fallback hint', r.code === 1 && r.out.trim() === 'aborted: secret-scan unavailable, use the prose fallback' && t.writes.length === 0);
|
|
376
|
+
const boom = runMain(APPEND, makeTracker(), { guard: { scan: () => { throw new Error('fault'); } } });
|
|
377
|
+
ok('fail closed: a scan fault aborts', boom.code === 1 && /secret-scan/.test(boom.out));
|
|
378
|
+
}
|
|
379
|
+
{
|
|
380
|
+
const g = loadGuard();
|
|
381
|
+
ok('loadGuard: finds the shipped guard from the source layout', g && typeof g.scan === 'function');
|
|
382
|
+
ok('loadGuard: the real guard flags a token', typeof g.scan('see ghp_' + 'b'.repeat(36), {}) === 'string');
|
|
383
|
+
ok('loadGuard: the real guard passes plain prose', g.scan('- [A thing #1](https://x/1): did it.', {}) === null);
|
|
384
|
+
const stray = fs.mkdtempSync(path.join(os.tmpdir(), 'issue-body-noguard-'));
|
|
385
|
+
ok('loadGuard: null when no module is found', loadGuard([path.join(stray, 'a.js'), path.join(stray, 'b.js')]) === null);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// --- remove / replace via main -----------------------------------------------
|
|
389
|
+
{
|
|
390
|
+
const t = makeTracker();
|
|
391
|
+
const r = runMain(['remove', '--issue', '7', '--section', 'Decisions so far', '--match', 'Other #2'], t);
|
|
392
|
+
ok('remove via main: summary and write', r.code === 0 && r.out.trim() === 'issue #7: removed 1 entry from "Decisions so far"' && t.writes.length === 1);
|
|
393
|
+
const t2 = makeTracker();
|
|
394
|
+
const r2 = runMain(['remove', '--issue', '7', '--section', 'Decisions so far', '--match', 'zzz'], t2);
|
|
395
|
+
ok('remove no-op: exit 0, reported, nothing written', r2.code === 0 && /no entry/.test(r2.out) && t2.writes.length === 0);
|
|
396
|
+
const t3 = makeTracker();
|
|
397
|
+
const r3 = runMain(['remove', '--issue', '7', '--section', 'Decisions so far', '--match', '#'], t3);
|
|
398
|
+
ok('remove ambiguous: exit 1, nothing written', r3.code === 1 && /ambiguous/.test(r3.out) && t3.writes.length === 0);
|
|
399
|
+
const t4 = makeTracker();
|
|
400
|
+
const r4 = runMain(['replace', '--issue', '7', '--section', 'Not yet specified', '--file', writeTmp('- only this\n')], t4);
|
|
401
|
+
ok('replace via main: summary and write', r4.code === 0 && /replaced "Not yet specified"/.test(r4.out) && t4.writes[0].includes('- only this'));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// --- dry run -----------------------------------------------------------------
|
|
405
|
+
{
|
|
406
|
+
const t = makeTracker();
|
|
407
|
+
const r = runMain([...APPEND, '--dry-run'], t);
|
|
408
|
+
ok('dry-run: exit 0, nothing written, no edit call', r.code === 0 && t.writes.length === 0 && !t.calls.some((c) => c.startsWith('issue edit')));
|
|
409
|
+
ok('dry-run: one line with old and new byte sizes', r.out.trim().split('\n').length === 1 && /dry-run/.test(r.out) && new RegExp(`${Buffer.byteLength(MAP)} -> ${Buffer.byteLength(MAP) + Buffer.byteLength('- [New #3](https://x/3): added.\n')}`).test(r.out));
|
|
410
|
+
const s = runMain([...APPEND.slice(0, 7), '--line', '- ghp_' + 'c'.repeat(36), '--dry-run'], makeTracker());
|
|
411
|
+
ok('dry-run: still runs the secret scan', s.code === 1);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// --- usage errors ------------------------------------------------------------
|
|
415
|
+
for (const argv of [
|
|
416
|
+
[],
|
|
417
|
+
['frobnicate', '--issue', '1'],
|
|
418
|
+
['append', '--section', 'x', '--line', 'y'],
|
|
419
|
+
['append', '--issue', 'abc', '--section', 'x', '--line', 'y'],
|
|
420
|
+
['append', '--issue', '1', '--line', 'y'],
|
|
421
|
+
['append', '--issue', '1', '--section', 'x'],
|
|
422
|
+
['append', '--issue', '1', '--section', 'x', '--line', 'y', '--file', 'f'],
|
|
423
|
+
['replace', '--issue', '1', '--section', 'x'],
|
|
424
|
+
['remove', '--issue', '1', '--section', 'x'],
|
|
425
|
+
['remove', '--issue', '1', '--section', 'x', '--match', ''],
|
|
426
|
+
['append', '--issue', '1', '--section', 'x', '--line', 'y', '--bogus', 'z'],
|
|
427
|
+
['append', '--issue', '1', '--section', 'x', '--line'],
|
|
428
|
+
['replace', '--issue', '1', '--section', 'x', '--file', '/nonexistent/file.md'],
|
|
429
|
+
]) {
|
|
430
|
+
const t = makeTracker();
|
|
431
|
+
const r = runMain(argv, t);
|
|
432
|
+
ok(`usage error exit 2: ${argv.join(' ')}`, r.code === 2 && r.err.length > 0 && r.out === '' && t.calls.length === 0);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// --- guardedWrite: whole-body ------------------------------------------------
|
|
436
|
+
{
|
|
437
|
+
const t = makeTracker();
|
|
438
|
+
const body = 'A whole new generated body, long enough.\n';
|
|
439
|
+
const g = guardedWrite(7, body, { gh: t.gh, guard: fakeGuard, repo: 'o/r' });
|
|
440
|
+
ok('guardedWrite: whole-body string writes', g.ok === true && t.writes[0] === body && g.oldSize === Buffer.byteLength(MAP) && g.newSize === Buffer.byteLength(body));
|
|
441
|
+
const t2 = makeTracker({ onView: (tr, n) => { if (n === 2) tr.externalEdit(tr.body + '\nx'); } });
|
|
442
|
+
const g2 = guardedWrite(7, body, { gh: t2.gh, guard: fakeGuard, repo: 'o/r' });
|
|
443
|
+
ok('guardedWrite: a string cannot be re-applied, a moved updatedAt aborts', g2.ok === false && t2.writes.length === 0);
|
|
444
|
+
const t3 = makeTracker();
|
|
445
|
+
const g3 = guardedWrite(7, ' \n', { gh: t3.gh, guard: fakeGuard, repo: 'o/r' });
|
|
446
|
+
ok('guardedWrite: empty body refused', g3.ok === false && t3.writes.length === 0);
|
|
447
|
+
const t4 = makeTracker();
|
|
448
|
+
const g4 = guardedWrite(7, 'harmless body that is long enough', { gh: t4.gh, guard: fakeGuard, repo: 'o/r', scanText: 'ghp_' + 'd'.repeat(36) });
|
|
449
|
+
ok('guardedWrite: scanText overrides what is scanned', g4.ok === false && /secret/.test(g4.reason) && t4.writes.length === 0);
|
|
450
|
+
}
|
|
451
|
+
{
|
|
452
|
+
const t = makeTracker();
|
|
453
|
+
// repo resolved through `gh repo view` when not passed
|
|
454
|
+
guardedWrite(7, 'A whole new generated body, long enough.\n', { gh: t.gh, guard: fakeGuard });
|
|
455
|
+
ok('guardedWrite: repo resolved from gh when absent', t.calls.some((c) => c.startsWith('repo view')));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// --- end-to-end with a fake gh on PATH ---------------------------------------
|
|
459
|
+
{
|
|
460
|
+
const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'issue-body-bin-'));
|
|
461
|
+
const log = path.join(binDir, 'calls.log');
|
|
462
|
+
fs.writeFileSync(path.join(binDir, 'gh'), `#!/bin/sh\necho "$@" >> "${log}"\nexit 1\n`, { mode: 0o755 });
|
|
463
|
+
const res = spawnSync('node', [scriptPath, ...APPEND], { encoding: 'utf8', env: { ...process.env, PATH: binDir + path.delimiter + process.env.PATH } });
|
|
464
|
+
ok('e2e: gh failing aborts with exit 1 and one line', res.status === 1 && /^aborted: /.test(res.stdout) && res.stdout.trim().split('\n').length === 1);
|
|
465
|
+
ok('e2e: gh was called with the view', /issue view 7/.test(fs.readFileSync(log, 'utf8')));
|
|
466
|
+
const bad = spawnSync('node', [scriptPath, 'append'], { encoding: 'utf8' });
|
|
467
|
+
ok('e2e: usage error exits 2', bad.status === 2);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
console.log(`issue-body.test.js: ${passed} checks passed`);
|