@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,245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for templates/trailhead-auto-queue.js. Run: node auto-queue.test.js
|
|
3
|
+
// No framework: plain asserts, same style as uat-backfill.test.js.
|
|
4
|
+
//
|
|
5
|
+
// Layers covered:
|
|
6
|
+
// 1. buildQueue() / main() against a fake tracker behind an injected `gh`
|
|
7
|
+
// runner that honours the frontier search filter and the paused-claims read.
|
|
8
|
+
// 2. 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-auto-queue.js');
|
|
16
|
+
const { main, buildQueue, RUN_SEARCH, NEEDS_YOU } = require(scriptPath);
|
|
17
|
+
|
|
18
|
+
let passed = 0;
|
|
19
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
20
|
+
|
|
21
|
+
// --- fake tracker ------------------------------------------------------------
|
|
22
|
+
// issue: { number, title, labels:[names], assignee: 'me'|null, state }
|
|
23
|
+
function makeTracker(init = {}) {
|
|
24
|
+
const t = { issues: init.issues || [], failOn: init.failOn || null, badJson: init.badJson || null, limit: init.limit || 1000, calls: [] };
|
|
25
|
+
const res = (stdout, status = 0) => ({ status, stdout, stderr: status ? 'forced failure' : '' });
|
|
26
|
+
t.gh = (args) => {
|
|
27
|
+
t.calls.push(args.join(' '));
|
|
28
|
+
if (args[0] !== 'issue' || args[1] !== 'list') return res('', 1);
|
|
29
|
+
const want = [];
|
|
30
|
+
let search = '';
|
|
31
|
+
let assignee = null;
|
|
32
|
+
let state = 'open';
|
|
33
|
+
let limit = 1000;
|
|
34
|
+
for (let i = 2; i < args.length; i++) {
|
|
35
|
+
if (args[i] === '--label') want.push(args[++i]);
|
|
36
|
+
else if (args[i] === '--search') search = args[++i];
|
|
37
|
+
else if (args[i] === '--assignee') assignee = args[++i];
|
|
38
|
+
else if (args[i] === '--state') state = args[++i];
|
|
39
|
+
else if (args[i] === '--limit') limit = Number(args[++i]);
|
|
40
|
+
}
|
|
41
|
+
const kind = want.includes('trailhead:map') ? 'maps' : want.includes('trailhead:paused') ? 'paused' : 'frontier';
|
|
42
|
+
if (t.failOn === kind) return res('', 1);
|
|
43
|
+
if (t.badJson && t.badJson === kind) return res('not json');
|
|
44
|
+
const excl = [...search.matchAll(/-label:(\S+)/g)].map((m) => m[1]);
|
|
45
|
+
const noAssignee = /no:assignee/.test(search);
|
|
46
|
+
let rows = t.issues.filter((i) => i.state !== 'closed' && state === 'open')
|
|
47
|
+
.filter((i) => want.every((l) => i.labels.includes(l)))
|
|
48
|
+
.filter((i) => !excl.some((l) => i.labels.includes(l)))
|
|
49
|
+
.filter((i) => !noAssignee || !i.assignee)
|
|
50
|
+
.filter((i) => assignee !== '@me' || i.assignee === 'me');
|
|
51
|
+
rows = rows.slice(0, t.limit === 1000 ? limit : t.limit);
|
|
52
|
+
return res(JSON.stringify(rows.map((i) => ({ number: i.number, title: i.title, labels: i.labels.map((name) => ({ name })) }))));
|
|
53
|
+
};
|
|
54
|
+
return t;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function run(t, argv) {
|
|
58
|
+
let out = '';
|
|
59
|
+
let errOut = '';
|
|
60
|
+
const code = main(argv, { gh: t.gh, stdout: { write: (s) => { out += s; } }, stderr: { write: (s) => { errOut += s; } } });
|
|
61
|
+
let json = null;
|
|
62
|
+
try { json = JSON.parse(out.trim()); } catch { /* not json */ }
|
|
63
|
+
return { code, out, json, errOut };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const map = (n, extra = {}) => ({ number: n, title: `Map ${n}`, labels: ['trailhead:map'], ...extra });
|
|
67
|
+
const tk = (n, type, labels, extra = {}) => ({ number: n, title: `T${n}`, labels: ['trailhead:ticket', `trailhead:${type}`, ...labels], assignee: null, ...extra });
|
|
68
|
+
const nums = (list) => list.map((x) => x.number);
|
|
69
|
+
|
|
70
|
+
const base = () => [
|
|
71
|
+
map(5), map(2), map(9, { state: 'closed' }),
|
|
72
|
+
tk(30, 'build', ['trailhead:map-5']),
|
|
73
|
+
tk(21, 'bug', ['trailhead:map-2']),
|
|
74
|
+
tk(11, 'task', ['trailhead:map-2']),
|
|
75
|
+
tk(40, 'research', ['trailhead:whiteboard']),
|
|
76
|
+
tk(31, 'decision', ['trailhead:map-5']),
|
|
77
|
+
tk(50, 'build', ['trailhead:map-9']), // closed map: ignored
|
|
78
|
+
tk(51, 'build', []), // no scope label: ignored
|
|
79
|
+
tk(52, 'build', ['trailhead:map-2', 'trailhead:whiteboard']), // map wins
|
|
80
|
+
tk(60, 'build', ['trailhead:map-2', 'trailhead:blocked']), // blocked: never queued
|
|
81
|
+
tk(61, 'build', ['trailhead:map-5', 'trailhead:paused']),
|
|
82
|
+
tk(62, 'build', ['trailhead:map-5', 'trailhead:unverified']),
|
|
83
|
+
tk(63, 'build', ['trailhead:map-5'], { assignee: 'someone' }),
|
|
84
|
+
tk(70, 'build', ['trailhead:map-2', 'trailhead:map-5']), // two open maps: drift
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
// --- ordering: everything ----------------------------------------------------
|
|
88
|
+
{
|
|
89
|
+
const t = makeTracker({ issues: base() });
|
|
90
|
+
const r = run(t, ['queue', '--scope', 'everything', '--repo', 'o/r']);
|
|
91
|
+
ok('exit 0, one JSON line', r.code === 0 && r.json.status === 'ok' && r.out.trim().split('\n').length === 1);
|
|
92
|
+
ok('groups: maps ascending, whiteboard last', JSON.stringify(r.json.groups.map((g) => g.map)) === '[2,5,null]' && r.json.groups[2].kind === 'whiteboard');
|
|
93
|
+
ok('tickets ascending inside a group, map label wins over whiteboard', JSON.stringify(nums(r.json.groups[0].tickets)) === '[11,21,52]');
|
|
94
|
+
ok('map 5 group', JSON.stringify(nums(r.json.groups[1].tickets)) === '[30,31]');
|
|
95
|
+
ok('whiteboard group', JSON.stringify(nums(r.json.groups[2].tickets)) === '[40]');
|
|
96
|
+
ok('each ticket carries its type', r.json.groups[0].tickets[0].type === 'task' && r.json.groups[0].tickets[1].type === 'bug' && r.json.groups[1].tickets[1].type === 'decision');
|
|
97
|
+
const all = r.json.groups.flatMap((g) => nums(g.tickets));
|
|
98
|
+
ok('blocked/unverified/paused/assigned never queued', [60, 61, 62, 63].every((n) => !all.includes(n)));
|
|
99
|
+
ok('closed-map and unlabelled tickets ignored', !all.includes(50) && !all.includes(51));
|
|
100
|
+
ok('multi-map-label ticket is drift, not queued', JSON.stringify(nums(r.json.drift)) === '[70]' && !all.includes(70));
|
|
101
|
+
ok('next is the lowest ticket of the first map', r.json.next.number === 11 && r.json.next.map === 2);
|
|
102
|
+
ok('queue reads: frontier uses the canonical search', t.calls.some((c) => c.includes('--label trailhead:ticket') && c.includes('no:assignee -label:trailhead:blocked -label:trailhead:unverified -label:trailhead:paused') && c.includes('--limit 1000') && c.includes('--json number,title,labels')));
|
|
103
|
+
ok('queue reads: open maps and caller paused claims', t.calls.some((c) => c.includes('--label trailhead:map --state open') && c.includes('--json number,title')) && t.calls.some((c) => c.includes('--label trailhead:paused') && c.includes('--assignee @me')));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- skip / next -------------------------------------------------------------
|
|
107
|
+
{
|
|
108
|
+
const t = makeTracker({ issues: base() });
|
|
109
|
+
let r = run(t, ['queue', '--scope', 'everything', '--skip', '11,21']);
|
|
110
|
+
ok('skipped tickets dropped, next stays in the current map', JSON.stringify(nums(r.json.groups[0].tickets)) === '[52]' && r.json.next.number === 52);
|
|
111
|
+
r = run(t, ['queue', '--scope', 'everything', '--skip', '11,21,52']);
|
|
112
|
+
ok('next moves to the next map only when the group is empty after skip', r.json.next.number === 30 && r.json.next.map === 5);
|
|
113
|
+
r = run(t, ['queue', '--scope', 'everything', '--skip', '11,21,52,30,31']);
|
|
114
|
+
ok('whiteboard only after every map', r.json.next.number === 40 && r.json.next.map === null);
|
|
115
|
+
r = run(t, ['queue', '--scope', 'everything', '--skip', '11,21,52,30,31,40']);
|
|
116
|
+
ok('drained scope has next null', r.code === 0 && r.json.next === null);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- single scopes -----------------------------------------------------------
|
|
120
|
+
{
|
|
121
|
+
const t = makeTracker({ issues: base() });
|
|
122
|
+
let r = run(t, ['queue', '--scope', 'map:5']);
|
|
123
|
+
ok('map:<n> yields only that map', r.json.groups.length === 1 && r.json.groups[0].map === 5 && r.json.next.number === 30);
|
|
124
|
+
ok('map:<n> drift is limited to that map', JSON.stringify(nums(r.json.drift)) === '[70]');
|
|
125
|
+
r = run(t, ['queue', '--scope', 'whiteboard']);
|
|
126
|
+
ok('whiteboard scope yields only the whiteboard group', r.json.groups.length === 1 && r.json.groups[0].kind === 'whiteboard' && r.json.next.number === 40 && r.json.drift.length === 0);
|
|
127
|
+
r = run(t, ['queue', '--scope', 'map:9']);
|
|
128
|
+
ok('a closed map is an error naming it', r.code === 1 && r.json.status === 'error' && /9/.test(r.json.reason));
|
|
129
|
+
r = run(t, ['queue', '--scope', 'map:77']);
|
|
130
|
+
ok('an unknown map is an error naming it', r.code === 1 && r.json.status === 'error' && /77/.test(r.json.reason));
|
|
131
|
+
ok('a non-map issue is an error', run(makeTracker({ issues: [tk(8, 'build', ['trailhead:map-2'])] }), ['queue', '--scope', 'map:8']).code === 1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// --- resume ------------------------------------------------------------------
|
|
135
|
+
{
|
|
136
|
+
const issues = [
|
|
137
|
+
map(2), map(5),
|
|
138
|
+
tk(14, 'build', ['trailhead:map-5', 'trailhead:paused'], { assignee: 'me' }),
|
|
139
|
+
tk(12, 'bug', ['trailhead:map-5', 'trailhead:paused'], { assignee: 'me' }),
|
|
140
|
+
tk(13, 'build', ['trailhead:map-2', 'trailhead:paused'], { assignee: 'other' }),
|
|
141
|
+
tk(15, 'task', ['trailhead:whiteboard', 'trailhead:paused'], { assignee: 'me' }),
|
|
142
|
+
tk(30, 'build', ['trailhead:map-5']),
|
|
143
|
+
];
|
|
144
|
+
const t = makeTracker({ issues });
|
|
145
|
+
let r = run(t, ['queue', '--scope', 'everything']);
|
|
146
|
+
ok('resume lists the caller paused tickets per group, ascending', JSON.stringify(nums(r.json.groups[1].resume)) === '[12,14]' && r.json.groups[0].resume.length === 0 && JSON.stringify(nums(r.json.groups[2].resume)) === '[15]');
|
|
147
|
+
ok('paused tickets are not in the work queue', JSON.stringify(nums(r.json.groups[1].tickets)) === '[30]');
|
|
148
|
+
r = run(t, ['queue', '--scope', 'map:5']);
|
|
149
|
+
ok('resume is scoped', r.json.groups.length === 1 && JSON.stringify(nums(r.json.groups[0].resume)) === '[12,14]');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- needs-you tickets are skipped by the run ---------------------------------
|
|
153
|
+
{
|
|
154
|
+
const issues = [
|
|
155
|
+
map(2),
|
|
156
|
+
tk(11, 'build', ['trailhead:map-2']),
|
|
157
|
+
tk(12, 'build', ['trailhead:map-2', 'trailhead:needs-you']),
|
|
158
|
+
tk(13, 'build', ['trailhead:map-2', 'trailhead:paused', 'trailhead:needs-you'], { assignee: 'me' }),
|
|
159
|
+
tk(14, 'build', ['trailhead:map-2', 'trailhead:paused'], { assignee: 'me' }),
|
|
160
|
+
];
|
|
161
|
+
const t = makeTracker({ issues });
|
|
162
|
+
let r = run(t, ['queue', '--scope', 'everything']);
|
|
163
|
+
ok('needs-you ticket is not queued nor next', JSON.stringify(nums(r.json.groups[0].tickets)) === '[11]' && r.json.next.number === 11);
|
|
164
|
+
ok('needs-you ticket is never auto-resumed', JSON.stringify(nums(r.json.groups[0].resume)) === '[14]');
|
|
165
|
+
ok('frontier read sends the needs-you exclusion', t.calls.some((c) => c.includes('--label trailhead:ticket') && c.includes('-label:trailhead:paused -label:trailhead:needs-you')));
|
|
166
|
+
r = run(t, ['scopes']);
|
|
167
|
+
ok('scopes does not count a needs-you ticket', r.json.maps[0].frontier === 1);
|
|
168
|
+
ok('RUN_SEARCH extends the canonical frontier', RUN_SEARCH.endsWith(' -label:trailhead:needs-you') && NEEDS_YOU === 'trailhead:needs-you');
|
|
169
|
+
// defence against a lagging search index: the pure builder drops it too
|
|
170
|
+
const q = buildQueue([{ number: 2, title: 'm' }],
|
|
171
|
+
[{ number: 5, title: 'a', labels: ['trailhead:map-2', 'trailhead:needs-you'] }, { number: 6, title: 'b', labels: ['trailhead:map-2'] }],
|
|
172
|
+
[{ number: 7, title: 'c', labels: ['trailhead:map-2', 'trailhead:needs-you'] }], 'everything', []);
|
|
173
|
+
ok('buildQueue drops needs-you from frontier and resume', nums(q.groups[0].tickets).join() === '6' && q.groups[0].resume.length === 0 && q.next.number === 6);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- fail closed -------------------------------------------------------------
|
|
177
|
+
{
|
|
178
|
+
const many = [map(2)];
|
|
179
|
+
for (let i = 0; i < 1000; i++) many.push(tk(100 + i, 'build', ['trailhead:map-2']));
|
|
180
|
+
const t = makeTracker({ issues: many });
|
|
181
|
+
const r = run(t, ['queue', '--scope', 'everything']);
|
|
182
|
+
ok('a frontier read at the limit fails closed', r.code === 1 && r.json.status === 'error' && /incomplete/.test(r.json.reason) && !r.json.groups);
|
|
183
|
+
const s = run(t, ['scopes']);
|
|
184
|
+
ok('scopes fails closed on truncation too', s.code === 1 && s.json.status === 'error' && /incomplete/.test(s.json.reason));
|
|
185
|
+
const maps = [];
|
|
186
|
+
for (let i = 1; i <= 1000; i++) maps.push(map(i));
|
|
187
|
+
ok('a truncated map list fails closed', run(makeTracker({ issues: maps }), ['queue', '--scope', 'everything']).code === 1);
|
|
188
|
+
}
|
|
189
|
+
for (const kind of ['maps', 'frontier', 'paused']) {
|
|
190
|
+
let r = run(makeTracker({ issues: base(), failOn: kind }), ['queue', '--scope', 'everything']);
|
|
191
|
+
ok(`gh failure on ${kind} read is an error`, r.code === 1 && r.json.status === 'error');
|
|
192
|
+
r = run(makeTracker({ issues: base(), badJson: kind }), ['queue', '--scope', 'everything']);
|
|
193
|
+
ok(`bad JSON on ${kind} read is an error`, r.code === 1 && r.json.status === 'error');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- scopes ------------------------------------------------------------------
|
|
197
|
+
{
|
|
198
|
+
const t = makeTracker({ issues: base() });
|
|
199
|
+
const r = run(t, ['scopes', '--repo', 'o/r']);
|
|
200
|
+
ok('scopes: open maps ascending with frontier counts', r.code === 0 && JSON.stringify(r.json.maps) === JSON.stringify([
|
|
201
|
+
{ number: 2, title: 'Map 2', frontier: 3 }, { number: 5, title: 'Map 5', frontier: 2 }]));
|
|
202
|
+
ok('scopes: whiteboard count', r.json.whiteboard.frontier === 1);
|
|
203
|
+
ok('scopes: passes -R', t.calls.every((c) => c.includes('-R o/r')));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- pure buildQueue ---------------------------------------------------------
|
|
207
|
+
{
|
|
208
|
+
const q = buildQueue([{ number: 1, title: 'a' }], [{ number: 3, title: 'x', labels: ['trailhead:map-1', 'trailhead:build'] }], [], 'everything', []);
|
|
209
|
+
ok('buildQueue accepts string labels', q.groups[0].tickets[0].type === 'build' && q.next.number === 3);
|
|
210
|
+
ok('buildQueue reports a bad scope', buildQueue([], [], [], 'map:4', []).error);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// --- usage -------------------------------------------------------------------
|
|
214
|
+
for (const argv of [[], ['bogus'], ['queue'], ['queue', '--scope', 'nope'], ['queue', '--scope', 'map:x'], ['queue', '--scope', 'everything', '--skip', 'a,b'],
|
|
215
|
+
['queue', '--scope'], ['scopes', '--bogus'], ['scopes', '--repo', 'nope']]) {
|
|
216
|
+
const t = makeTracker();
|
|
217
|
+
const r = run(t, argv);
|
|
218
|
+
ok(`usage error exit 2: ${argv.join(' ')}`, r.code === 2 && t.calls.length === 0);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --- end-to-end with a fake gh on PATH -----------------------------------------
|
|
222
|
+
{
|
|
223
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'auto-queue-'));
|
|
224
|
+
const binDir = path.join(tmp, 'bin');
|
|
225
|
+
fs.mkdirSync(binDir);
|
|
226
|
+
const script = `#!/bin/sh
|
|
227
|
+
case "$*" in
|
|
228
|
+
*"--label trailhead:map "*) echo '[{"number":2,"title":"M"}]' ;;
|
|
229
|
+
*"--label trailhead:paused"*) echo '[]' ;;
|
|
230
|
+
*) echo '[{"number":7,"title":"T","labels":[{"name":"trailhead:map-2"},{"name":"trailhead:build"}]}]' ;;
|
|
231
|
+
esac
|
|
232
|
+
`;
|
|
233
|
+
fs.writeFileSync(path.join(binDir, 'gh'), script, { mode: 0o755 });
|
|
234
|
+
const env = { ...process.env, PATH: binDir + path.delimiter + process.env.PATH };
|
|
235
|
+
const res = spawnSync('node', [scriptPath, 'queue', '--scope', 'map:2'], { encoding: 'utf8', env });
|
|
236
|
+
const j = JSON.parse(res.stdout.trim());
|
|
237
|
+
ok('e2e: queue prints one JSON line with next', res.status === 0 && j.status === 'ok' && j.next.number === 7 && j.next.type === 'build');
|
|
238
|
+
fs.writeFileSync(path.join(binDir, 'gh'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
|
|
239
|
+
const bad = spawnSync('node', [scriptPath, 'queue', '--scope', 'everything'], { encoding: 'utf8', env });
|
|
240
|
+
ok('e2e: gh failing exits 1 with an error line', bad.status === 1 && JSON.parse(bad.stdout.trim()).status === 'error');
|
|
241
|
+
ok('e2e: usage error exits 2', spawnSync('node', [scriptPath, '--bogus'], { encoding: 'utf8' }).status === 2);
|
|
242
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
console.log(`auto-queue.test.js: ${passed} checks passed`);
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for templates/trailhead-auto-run.js. Run: node auto-run.test.js
|
|
3
|
+
// No framework: plain asserts, same style as work-state.test.js.
|
|
4
|
+
//
|
|
5
|
+
// Layers covered:
|
|
6
|
+
// 1. sanitizeRecord() / parseArgs() pure.
|
|
7
|
+
// 2. main() with injected cwd/env/stdout/stderr against mkdtemp folders.
|
|
8
|
+
// 3. Fault injection: an fs whose write, read or unlink throws.
|
|
9
|
+
// 4. End-to-end: spawnSync of the script, including a simulated compaction.
|
|
10
|
+
const assert = require('assert');
|
|
11
|
+
const { spawnSync } = require('child_process');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
|
|
16
|
+
const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-auto-run.js');
|
|
17
|
+
const { main, parseArgs, sanitizeRecord } = require(scriptPath);
|
|
18
|
+
|
|
19
|
+
let passed = 0;
|
|
20
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
21
|
+
|
|
22
|
+
const mktmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-auto-run-test-'));
|
|
23
|
+
const recPath = (dir) => path.join(dir, '.trailhead', 'auto-run.json');
|
|
24
|
+
const readRec = (dir) => JSON.parse(fs.readFileSync(recPath(dir), 'utf8'));
|
|
25
|
+
const trailheadFiles = (dir) => fs.existsSync(path.join(dir, '.trailhead')) ? fs.readdirSync(path.join(dir, '.trailhead')) : [];
|
|
26
|
+
const fixedNow = () => new Date('2026-10-20T10:00:00.000Z');
|
|
27
|
+
|
|
28
|
+
// Esegue main() catturando stdout e stderr; ritorna { code, lines, err }.
|
|
29
|
+
function run(argv, { cwd = '/', env = {}, fsMod } = {}) {
|
|
30
|
+
const chunks = [];
|
|
31
|
+
const errChunks = [];
|
|
32
|
+
const code = main(argv, {
|
|
33
|
+
cwd,
|
|
34
|
+
env,
|
|
35
|
+
now: fixedNow(),
|
|
36
|
+
stdout: { write: (s) => chunks.push(s) },
|
|
37
|
+
stderr: { write: (s) => errChunks.push(String(s)) },
|
|
38
|
+
...(fsMod ? { fs: fsMod } : {}),
|
|
39
|
+
});
|
|
40
|
+
const lines = chunks.join('').split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
41
|
+
return { code, lines, err: errChunks.join('') };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const START = (dir, extra = []) => ['start', '--repo', dir, '--scope', 'map:12', '--slots', '2', '--review', '2', ...extra];
|
|
45
|
+
|
|
46
|
+
// --- start -------------------------------------------------------------------
|
|
47
|
+
{
|
|
48
|
+
const dir = mktmp();
|
|
49
|
+
const r = run(START(dir), { env: { CLAUDE_CODE_SESSION_ID: 'sess-1' } });
|
|
50
|
+
const rec = readRec(dir);
|
|
51
|
+
ok('start: exit 0, one line, status written', r.code === 0 && r.lines.length === 1 && r.lines[0].status === 'written');
|
|
52
|
+
ok('start: output carries path and record', r.lines[0].path === recPath(dir) && r.lines[0].record.scope === 'map:12');
|
|
53
|
+
ok('start: exact key set', JSON.stringify(Object.keys(rec)) ===
|
|
54
|
+
JSON.stringify(['version', 'scope', 'slots', 'review', 'session', 'skip', 'unmerged', 'startedAt', 'updatedAt']));
|
|
55
|
+
ok('start: values', rec.version === 1 && rec.scope === 'map:12' && rec.slots === 2 && rec.review === '2');
|
|
56
|
+
ok('start: empty lists', Array.isArray(rec.skip) && rec.skip.length === 0 && Array.isArray(rec.unmerged) && rec.unmerged.length === 0);
|
|
57
|
+
ok('start: session from env', rec.session === 'sess-1');
|
|
58
|
+
ok('start: timestamps ISO and equal', rec.startedAt === '2026-10-20T10:00:00.000Z' && rec.updatedAt === rec.startedAt);
|
|
59
|
+
ok('start: no temp file left', JSON.stringify(trailheadFiles(dir)) === JSON.stringify(['auto-run.json']));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- session null (Codex) ----------------------------------------------------
|
|
63
|
+
{
|
|
64
|
+
const dir = mktmp();
|
|
65
|
+
run(START(dir), { env: {} });
|
|
66
|
+
ok('start: session unset -> null', readRec(dir).session === null);
|
|
67
|
+
const dir2 = mktmp();
|
|
68
|
+
run(START(dir2), { env: { CLAUDE_CODE_SESSION_ID: '' } });
|
|
69
|
+
ok('start: session empty -> null', readRec(dir2).session === null);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- scope / review tokens ---------------------------------------------------
|
|
73
|
+
for (const scope of ['whiteboard', 'everything', 'map:7']) {
|
|
74
|
+
const dir = mktmp();
|
|
75
|
+
const r = run(['start', '--repo', dir, '--scope', scope, '--slots', '1', '--review', 'config']);
|
|
76
|
+
ok(`start: scope ${scope} accepted`, r.code === 0 && readRec(dir).scope === scope);
|
|
77
|
+
}
|
|
78
|
+
for (const review of ['config', '1', '2', '3', 'until', 'skip']) {
|
|
79
|
+
const dir = mktmp();
|
|
80
|
+
const r = run(['start', '--repo', dir, '--scope', 'everything', '--slots', '4', '--review', review]);
|
|
81
|
+
ok(`start: review ${review} accepted`, r.code === 0 && readRec(dir).review === review);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// --- start on an existing record ---------------------------------------------
|
|
85
|
+
{
|
|
86
|
+
const dir = mktmp();
|
|
87
|
+
run(START(dir));
|
|
88
|
+
run(['skip', '--repo', dir, '--ticket', '5']);
|
|
89
|
+
const r = run(['start', '--repo', dir, '--scope', 'everything', '--slots', '3', '--review', 'until']);
|
|
90
|
+
ok('start over existing: exists with the old record', r.code === 0 && r.lines[0].status === 'exists' && r.lines[0].record.scope === 'map:12');
|
|
91
|
+
ok('start over existing: nothing written', readRec(dir).scope === 'map:12' && readRec(dir).skip[0] === 5);
|
|
92
|
+
const r2 = run(['start', '--repo', dir, '--scope', 'everything', '--slots', '3', '--review', 'until', '--replace']);
|
|
93
|
+
const rec = readRec(dir);
|
|
94
|
+
ok('start --replace: written fresh', r2.lines[0].status === 'written' && rec.scope === 'everything' && rec.slots === 3 && rec.review === 'until' && rec.skip.length === 0);
|
|
95
|
+
}
|
|
96
|
+
{
|
|
97
|
+
const dir = mktmp();
|
|
98
|
+
fs.mkdirSync(path.join(dir, '.trailhead'));
|
|
99
|
+
fs.writeFileSync(recPath(dir), '{not json');
|
|
100
|
+
const r = run(START(dir));
|
|
101
|
+
ok('start over corrupt: exists, no record, file untouched', r.lines[0].status === 'exists' && r.lines[0].record === null && fs.readFileSync(recPath(dir), 'utf8') === '{not json');
|
|
102
|
+
const r2 = run(START(dir, ['--replace']));
|
|
103
|
+
ok('start --replace over corrupt: written', r2.lines[0].status === 'written' && readRec(dir).scope === 'map:12');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- read --------------------------------------------------------------------
|
|
107
|
+
{
|
|
108
|
+
const dir = mktmp();
|
|
109
|
+
const r = run(['read', '--repo', dir]);
|
|
110
|
+
ok('read: absent', r.code === 0 && r.lines[0].status === 'absent' && trailheadFiles(dir).length === 0);
|
|
111
|
+
}
|
|
112
|
+
{
|
|
113
|
+
const dir = mktmp();
|
|
114
|
+
run(START(dir), { env: { CLAUDE_CODE_SESSION_ID: 'sess-1' } });
|
|
115
|
+
const same = run(['read', '--repo', dir], { env: { CLAUDE_CODE_SESSION_ID: 'sess-1' } });
|
|
116
|
+
ok('read: found with record', same.lines[0].status === 'found' && same.lines[0].record.slots === 2);
|
|
117
|
+
ok('read: sameSession true', same.lines[0].sameSession === true);
|
|
118
|
+
const other = run(['read', '--repo', dir], { env: { CLAUDE_CODE_SESSION_ID: 'sess-2' } });
|
|
119
|
+
ok('read: sameSession false for another session', other.lines[0].sameSession === false);
|
|
120
|
+
const none = run(['read', '--repo', dir], { env: {} });
|
|
121
|
+
ok('read: sameSession false when the reader has no session', none.lines[0].sameSession === false);
|
|
122
|
+
}
|
|
123
|
+
{
|
|
124
|
+
const dir = mktmp();
|
|
125
|
+
run(START(dir), { env: {} });
|
|
126
|
+
const r = run(['read', '--repo', dir], { env: { CLAUDE_CODE_SESSION_ID: 'sess-1' } });
|
|
127
|
+
ok('read: sameSession false when the record has no session', r.lines[0].sameSession === false);
|
|
128
|
+
const r2 = run(['read', '--repo', dir], { env: {} });
|
|
129
|
+
ok('read: sameSession false when both sessions are null', r2.lines[0].sameSession === false);
|
|
130
|
+
}
|
|
131
|
+
{
|
|
132
|
+
const dir = mktmp();
|
|
133
|
+
fs.mkdirSync(path.join(dir, '.trailhead'));
|
|
134
|
+
fs.writeFileSync(recPath(dir), '{not json');
|
|
135
|
+
const r = run(['read', '--repo', dir]);
|
|
136
|
+
ok('read: unparseable is corrupt', r.code === 0 && r.lines[0].status === 'corrupt');
|
|
137
|
+
for (const patch of [{ version: 2 }, { scope: 'nope' }, { slots: 9 }, { slots: 0 }, { review: 'x' }, { version: undefined }, { scope: undefined }, { slots: undefined }, { review: undefined }]) {
|
|
138
|
+
const base = { version: 1, scope: 'whiteboard', slots: 1, review: 'config', session: null, skip: [], unmerged: [], startedAt: 'a', updatedAt: 'b' };
|
|
139
|
+
fs.writeFileSync(recPath(dir), JSON.stringify({ ...base, ...patch }));
|
|
140
|
+
const rr = run(['read', '--repo', dir]);
|
|
141
|
+
ok(`read: invalid required field ${JSON.stringify(patch)} is corrupt`, rr.code === 0 && rr.lines[0].status === 'corrupt');
|
|
142
|
+
}
|
|
143
|
+
fs.writeFileSync(recPath(dir), '[1,2]');
|
|
144
|
+
ok('read: array is corrupt', run(['read', '--repo', dir]).lines[0].status === 'corrupt');
|
|
145
|
+
fs.writeFileSync(recPath(dir), 'null');
|
|
146
|
+
ok('read: null is corrupt', run(['read', '--repo', dir]).lines[0].status === 'corrupt');
|
|
147
|
+
}
|
|
148
|
+
{
|
|
149
|
+
// Voci di lista invalide: scartate, il record resta found.
|
|
150
|
+
const dir = mktmp();
|
|
151
|
+
fs.mkdirSync(path.join(dir, '.trailhead'));
|
|
152
|
+
fs.writeFileSync(recPath(dir), JSON.stringify({
|
|
153
|
+
version: 1, scope: 'everything', slots: 2, review: 'skip', session: 's', startedAt: 'a', updatedAt: 'b',
|
|
154
|
+
skip: [9, 3, 'x', -1, 0, 1.5, 3],
|
|
155
|
+
unmerged: [
|
|
156
|
+
{ ticket: 4, branch: 'trailhead/t4', files: ['a.js', 7, ''], mergeTicket: 11 },
|
|
157
|
+
{ ticket: 'x', branch: 'trailhead/t5', files: [], mergeTicket: null },
|
|
158
|
+
{ ticket: 6, branch: '', files: [], mergeTicket: null },
|
|
159
|
+
{ ticket: 7, branch: 'a\nb', files: [], mergeTicket: null },
|
|
160
|
+
{ ticket: 8, branch: 'trailhead/t8', files: 'nope', mergeTicket: 'z' },
|
|
161
|
+
'junk',
|
|
162
|
+
],
|
|
163
|
+
}));
|
|
164
|
+
const r = run(['read', '--repo', dir]);
|
|
165
|
+
const rec = r.lines[0].record;
|
|
166
|
+
ok('read: partial list entries -> found', r.lines[0].status === 'found');
|
|
167
|
+
ok('read: skip sanitized, deduped, sorted', JSON.stringify(rec.skip) === JSON.stringify([3, 9]));
|
|
168
|
+
ok('read: unmerged invalid entries dropped', rec.unmerged.length === 2 && rec.unmerged[0].ticket === 4 && rec.unmerged[1].ticket === 8);
|
|
169
|
+
ok('read: unmerged fields normalised', JSON.stringify(rec.unmerged[0].files) === JSON.stringify(['a.js']) && rec.unmerged[0].mergeTicket === 11 &&
|
|
170
|
+
rec.unmerged[1].files.length === 0 && rec.unmerged[1].mergeTicket === null);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- skip --------------------------------------------------------------------
|
|
174
|
+
{
|
|
175
|
+
const dir = mktmp();
|
|
176
|
+
const r = run(['skip', '--repo', dir, '--ticket', '3']);
|
|
177
|
+
ok('skip: absent record, nothing written', r.code === 0 && r.lines[0].status === 'absent' && trailheadFiles(dir).length === 0);
|
|
178
|
+
run(START(dir));
|
|
179
|
+
run(['skip', '--repo', dir, '--ticket', '30']);
|
|
180
|
+
run(['skip', '--repo', dir, '--ticket', '4']);
|
|
181
|
+
const again = run(['skip', '--repo', dir, '--ticket', '30']);
|
|
182
|
+
run(['skip', '--repo', dir, '--ticket', '17']);
|
|
183
|
+
ok('skip: written status and record', again.lines[0].status === 'written' && again.lines[0].record.skip.length === 2);
|
|
184
|
+
ok('skip: sorted ascending, deduped', JSON.stringify(readRec(dir).skip) === JSON.stringify([4, 17, 30]));
|
|
185
|
+
ok('skip: other fields kept', readRec(dir).scope === 'map:12' && readRec(dir).startedAt === '2026-10-20T10:00:00.000Z');
|
|
186
|
+
}
|
|
187
|
+
{
|
|
188
|
+
const dir = mktmp();
|
|
189
|
+
fs.mkdirSync(path.join(dir, '.trailhead'));
|
|
190
|
+
fs.writeFileSync(recPath(dir), 'garbage');
|
|
191
|
+
const r = run(['skip', '--repo', dir, '--ticket', '3']);
|
|
192
|
+
ok('skip: corrupt record reported, not overwritten', r.code === 0 && r.lines[0].status === 'corrupt' && fs.readFileSync(recPath(dir), 'utf8') === 'garbage');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- unmerged ----------------------------------------------------------------
|
|
196
|
+
{
|
|
197
|
+
const dir = mktmp();
|
|
198
|
+
const r = run(['unmerged', '--repo', dir, '--ticket', '3', '--branch', 'trailhead/t3']);
|
|
199
|
+
ok('unmerged: absent record, nothing written', r.code === 0 && r.lines[0].status === 'absent' && trailheadFiles(dir).length === 0);
|
|
200
|
+
run(START(dir));
|
|
201
|
+
const a = run(['unmerged', '--repo', dir, '--ticket', '8', '--branch', 'trailhead/t8', '--file', 'a.js', '--file', 'b/c.js']);
|
|
202
|
+
ok('unmerged: insert', a.lines[0].status === 'written' && JSON.stringify(readRec(dir).unmerged) ===
|
|
203
|
+
JSON.stringify([{ ticket: 8, branch: 'trailhead/t8', files: ['a.js', 'b/c.js'], mergeTicket: null }]));
|
|
204
|
+
run(['unmerged', '--repo', dir, '--ticket', '5', '--branch', 'trailhead/t5']);
|
|
205
|
+
ok('unmerged: no files is an empty list, sorted by ticket', JSON.stringify(readRec(dir).unmerged.map((u) => u.ticket)) === '[5,8]' && readRec(dir).unmerged[0].files.length === 0);
|
|
206
|
+
run(['unmerged', '--repo', dir, '--ticket', '8', '--branch', 'trailhead/t8', '--file', 'a.js', '--file', 'b/c.js', '--merge-ticket', '99']);
|
|
207
|
+
const rec = readRec(dir);
|
|
208
|
+
ok('unmerged: upsert replaces the entry (no duplicate)', rec.unmerged.length === 2 && rec.unmerged.find((u) => u.ticket === 8).mergeTicket === 99);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- clear -------------------------------------------------------------------
|
|
212
|
+
{
|
|
213
|
+
const dir = mktmp();
|
|
214
|
+
ok('clear: absent', run(['clear', '--repo', dir]).lines[0].status === 'absent');
|
|
215
|
+
run(START(dir));
|
|
216
|
+
const r = run(['clear', '--repo', dir]);
|
|
217
|
+
ok('clear: cleared and file gone', r.code === 0 && r.lines[0].status === 'cleared' && !fs.existsSync(recPath(dir)));
|
|
218
|
+
ok('clear: read afterwards is absent', run(['read', '--repo', dir]).lines[0].status === 'absent');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --- --repo default is cwd ---------------------------------------------------
|
|
222
|
+
{
|
|
223
|
+
const dir = mktmp();
|
|
224
|
+
run(['start', '--scope', 'whiteboard', '--slots', '1', '--review', 'skip'], { cwd: dir });
|
|
225
|
+
ok('repo defaults to cwd', readRec(dir).scope === 'whiteboard');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- usage errors: exit 2, nothing written -----------------------------------
|
|
229
|
+
for (const argv of [
|
|
230
|
+
[],
|
|
231
|
+
['nope'],
|
|
232
|
+
['start', '--scope', 'map:1', '--slots', '2'],
|
|
233
|
+
['start', '--scope', 'map:0', '--slots', '2', '--review', 'config'],
|
|
234
|
+
['start', '--scope', 'map:x', '--slots', '2', '--review', 'config'],
|
|
235
|
+
['start', '--scope', 'maps', '--slots', '2', '--review', 'config'],
|
|
236
|
+
['start', '--scope', 'everything', '--slots', '0', '--review', 'config'],
|
|
237
|
+
['start', '--scope', 'everything', '--slots', '5', '--review', 'config'],
|
|
238
|
+
['start', '--scope', 'everything', '--slots', 'two', '--review', 'config'],
|
|
239
|
+
['start', '--scope', 'everything', '--slots', '2', '--review', 'four'],
|
|
240
|
+
['start', '--scope', 'everything', '--slots', '2', '--review', 'config', '--bogus', 'x'],
|
|
241
|
+
['start', '--scope', 'everything', '--slots'],
|
|
242
|
+
['skip', '--ticket', '0'],
|
|
243
|
+
['skip', '--ticket', 'abc'],
|
|
244
|
+
['skip'],
|
|
245
|
+
['unmerged', '--ticket', '3'],
|
|
246
|
+
['unmerged', '--branch', 'trailhead/t3'],
|
|
247
|
+
['unmerged', '--ticket', '3', '--branch', ''],
|
|
248
|
+
['unmerged', '--ticket', '3', '--branch', 'a\nb'],
|
|
249
|
+
['unmerged', '--ticket', '3', '--branch', 'b', '--merge-ticket', '0'],
|
|
250
|
+
['unmerged', '--ticket', '3', '--branch', 'b', '--merge-ticket', 'q'],
|
|
251
|
+
['read', '--ticket', '3'],
|
|
252
|
+
['clear', '--replace'],
|
|
253
|
+
['skip', '--ticket', '3', '--replace'],
|
|
254
|
+
]) {
|
|
255
|
+
const dir = mktmp();
|
|
256
|
+
const withRepo = argv.length ? [argv[0], '--repo', dir, ...argv.slice(1)] : argv;
|
|
257
|
+
const r = run(withRepo);
|
|
258
|
+
ok(`usage error exit 2, nothing written: ${JSON.stringify(argv)}`, r.code === 2 && r.lines.length === 0 && r.err.length > 0 && trailheadFiles(dir).length === 0);
|
|
259
|
+
}
|
|
260
|
+
ok('parseArgs: --replace only on start', parseArgs(['clear', '--replace']).error !== undefined && parseArgs(START('/x', ['--replace'])).replace === true);
|
|
261
|
+
ok('parseArgs: --file repeatable', JSON.stringify(parseArgs(['unmerged', '--ticket', '1', '--branch', 'b', '--file', 'x', '--file', 'y']).files) === '["x","y"]');
|
|
262
|
+
|
|
263
|
+
// --- sanitizeRecord pure -----------------------------------------------------
|
|
264
|
+
ok('sanitizeRecord: non-object is an error', sanitizeRecord(null).error !== undefined && sanitizeRecord('x').error !== undefined);
|
|
265
|
+
|
|
266
|
+
// --- skipped path: an injected failing fs ------------------------------------
|
|
267
|
+
const boom = () => { throw new Error('disk on fire'); };
|
|
268
|
+
{
|
|
269
|
+
const dir = mktmp();
|
|
270
|
+
const r = run(START(dir), { fsMod: { ...fs, writeFileSync: boom } });
|
|
271
|
+
ok('skipped: start with a failing write', r.code === 0 && r.lines.length === 1 && r.lines[0].status === 'skipped' && /disk on fire/.test(r.lines[0].message));
|
|
272
|
+
ok('skipped: start leaves no temp file', trailheadFiles(dir).every((f) => !f.endsWith('.tmp')));
|
|
273
|
+
const r2 = run(START(dir), { fsMod: { ...fs, renameSync: boom } });
|
|
274
|
+
ok('skipped: start with a failing rename', r2.code === 0 && r2.lines[0].status === 'skipped' && trailheadFiles(dir).every((f) => !f.endsWith('.tmp')));
|
|
275
|
+
}
|
|
276
|
+
{
|
|
277
|
+
const dir = mktmp();
|
|
278
|
+
run(START(dir));
|
|
279
|
+
const fail = { ...fs, writeFileSync: boom };
|
|
280
|
+
const s = run(['skip', '--repo', dir, '--ticket', '3'], { fsMod: fail });
|
|
281
|
+
ok('skipped: skip with a failing write', s.code === 0 && s.lines[0].status === 'skipped' && readRec(dir).skip.length === 0);
|
|
282
|
+
const u = run(['unmerged', '--repo', dir, '--ticket', '3', '--branch', 'b'], { fsMod: fail });
|
|
283
|
+
ok('skipped: unmerged with a failing write', u.code === 0 && u.lines[0].status === 'skipped' && readRec(dir).unmerged.length === 0);
|
|
284
|
+
const rd = run(['read', '--repo', dir], { fsMod: { ...fs, readFileSync: boom } });
|
|
285
|
+
ok('skipped: read with a failing read', rd.code === 0 && rd.lines[0].status === 'skipped');
|
|
286
|
+
const c = run(['clear', '--repo', dir], { fsMod: { ...fs, unlinkSync: boom } });
|
|
287
|
+
ok('skipped: clear with a failing unlink', c.code === 0 && c.lines[0].status === 'skipped' && fs.existsSync(recPath(dir)));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// --- end-to-end, simulated compaction ----------------------------------------
|
|
291
|
+
{
|
|
292
|
+
const dir = mktmp();
|
|
293
|
+
const cli = (args, env = {}) => {
|
|
294
|
+
const p = spawnSync(process.execPath, [scriptPath, ...args], { encoding: 'utf8', env: { PATH: process.env.PATH, ...env } });
|
|
295
|
+
return { code: p.status, line: JSON.parse(p.stdout.trim().split('\n').pop()) };
|
|
296
|
+
};
|
|
297
|
+
// Un solo processo: start + skip + unmerged (poi upsert con merge ticket).
|
|
298
|
+
const writer = (args) => run(args, { env: { CLAUDE_CODE_SESSION_ID: 'sess-A' } });
|
|
299
|
+
writer(['start', '--repo', dir, '--scope', 'map:42', '--slots', '3', '--review', 'until']);
|
|
300
|
+
writer(['skip', '--repo', dir, '--ticket', '31']);
|
|
301
|
+
writer(['skip', '--repo', dir, '--ticket', '12']);
|
|
302
|
+
writer(['unmerged', '--repo', dir, '--ticket', '12', '--branch', 'trailhead/t12', '--file', 'src/a.ts', '--file', 'src/b.ts']);
|
|
303
|
+
writer(['unmerged', '--repo', dir, '--ticket', '12', '--branch', 'trailhead/t12', '--file', 'src/a.ts', '--file', 'src/b.ts', '--merge-ticket', '77']);
|
|
304
|
+
// Dopo la compattazione: un processo fresco rilegge tutto.
|
|
305
|
+
const fresh = cli(['read', '--repo', dir], { CLAUDE_CODE_SESSION_ID: 'sess-A' });
|
|
306
|
+
const rec = fresh.line.record;
|
|
307
|
+
ok('compaction: fresh process finds the record', fresh.code === 0 && fresh.line.status === 'found' && fresh.line.sameSession === true);
|
|
308
|
+
ok('compaction: scope, slots, review survive', rec.scope === 'map:42' && rec.slots === 3 && rec.review === 'until');
|
|
309
|
+
ok('compaction: skip list survives', JSON.stringify(rec.skip) === '[12,31]');
|
|
310
|
+
ok('compaction: unmerged list survives with the merge ticket', JSON.stringify(rec.unmerged) ===
|
|
311
|
+
JSON.stringify([{ ticket: 12, branch: 'trailhead/t12', files: ['src/a.ts', 'src/b.ts'], mergeTicket: 77 }]));
|
|
312
|
+
const other = cli(['read', '--repo', dir], { CLAUDE_CODE_SESSION_ID: 'sess-B' });
|
|
313
|
+
ok('compaction: another session sees sameSession false', other.line.sameSession === false && other.line.record.scope === 'map:42');
|
|
314
|
+
ok('cli: usage error exits 2', spawnSync(process.execPath, [scriptPath, 'nope'], { encoding: 'utf8' }).status === 2);
|
|
315
|
+
ok('cli: clear then read is absent', cli(['clear', '--repo', dir]).line.status === 'cleared' && cli(['read', '--repo', dir]).line.status === 'absent');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
console.log(`auto-run.test.js: ${passed} checks passed`);
|