claude-code-session-manager 0.25.1 → 0.26.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/dist/assets/{TiptapBody-_BRc_wPu.js → TiptapBody-BsI_35c5.js} +1 -1
- package/dist/assets/index-BKkf6gtA.css +32 -0
- package/dist/assets/{index-CK5Ob11w.js → index-C61eFtxs.js} +880 -880
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/dod-drain-hook.test.cjs +197 -0
- package/src/main/__tests__/dod-report.test.cjs +304 -0
- package/src/main/lib/definitionOfDone.cjs +231 -2
- package/src/main/lib/dodDrainHook.cjs +81 -0
- package/src/main/scheduler.cjs +25 -13
- package/dist/assets/index-Cu9X6oyA.css +0 -32
package/dist/index.html
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
8
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
10
|
-
<script type="module" crossorigin src="./assets/index-
|
|
10
|
+
<script type="module" crossorigin src="./assets/index-C61eFtxs.js"></script>
|
|
11
11
|
<link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
|
|
12
12
|
<link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
|
|
13
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
13
|
+
<link rel="stylesheet" crossorigin href="./assets/index-BKkf6gtA.css">
|
|
14
14
|
</head>
|
|
15
15
|
<body class="bg-bg text-fg font-sans antialiased">
|
|
16
16
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-session-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main/index.cjs",
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dod-drain-hook.test.cjs — drives the DoD drain handler.
|
|
3
|
+
*
|
|
4
|
+
* Run: timeout 120 node --test src/main/__tests__/dod-drain-hook.test.cjs
|
|
5
|
+
*
|
|
6
|
+
* Scenarios:
|
|
7
|
+
* 1. Drain with completed jobs → writes report once.
|
|
8
|
+
* 2. Second drain over same completed set → no-op (idempotent).
|
|
9
|
+
* 3. SM_DOD_DISABLE=1 → no report.
|
|
10
|
+
* 4. state.paused set → no report.
|
|
11
|
+
* 5. cancelToken.cancelled = true → no report.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const { test, beforeEach, afterEach } = require('node:test');
|
|
17
|
+
const assert = require('node:assert/strict');
|
|
18
|
+
const os = require('node:os');
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
const { runDefinitionOfDoneOnDrain } = require('../lib/dodDrainHook.cjs');
|
|
22
|
+
|
|
23
|
+
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function makeTmpDir() {
|
|
26
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'dod-drain-hook-test-'));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function rmdir(dir) {
|
|
30
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* */ }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Write a minimal PRD and return a completed-job record. */
|
|
34
|
+
function writePrd(prdsDir, slug, cwd) {
|
|
35
|
+
const body = [
|
|
36
|
+
'---',
|
|
37
|
+
`title: ${slug}`,
|
|
38
|
+
`cwd: ${cwd}`,
|
|
39
|
+
'estimateMinutes: 5',
|
|
40
|
+
'---',
|
|
41
|
+
'',
|
|
42
|
+
'# Goal',
|
|
43
|
+
'',
|
|
44
|
+
'Test fixture.',
|
|
45
|
+
'',
|
|
46
|
+
'# Acceptance criteria',
|
|
47
|
+
'',
|
|
48
|
+
'- [ ] `timeout 10 node -e "process.exit(0)"` passes.',
|
|
49
|
+
'',
|
|
50
|
+
'# Out of scope',
|
|
51
|
+
'',
|
|
52
|
+
'- N/A',
|
|
53
|
+
].join('\n');
|
|
54
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), body);
|
|
55
|
+
return { slug, cwd, status: 'completed', runId: '2026-06-14T00-00-00-000Z' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Count DoD report files under runsDir (shallow scan). */
|
|
59
|
+
function countReports(runsDir) {
|
|
60
|
+
let count = 0;
|
|
61
|
+
try {
|
|
62
|
+
for (const entry of fs.readdirSync(runsDir, { withFileTypes: true })) {
|
|
63
|
+
if (!entry.isDirectory()) continue;
|
|
64
|
+
const dir = path.join(runsDir, entry.name);
|
|
65
|
+
for (const f of fs.readdirSync(dir)) {
|
|
66
|
+
if (f.startsWith('definition-of-done-')) count++;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch { /* runsDir may not exist */ }
|
|
70
|
+
return count;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── tests ────────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
test('drain with completed jobs writes exactly one report', async () => {
|
|
76
|
+
const tmpDir = makeTmpDir();
|
|
77
|
+
const prdsDir = path.join(tmpDir, 'prds');
|
|
78
|
+
const runsDir = path.join(tmpDir, 'runs');
|
|
79
|
+
const cwd = path.join(tmpDir, 'project');
|
|
80
|
+
fs.mkdirSync(prdsDir);
|
|
81
|
+
fs.mkdirSync(cwd);
|
|
82
|
+
|
|
83
|
+
const job = writePrd(prdsDir, '101-test', cwd);
|
|
84
|
+
const state = { paused: null, jobs: [job] };
|
|
85
|
+
const cancelToken = { cancelled: false };
|
|
86
|
+
const savedDisable = process.env.SM_DOD_DISABLE;
|
|
87
|
+
delete process.env.SM_DOD_DISABLE;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
await runDefinitionOfDoneOnDrain(state, { cancelToken, prdsDir, runsDir });
|
|
91
|
+
assert.strictEqual(countReports(runsDir), 1);
|
|
92
|
+
} finally {
|
|
93
|
+
if (savedDisable !== undefined) process.env.SM_DOD_DISABLE = savedDisable;
|
|
94
|
+
rmdir(tmpDir);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('second drain over same completed set is a no-op (idempotent)', async () => {
|
|
99
|
+
const tmpDir = makeTmpDir();
|
|
100
|
+
const prdsDir = path.join(tmpDir, 'prds');
|
|
101
|
+
const runsDir = path.join(tmpDir, 'runs');
|
|
102
|
+
const cwd = path.join(tmpDir, 'project');
|
|
103
|
+
fs.mkdirSync(prdsDir);
|
|
104
|
+
fs.mkdirSync(cwd);
|
|
105
|
+
|
|
106
|
+
const job = writePrd(prdsDir, '102-test', cwd);
|
|
107
|
+
const state = { paused: null, jobs: [job] };
|
|
108
|
+
const cancelToken = { cancelled: false };
|
|
109
|
+
const savedDisable = process.env.SM_DOD_DISABLE;
|
|
110
|
+
delete process.env.SM_DOD_DISABLE;
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
await runDefinitionOfDoneOnDrain(state, { cancelToken, prdsDir, runsDir });
|
|
114
|
+
const afterFirst = countReports(runsDir);
|
|
115
|
+
assert.strictEqual(afterFirst, 1, 'first call must write one report');
|
|
116
|
+
|
|
117
|
+
await runDefinitionOfDoneOnDrain(state, { cancelToken, prdsDir, runsDir });
|
|
118
|
+
const afterSecond = countReports(runsDir);
|
|
119
|
+
assert.strictEqual(afterSecond, 1, 'second call with same jobs must be no-op');
|
|
120
|
+
} finally {
|
|
121
|
+
if (savedDisable !== undefined) process.env.SM_DOD_DISABLE = savedDisable;
|
|
122
|
+
rmdir(tmpDir);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('SM_DOD_DISABLE=1 → no report written', async () => {
|
|
127
|
+
const tmpDir = makeTmpDir();
|
|
128
|
+
const prdsDir = path.join(tmpDir, 'prds');
|
|
129
|
+
const runsDir = path.join(tmpDir, 'runs');
|
|
130
|
+
const cwd = path.join(tmpDir, 'project');
|
|
131
|
+
fs.mkdirSync(prdsDir);
|
|
132
|
+
fs.mkdirSync(cwd);
|
|
133
|
+
|
|
134
|
+
writePrd(prdsDir, '103-test', cwd);
|
|
135
|
+
const job = { slug: '103-test', cwd, status: 'completed', runId: '2026-06-14T00-00-00-001Z' };
|
|
136
|
+
const state = { paused: null, jobs: [job] };
|
|
137
|
+
const cancelToken = { cancelled: false };
|
|
138
|
+
const savedDisable = process.env.SM_DOD_DISABLE;
|
|
139
|
+
process.env.SM_DOD_DISABLE = '1';
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
await runDefinitionOfDoneOnDrain(state, { cancelToken, prdsDir, runsDir });
|
|
143
|
+
assert.strictEqual(countReports(runsDir), 0, 'SM_DOD_DISABLE=1 must suppress the report');
|
|
144
|
+
} finally {
|
|
145
|
+
if (savedDisable !== undefined) process.env.SM_DOD_DISABLE = savedDisable;
|
|
146
|
+
else delete process.env.SM_DOD_DISABLE;
|
|
147
|
+
rmdir(tmpDir);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('paused state → no report written', async () => {
|
|
152
|
+
const tmpDir = makeTmpDir();
|
|
153
|
+
const prdsDir = path.join(tmpDir, 'prds');
|
|
154
|
+
const runsDir = path.join(tmpDir, 'runs');
|
|
155
|
+
const cwd = path.join(tmpDir, 'project');
|
|
156
|
+
fs.mkdirSync(prdsDir);
|
|
157
|
+
fs.mkdirSync(cwd);
|
|
158
|
+
|
|
159
|
+
writePrd(prdsDir, '104-test', cwd);
|
|
160
|
+
const job = { slug: '104-test', cwd, status: 'completed', runId: '2026-06-14T00-00-00-002Z' };
|
|
161
|
+
const state = { paused: { reason: 'rate_limit', since: new Date().toISOString(), resumeAt: null }, jobs: [job] };
|
|
162
|
+
const cancelToken = { cancelled: false };
|
|
163
|
+
const savedDisable = process.env.SM_DOD_DISABLE;
|
|
164
|
+
delete process.env.SM_DOD_DISABLE;
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
await runDefinitionOfDoneOnDrain(state, { cancelToken, prdsDir, runsDir });
|
|
168
|
+
assert.strictEqual(countReports(runsDir), 0, 'paused state must suppress the report');
|
|
169
|
+
} finally {
|
|
170
|
+
if (savedDisable !== undefined) process.env.SM_DOD_DISABLE = savedDisable;
|
|
171
|
+
rmdir(tmpDir);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('cancelToken.cancelled=true → no report written', async () => {
|
|
176
|
+
const tmpDir = makeTmpDir();
|
|
177
|
+
const prdsDir = path.join(tmpDir, 'prds');
|
|
178
|
+
const runsDir = path.join(tmpDir, 'runs');
|
|
179
|
+
const cwd = path.join(tmpDir, 'project');
|
|
180
|
+
fs.mkdirSync(prdsDir);
|
|
181
|
+
fs.mkdirSync(cwd);
|
|
182
|
+
|
|
183
|
+
writePrd(prdsDir, '105-test', cwd);
|
|
184
|
+
const job = { slug: '105-test', cwd, status: 'completed', runId: '2026-06-14T00-00-00-003Z' };
|
|
185
|
+
const state = { paused: null, jobs: [job] };
|
|
186
|
+
const cancelToken = { cancelled: true };
|
|
187
|
+
const savedDisable = process.env.SM_DOD_DISABLE;
|
|
188
|
+
delete process.env.SM_DOD_DISABLE;
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
await runDefinitionOfDoneOnDrain(state, { cancelToken, prdsDir, runsDir });
|
|
192
|
+
assert.strictEqual(countReports(runsDir), 0, 'cancelled token must suppress the report');
|
|
193
|
+
} finally {
|
|
194
|
+
if (savedDisable !== undefined) process.env.SM_DOD_DISABLE = savedDisable;
|
|
195
|
+
rmdir(tmpDir);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dod-report.test.cjs — unit tests for flagRiskySurfaces + writeReport.
|
|
3
|
+
*
|
|
4
|
+
* Run: timeout 120 node --test src/main/__tests__/dod-report.test.cjs
|
|
5
|
+
*
|
|
6
|
+
* Fixtures: os.tmpdir() only — never writes into real runs/.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const { test } = require('node:test');
|
|
12
|
+
const assert = require('node:assert/strict');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const { flagRiskySurfaces, writeReport } = require('../lib/definitionOfDone.cjs');
|
|
17
|
+
|
|
18
|
+
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function makeTmpDir() {
|
|
21
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'dod-report-test-'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function rmdir(dir) {
|
|
25
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* */ }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writePrd(prdsDir, slug, implNotes, cwd) {
|
|
29
|
+
const body = [
|
|
30
|
+
'---',
|
|
31
|
+
`title: ${slug}`,
|
|
32
|
+
`cwd: ${cwd}`,
|
|
33
|
+
'estimateMinutes: 5',
|
|
34
|
+
'---',
|
|
35
|
+
'',
|
|
36
|
+
'# Goal',
|
|
37
|
+
'',
|
|
38
|
+
'Test fixture.',
|
|
39
|
+
'',
|
|
40
|
+
'# Acceptance criteria',
|
|
41
|
+
'',
|
|
42
|
+
'- [ ] `timeout 10 node -e "process.exit(0)"` passes.',
|
|
43
|
+
'',
|
|
44
|
+
'# Implementation notes',
|
|
45
|
+
'',
|
|
46
|
+
implNotes,
|
|
47
|
+
'',
|
|
48
|
+
'# Out of scope',
|
|
49
|
+
'',
|
|
50
|
+
'- N/A',
|
|
51
|
+
].join('\n');
|
|
52
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), body);
|
|
53
|
+
return { slug, cwd };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── flagRiskySurfaces: money-path ────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
test('flagRiskySurfaces: flags money-path when impl notes reference payment', () => {
|
|
59
|
+
const tmpDir = makeTmpDir();
|
|
60
|
+
const prdsDir = path.join(tmpDir, 'prds');
|
|
61
|
+
const cwd = path.join(tmpDir, 'project');
|
|
62
|
+
fs.mkdirSync(prdsDir);
|
|
63
|
+
fs.mkdirSync(cwd);
|
|
64
|
+
const job = writePrd(prdsDir, '201-billing', '- Edit `src/payment-processor.js`\n- Update order schema', cwd);
|
|
65
|
+
try {
|
|
66
|
+
const flags = flagRiskySurfaces([job], { prdsDir });
|
|
67
|
+
assert.strictEqual(flags.length, 1);
|
|
68
|
+
assert.strictEqual(flags[0].slug, '201-billing');
|
|
69
|
+
assert.ok(flags[0].surfaces.includes('money-path'), `expected money-path in ${JSON.stringify(flags[0].surfaces)}`);
|
|
70
|
+
} finally {
|
|
71
|
+
rmdir(tmpDir);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('flagRiskySurfaces: flags auth when impl notes reference auth/token', () => {
|
|
76
|
+
const tmpDir = makeTmpDir();
|
|
77
|
+
const prdsDir = path.join(tmpDir, 'prds');
|
|
78
|
+
const cwd = path.join(tmpDir, 'project');
|
|
79
|
+
fs.mkdirSync(prdsDir);
|
|
80
|
+
fs.mkdirSync(cwd);
|
|
81
|
+
const job = writePrd(prdsDir, '202-auth', '- Modify `src/auth-middleware.py`\n- Update token refresh logic', cwd);
|
|
82
|
+
try {
|
|
83
|
+
const flags = flagRiskySurfaces([job], { prdsDir });
|
|
84
|
+
assert.strictEqual(flags.length, 1);
|
|
85
|
+
assert.ok(flags[0].surfaces.includes('auth'));
|
|
86
|
+
} finally {
|
|
87
|
+
rmdir(tmpDir);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('flagRiskySurfaces: flags migration when impl notes reference .sql file', () => {
|
|
92
|
+
const tmpDir = makeTmpDir();
|
|
93
|
+
const prdsDir = path.join(tmpDir, 'prds');
|
|
94
|
+
const cwd = path.join(tmpDir, 'project');
|
|
95
|
+
fs.mkdirSync(prdsDir);
|
|
96
|
+
fs.mkdirSync(cwd);
|
|
97
|
+
const job = writePrd(prdsDir, '203-schema', '- Run `db/migrations/0042_users.sql`', cwd);
|
|
98
|
+
try {
|
|
99
|
+
const flags = flagRiskySurfaces([job], { prdsDir });
|
|
100
|
+
assert.strictEqual(flags.length, 1);
|
|
101
|
+
assert.ok(flags[0].surfaces.includes('migration'));
|
|
102
|
+
} finally {
|
|
103
|
+
rmdir(tmpDir);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('flagRiskySurfaces: clean job is not returned', () => {
|
|
108
|
+
const tmpDir = makeTmpDir();
|
|
109
|
+
const prdsDir = path.join(tmpDir, 'prds');
|
|
110
|
+
const cwd = path.join(tmpDir, 'project');
|
|
111
|
+
fs.mkdirSync(prdsDir);
|
|
112
|
+
fs.mkdirSync(cwd);
|
|
113
|
+
const job = writePrd(prdsDir, '204-clean', '- Update `src/ui/button.tsx`\n- Add tooltip component', cwd);
|
|
114
|
+
try {
|
|
115
|
+
const flags = flagRiskySurfaces([job], { prdsDir });
|
|
116
|
+
assert.strictEqual(flags.length, 0);
|
|
117
|
+
} finally {
|
|
118
|
+
rmdir(tmpDir);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('flagRiskySurfaces: job with no PRD file is skipped (not thrown)', () => {
|
|
123
|
+
const tmpDir = makeTmpDir();
|
|
124
|
+
const prdsDir = path.join(tmpDir, 'prds');
|
|
125
|
+
const cwd = path.join(tmpDir, 'project');
|
|
126
|
+
fs.mkdirSync(prdsDir);
|
|
127
|
+
fs.mkdirSync(cwd);
|
|
128
|
+
try {
|
|
129
|
+
const flags = flagRiskySurfaces([{ slug: '205-missing', cwd }], { prdsDir });
|
|
130
|
+
assert.strictEqual(flags.length, 0);
|
|
131
|
+
} finally {
|
|
132
|
+
rmdir(tmpDir);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('flagRiskySurfaces: mixed batch returns only risky jobs', () => {
|
|
137
|
+
const tmpDir = makeTmpDir();
|
|
138
|
+
const prdsDir = path.join(tmpDir, 'prds');
|
|
139
|
+
const cwd = path.join(tmpDir, 'project');
|
|
140
|
+
fs.mkdirSync(prdsDir);
|
|
141
|
+
fs.mkdirSync(cwd);
|
|
142
|
+
const jobRisky = writePrd(prdsDir, '206-risky', '- Modify `src/trade-engine.js`', cwd);
|
|
143
|
+
const jobClean = writePrd(prdsDir, '207-clean', '- Update `src/tooltip.tsx`', cwd);
|
|
144
|
+
try {
|
|
145
|
+
const flags = flagRiskySurfaces([jobRisky, jobClean], { prdsDir });
|
|
146
|
+
assert.strictEqual(flags.length, 1);
|
|
147
|
+
assert.strictEqual(flags[0].slug, '206-risky');
|
|
148
|
+
} finally {
|
|
149
|
+
rmdir(tmpDir);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ─── writeReport: structure ────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
test('writeReport: all three jobs appear in AC table', () => {
|
|
156
|
+
const tmpDir = makeTmpDir();
|
|
157
|
+
const runsDir = path.join(tmpDir, 'runs');
|
|
158
|
+
const acResults = [
|
|
159
|
+
{ slug: '101-pass', status: 'pass', code: 0, ms: 500 },
|
|
160
|
+
{ slug: '102-fail', status: 'fail', code: 1, ms: 300 },
|
|
161
|
+
{ slug: '103-risky', status: 'pass', code: 0, ms: 400 },
|
|
162
|
+
];
|
|
163
|
+
const riskFlags = [{ slug: '103-risky', surfaces: ['money-path'] }];
|
|
164
|
+
try {
|
|
165
|
+
const reportPath = writeReport('abcd1234', { acResults, riskFlags, runsDir });
|
|
166
|
+
assert.ok(fs.existsSync(reportPath), `report missing at ${reportPath}`);
|
|
167
|
+
const content = fs.readFileSync(reportPath, 'utf8');
|
|
168
|
+
assert.ok(content.includes('101-pass'), 'missing 101-pass in table');
|
|
169
|
+
assert.ok(content.includes('102-fail'), 'missing 102-fail in table');
|
|
170
|
+
assert.ok(content.includes('103-risky'), 'missing 103-risky in table');
|
|
171
|
+
} finally {
|
|
172
|
+
rmdir(tmpDir);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('writeReport: fail and risky slug both appear in needs-attention list', () => {
|
|
177
|
+
const tmpDir = makeTmpDir();
|
|
178
|
+
const runsDir = path.join(tmpDir, 'runs');
|
|
179
|
+
const acResults = [
|
|
180
|
+
{ slug: '101-pass', status: 'pass', code: 0, ms: 500 },
|
|
181
|
+
{ slug: '102-fail', status: 'fail', code: 1, ms: 300 },
|
|
182
|
+
{ slug: '103-risky', status: 'pass', code: 0, ms: 400 },
|
|
183
|
+
];
|
|
184
|
+
const riskFlags = [{ slug: '103-risky', surfaces: ['money-path'] }];
|
|
185
|
+
try {
|
|
186
|
+
const reportPath = writeReport('abcd1234', { acResults, riskFlags, runsDir });
|
|
187
|
+
const content = fs.readFileSync(reportPath, 'utf8');
|
|
188
|
+
// Both should appear in the Needs Human Attention section
|
|
189
|
+
const attentionIdx = content.indexOf('## Needs Human Attention');
|
|
190
|
+
assert.ok(attentionIdx !== -1, 'missing Needs Human Attention section');
|
|
191
|
+
const attentionSection = content.slice(attentionIdx);
|
|
192
|
+
assert.ok(attentionSection.includes('102-fail'), '102-fail missing from attention list');
|
|
193
|
+
assert.ok(attentionSection.includes('103-risky'), '103-risky missing from attention list');
|
|
194
|
+
// Clean pass should NOT be in attention list
|
|
195
|
+
const passInAttention = attentionSection.includes('101-pass');
|
|
196
|
+
assert.ok(!passInAttention, '101-pass should not appear in attention list');
|
|
197
|
+
} finally {
|
|
198
|
+
rmdir(tmpDir);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('writeReport: report states review is recommended, not auto-run', () => {
|
|
203
|
+
const tmpDir = makeTmpDir();
|
|
204
|
+
const runsDir = path.join(tmpDir, 'runs');
|
|
205
|
+
try {
|
|
206
|
+
const reportPath = writeReport('abcd1234', { acResults: [], riskFlags: [], runsDir });
|
|
207
|
+
const content = fs.readFileSync(reportPath, 'utf8');
|
|
208
|
+
assert.ok(
|
|
209
|
+
content.toLowerCase().includes('recommended') && content.toLowerCase().includes('not auto-run'),
|
|
210
|
+
'report must state review is recommended, not auto-run'
|
|
211
|
+
);
|
|
212
|
+
} finally {
|
|
213
|
+
rmdir(tmpDir);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('writeReport: no .tmp files left after write', () => {
|
|
218
|
+
const tmpDir = makeTmpDir();
|
|
219
|
+
const runsDir = path.join(tmpDir, 'runs');
|
|
220
|
+
const acResults = [{ slug: '101-pass', status: 'pass', code: 0, ms: 100 }];
|
|
221
|
+
try {
|
|
222
|
+
writeReport('deadbeef', { acResults, riskFlags: [], runsDir });
|
|
223
|
+
// Walk all files in runsDir recursively, check none end in .tmp-*
|
|
224
|
+
function findTmps(dir) {
|
|
225
|
+
const hits = [];
|
|
226
|
+
try {
|
|
227
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
228
|
+
const full = path.join(dir, e.name);
|
|
229
|
+
if (e.isDirectory()) hits.push(...findTmps(full));
|
|
230
|
+
else if (e.name.includes('.tmp-')) hits.push(full);
|
|
231
|
+
}
|
|
232
|
+
} catch { /* */ }
|
|
233
|
+
return hits;
|
|
234
|
+
}
|
|
235
|
+
const tmps = findTmps(runsDir);
|
|
236
|
+
assert.deepStrictEqual(tmps, [], `found leftover tmp files: ${tmps.join(', ')}`);
|
|
237
|
+
} finally {
|
|
238
|
+
rmdir(tmpDir);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('writeReport: second call with same batchKey produces clean report, no tmp files', () => {
|
|
243
|
+
const tmpDir = makeTmpDir();
|
|
244
|
+
const runsDir = path.join(tmpDir, 'runs');
|
|
245
|
+
const acResults = [
|
|
246
|
+
{ slug: '101-pass', status: 'pass', code: 0, ms: 100 },
|
|
247
|
+
{ slug: '102-fail', status: 'fail', code: 1, ms: 200 },
|
|
248
|
+
];
|
|
249
|
+
const riskFlags = [{ slug: '101-pass', surfaces: ['auth'] }];
|
|
250
|
+
try {
|
|
251
|
+
writeReport('cafebabe', { acResults, riskFlags, runsDir });
|
|
252
|
+
// Small delay to guarantee different timestamp dir
|
|
253
|
+
const t = Date.now() + 2;
|
|
254
|
+
while (Date.now() < t) { /* busy wait 2ms */ }
|
|
255
|
+
const report2 = writeReport('cafebabe', { acResults, riskFlags, runsDir });
|
|
256
|
+
|
|
257
|
+
// Second report must exist and be valid
|
|
258
|
+
assert.ok(fs.existsSync(report2));
|
|
259
|
+
const content = fs.readFileSync(report2, 'utf8');
|
|
260
|
+
|
|
261
|
+
// No duplicate rows in the AC table — slice only the AC table section.
|
|
262
|
+
const acStart = content.indexOf('## AC Results');
|
|
263
|
+
const riskStart = content.indexOf('## Risk Flags');
|
|
264
|
+
const acSection = content.slice(acStart, riskStart === -1 ? undefined : riskStart);
|
|
265
|
+
const passCount = (acSection.match(/101-pass/g) || []).length;
|
|
266
|
+
const failCount = (acSection.match(/102-fail/g) || []).length;
|
|
267
|
+
assert.strictEqual(passCount, 1, `101-pass appears ${passCount} times in AC table`);
|
|
268
|
+
assert.strictEqual(failCount, 1, `102-fail appears ${failCount} times in AC table`);
|
|
269
|
+
|
|
270
|
+
// No tmp files anywhere
|
|
271
|
+
function findTmps(dir) {
|
|
272
|
+
const hits = [];
|
|
273
|
+
try {
|
|
274
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
275
|
+
const full = path.join(dir, e.name);
|
|
276
|
+
if (e.isDirectory()) hits.push(...findTmps(full));
|
|
277
|
+
else if (e.name.includes('.tmp-')) hits.push(full);
|
|
278
|
+
}
|
|
279
|
+
} catch { /* */ }
|
|
280
|
+
return hits;
|
|
281
|
+
}
|
|
282
|
+
assert.deepStrictEqual(findTmps(runsDir), []);
|
|
283
|
+
} finally {
|
|
284
|
+
rmdir(tmpDir);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('writeReport: report filename contains the batchKey', () => {
|
|
289
|
+
const tmpDir = makeTmpDir();
|
|
290
|
+
const runsDir = path.join(tmpDir, 'runs');
|
|
291
|
+
try {
|
|
292
|
+
const reportPath = writeReport('feed1234', { acResults: [], riskFlags: [], runsDir });
|
|
293
|
+
assert.ok(path.basename(reportPath).includes('feed1234'));
|
|
294
|
+
} finally {
|
|
295
|
+
rmdir(tmpDir);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('writeReport: invalid batchKey throws', () => {
|
|
300
|
+
assert.throws(
|
|
301
|
+
() => writeReport('not-hex!!', { acResults: [], riskFlags: [] }),
|
|
302
|
+
/invalid batchKey/
|
|
303
|
+
);
|
|
304
|
+
});
|