claude-code-session-manager 0.76.0 → 0.77.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/{AgentLibrary-CBx9l4zN.js → AgentLibrary-B2ie8bbw.js} +2 -2
- package/dist/assets/{DataModel-Bf0EIE_t.js → DataModel-BIJPYw32.js} +1 -1
- package/dist/assets/{History-CpdtWhC8.js → History-CeY6dk9S.js} +2 -2
- package/dist/assets/{Hooks-DyUbMDmg.js → Hooks-BFH2ocKg.js} +2 -2
- package/dist/assets/{HostBilko-By-wIpry.js → HostBilko-36gj9wLz.js} +1 -1
- package/dist/assets/{Library-CQmo4QVC.js → Library-C-hBct39.js} +1 -1
- package/dist/assets/{ListDetail-BQMd6NOm.js → ListDetail-CNq64VWV.js} +1 -1
- package/dist/assets/{MarkdownEditor-DEp43FXX.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
- package/dist/assets/{McpServers-CLarzwqA.js → McpServers-DpGN0oyz.js} +1 -1
- package/dist/assets/{Memory-B0sCdIy1.js → Memory-D59hUjC4.js} +6 -6
- package/dist/assets/{Panel-BhWPVOCD.js → Panel-DCgbaoci.js} +1 -1
- package/dist/assets/{Permissions-Ddlq8T_O.js → Permissions-DAmQ0DYV.js} +2 -2
- package/dist/assets/{Plugins-D2oA_2Jl.js → Plugins-Dyfgn6Is.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DgAgavUM.js → ProvenanceBadge-BiYhPO1U.js} +1 -1
- package/dist/assets/SaveBar-RV7B6sOh.js +1 -0
- package/dist/assets/Scheduler-BPaNqx1b.js +14 -0
- package/dist/assets/{ScopeSwitcher-C_zWEtIl.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
- package/dist/assets/{Settings-2Vx3X5SI.js → Settings-BL4vf5aX.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-BDEUjlTQ.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
- package/dist/assets/{Skills-Cmrz_LeN.js → Skills-BV08gDUH.js} +2 -2
- package/dist/assets/{SystemPrompt-DVA1eYDP.js → SystemPrompt-CLftSsDw.js} +1 -1
- package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
- package/dist/assets/{TiptapBody-DmPc3amD.js → TiptapBody-jCpuB6E5.js} +1 -1
- package/dist/assets/{Toggle-zfd5LJkK.js → Toggle-D2paA1xf.js} +1 -1
- package/dist/assets/{index-B_4PNh9T.js → index-BDRSqBl3.js} +175 -175
- package/dist/assets/{index-DIjnPkRN.css → index-CYhdtisq.css} +1 -1
- package/dist/assets/{settingsSchema-B9es6fdA.js → settingsSchema-6IOLjZZN.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -2
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
- package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
- package/scripts/render-project-pages.cjs +70 -0
- package/scripts/scheduler-mcp-server.cjs +115 -1
- package/scripts/validate-project-pages-summary.cjs +62 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/prdAgentType.test.cjs +103 -0
- package/src/main/__tests__/prdCreate.test.cjs +138 -0
- package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
- package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
- package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
- package/src/main/__tests__/projectPages.test.cjs +73 -1
- package/src/main/__tests__/rcaReport.test.cjs +54 -0
- package/src/main/__tests__/runVerify.test.cjs +94 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +43 -0
- package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
- package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
- package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
- package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
- package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
- package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +147 -0
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
- package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
- package/src/main/config.cjs +4 -1
- package/src/main/index.cjs +8 -1
- package/src/main/ipcSchemas.cjs +51 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +78 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +152 -2
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +4 -2
- package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +277 -4
- package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +5 -3
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
- package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +1 -0
- package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
- package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
- package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
- package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +22 -1
- package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
- package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
- package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
- package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
- package/src/main/lib/agentModelResolve.cjs +58 -0
- package/src/main/lib/childWithLog.cjs +40 -5
- package/src/main/lib/claudeBin.cjs +54 -1
- package/src/main/lib/definitionOfDone.cjs +3 -2
- package/src/main/lib/delegationReadiness.cjs +115 -9
- package/src/main/lib/epicWorktreeMint.cjs +5 -2
- package/src/main/lib/fixChainDepth.cjs +45 -0
- package/src/main/lib/gitWorktree.cjs +464 -19
- package/src/main/lib/jobWorktree.cjs +1 -0
- package/src/main/lib/landedSinceRun.cjs +55 -0
- package/src/main/lib/launchFailure.cjs +357 -0
- package/src/main/lib/mcpToolCatalog.cjs +87 -2
- package/src/main/lib/opsOwnership.cjs +12 -0
- package/src/main/lib/prdAgentType.cjs +84 -0
- package/src/main/lib/prdCreate.cjs +57 -1
- package/src/main/lib/prdDeclaredPaths.cjs +70 -0
- package/src/main/lib/prdFrontmatter.cjs +17 -3
- package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
- package/src/main/lib/projectPageSummarySchema.cjs +181 -0
- package/src/main/lib/queueHealth.cjs +38 -0
- package/src/main/lib/queueStore.cjs +9 -2
- package/src/main/lib/quietMachineLease.cjs +48 -0
- package/src/main/lib/rcaReport.cjs +53 -3
- package/src/main/lib/reaperHelpers.cjs +18 -1
- package/src/main/lib/scheduleJobSchema.cjs +31 -0
- package/src/main/lib/scheduleJobTransitions.cjs +6 -2
- package/src/main/lib/schedulerBatch.cjs +133 -29
- package/src/main/lib/schedulerConfig.cjs +19 -0
- package/src/main/projectPages.cjs +160 -2
- package/src/main/runVerify.cjs +50 -9
- package/src/main/scheduler/prdParser.cjs +18 -1
- package/src/main/scheduler.cjs +1371 -97
- package/src/main/seedAgentPersonas.cjs +62 -21
- package/src/main/templates/project-pages-catalog.json +741 -0
- package/src/main/templates/project-pages-pipeline.md +417 -0
- package/src/preload/api.d.ts +118 -2
- package/src/preload/index.cjs +7 -0
- package/src/seed/agents/project-home-builder.md +59 -0
- package/dist/assets/SaveBar-Qvc4Ek-H.js +0 -1
- package/dist/assets/Scheduler-BmYJvNzK.js +0 -14
- package/dist/assets/TagLibrary-DYJGAKZu.js +0 -1
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* projectHomeAdminRoutes.test.cjs — unit tests for
|
|
3
|
+
* lib/projectHomeAdminRoutes.cjs, the app-side half of "the generating
|
|
4
|
+
* session needs zero repo knowledge to build a Project Home page" (PRD:
|
|
5
|
+
* project-home-admin-routes).
|
|
6
|
+
*
|
|
7
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/projectHomeAdminRoutes.test.cjs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
import { test, expect, afterEach, vi } from 'vitest';
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const fsp = require('node:fs/promises');
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
const config = require('../config.cjs');
|
|
18
|
+
const { registerAdminRoute } = require('../lib/projectHomeAdminRoutes.cjs');
|
|
19
|
+
|
|
20
|
+
const tmpDirs = [];
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
while (tmpDirs.length) {
|
|
23
|
+
const d = tmpDirs.pop();
|
|
24
|
+
await fsp.rm(d, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
async function mkProjectCwd() {
|
|
29
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-project-home-admin-'));
|
|
30
|
+
config.addAllowedRoot(dir);
|
|
31
|
+
fs.mkdirSync(path.join(dir, 'session-manager-operations'), { recursive: true });
|
|
32
|
+
tmpDirs.push(dir);
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Fake adminHttp stub — same pattern prdAdminRouteParity.test.cjs uses:
|
|
37
|
+
* captures registered routes and lets a test invoke one directly with a
|
|
38
|
+
* fake req/res/query, no real HTTP server or Electron boot needed. */
|
|
39
|
+
function makeFakeAdminHttp() {
|
|
40
|
+
const routes = new Map();
|
|
41
|
+
return {
|
|
42
|
+
registerRoute(method, url, handler) {
|
|
43
|
+
routes.set(`${method} ${url}`, handler);
|
|
44
|
+
},
|
|
45
|
+
async call(method, url, { query = new URLSearchParams(), body } = {}) {
|
|
46
|
+
const handler = routes.get(`${method} ${url}`);
|
|
47
|
+
if (!handler) throw new Error(`no route registered for ${method} ${url}`);
|
|
48
|
+
const chunks = body !== undefined ? [Buffer.from(JSON.stringify(body))] : [];
|
|
49
|
+
const req = {
|
|
50
|
+
on(event, cb) {
|
|
51
|
+
if (event === 'data') chunks.forEach((c) => cb(c));
|
|
52
|
+
if (event === 'end') cb();
|
|
53
|
+
return req;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
let status = null;
|
|
57
|
+
let payload = null;
|
|
58
|
+
const res = {
|
|
59
|
+
writeHead(s) { status = s; },
|
|
60
|
+
end(b) { payload = b ? JSON.parse(b) : null; },
|
|
61
|
+
};
|
|
62
|
+
await handler(req, res, query);
|
|
63
|
+
return { status, body: payload };
|
|
64
|
+
},
|
|
65
|
+
routes,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validSummary(overrides = {}) {
|
|
70
|
+
return {
|
|
71
|
+
identity: {
|
|
72
|
+
name: 'Demo', tag: 'demo', version: '1.0.0', oneLine: 'A demo project.',
|
|
73
|
+
claim: 'Ship faster.', sub: 'Subtitle text.', audience: 'Developers', install: 'npm i demo',
|
|
74
|
+
},
|
|
75
|
+
stats: [{ v: '5', k: 'years', n: 'since launch' }],
|
|
76
|
+
pillars: [{ t: 'Fast', d: 'It is fast.', k: 'speed' }],
|
|
77
|
+
quotes: [],
|
|
78
|
+
feature: {
|
|
79
|
+
name: 'Feature', kicker: 'New', status: 'shipped', owner: 'team', oneLine: 'One line.',
|
|
80
|
+
problem: 'Problem text.', solution: 'Solution text.',
|
|
81
|
+
steps: [{ t: 'Step', d: 'desc' }], rules: [{ t: 'Rule', d: 'desc' }],
|
|
82
|
+
specs: [['label', 'value', 'note']], faq: [{ q: 'Q', a: 'A' }],
|
|
83
|
+
timeline: [{ w: 'W1', t: 'Thing', s: 'done' }],
|
|
84
|
+
},
|
|
85
|
+
architecture: {
|
|
86
|
+
summary: 'Arch summary.',
|
|
87
|
+
principles: [{ t: 'P', d: 'd' }],
|
|
88
|
+
layers: [{ n: 'Layer', d: 'd', f: '5', tone: 'accent' }],
|
|
89
|
+
modules: [{ n: 'Mod', d: 'd', f: 3, dep: [], heat: 1 }],
|
|
90
|
+
flow: [{ a: 'A', b: 'B', t: 't', n: 'n' }],
|
|
91
|
+
decisions: [{ id: 'd1', t: 't', w: 'w', s: 'accepted' }],
|
|
92
|
+
risks: [],
|
|
93
|
+
},
|
|
94
|
+
...overrides,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function picksFromCatalog(catalog) {
|
|
99
|
+
const picks = {};
|
|
100
|
+
for (const lens of catalog.lenses) {
|
|
101
|
+
picks[lens.id] = {};
|
|
102
|
+
for (const slot of lens.slots) {
|
|
103
|
+
picks[lens.id][slot.id] = slot.variants[0].id;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return picks;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── Route registration ────────────────────────────────────────────────
|
|
110
|
+
test('registers all four project-home admin routes', () => {
|
|
111
|
+
const fake = makeFakeAdminHttp();
|
|
112
|
+
registerAdminRoute(fake);
|
|
113
|
+
expect(fake.routes.has('GET /admin/project-home/contract')).toBe(true);
|
|
114
|
+
expect(fake.routes.has('POST /admin/project-home/validate-summary')).toBe(true);
|
|
115
|
+
expect(fake.routes.has('POST /admin/project-home/render')).toBe(true);
|
|
116
|
+
expect(fake.routes.has('GET /admin/project-home/status')).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ─── contract ──────────────────────────────────────────────────────────
|
|
120
|
+
test('contract: self-sufficient — 5 lenses, non-empty slots, absolute paths, no repo-relative path strings', async () => {
|
|
121
|
+
const cwd = await mkProjectCwd();
|
|
122
|
+
const fake = makeFakeAdminHttp();
|
|
123
|
+
registerAdminRoute(fake);
|
|
124
|
+
|
|
125
|
+
const { status, body } = await fake.call('GET', '/admin/project-home/contract', {
|
|
126
|
+
query: new URLSearchParams({ cwd }),
|
|
127
|
+
});
|
|
128
|
+
expect(status).toBe(200);
|
|
129
|
+
expect(body.ok).toBe(true);
|
|
130
|
+
expect(Array.isArray(body.protocol)).toBe(true);
|
|
131
|
+
expect(body.protocol.length).toBeGreaterThan(0);
|
|
132
|
+
|
|
133
|
+
expect(body.catalog.lenses.map((l) => l.id).sort()).toEqual(
|
|
134
|
+
['architecture', 'brief', 'feature', 'home', 'marketing'].sort(),
|
|
135
|
+
);
|
|
136
|
+
for (const lens of body.catalog.lenses) {
|
|
137
|
+
expect(Array.isArray(lens.slots)).toBe(true);
|
|
138
|
+
expect(lens.slots.length).toBeGreaterThan(0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
expect(path.isAbsolute(body.paths.summaryPath)).toBe(true);
|
|
142
|
+
expect(path.isAbsolute(body.paths.picksPath)).toBe(true);
|
|
143
|
+
expect(path.isAbsolute(body.paths.outputDir)).toBe(true);
|
|
144
|
+
expect(body.paths.summaryPath).toContain(cwd);
|
|
145
|
+
|
|
146
|
+
expect(body.summarySchema).toBeTruthy();
|
|
147
|
+
expect(body.summarySchema.properties.identity).toBeTruthy();
|
|
148
|
+
|
|
149
|
+
// The operational fields (catalog/paths/protocol) must never point the
|
|
150
|
+
// caller at a repo-relative path — the whole point of this route. The
|
|
151
|
+
// free-form "spec.text" field is the architecture doc's own prose, which
|
|
152
|
+
// legitimately documents source file locations for background reading;
|
|
153
|
+
// it is explicitly excluded from this check (see route file's loadCatalog
|
|
154
|
+
// comment for why the catalog's own $comment is stripped for the same
|
|
155
|
+
// reason).
|
|
156
|
+
const operationalFields = JSON.stringify({ protocol: body.protocol, catalog: body.catalog, paths: body.paths });
|
|
157
|
+
expect(operationalFields).not.toMatch(/src\/renderer|src\/main|scripts\//);
|
|
158
|
+
// PRD 1093's exact banned-substring list (the class of path that broke
|
|
159
|
+
// generation on a foreign machine before this route existed).
|
|
160
|
+
for (const banned of [
|
|
161
|
+
'session-manager-operations/architecture/',
|
|
162
|
+
'session-manager-operations/design-mocks/',
|
|
163
|
+
'.claude/agents/',
|
|
164
|
+
'src/renderer/',
|
|
165
|
+
'npm run build:project-pages',
|
|
166
|
+
]) {
|
|
167
|
+
expect(operationalFields, `operational fields must not mention "${banned}"`).not.toContain(banned);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('contract: rejects a missing/non-absolute cwd with a structured error, not a throw', async () => {
|
|
172
|
+
const fake = makeFakeAdminHttp();
|
|
173
|
+
registerAdminRoute(fake);
|
|
174
|
+
|
|
175
|
+
const missing = await fake.call('GET', '/admin/project-home/contract', { query: new URLSearchParams() });
|
|
176
|
+
expect(missing.status).toBe(400);
|
|
177
|
+
expect(missing.body.ok).toBe(false);
|
|
178
|
+
|
|
179
|
+
const relative = await fake.call('GET', '/admin/project-home/contract', {
|
|
180
|
+
query: new URLSearchParams({ cwd: 'relative/path' }),
|
|
181
|
+
});
|
|
182
|
+
expect(relative.status).toBe(400);
|
|
183
|
+
expect(relative.body.ok).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('contract: rejects a cwd with no session-manager-operations/ directory', async () => {
|
|
187
|
+
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-not-a-project-'));
|
|
188
|
+
config.addAllowedRoot(dir);
|
|
189
|
+
tmpDirs.push(dir);
|
|
190
|
+
const fake = makeFakeAdminHttp();
|
|
191
|
+
registerAdminRoute(fake);
|
|
192
|
+
|
|
193
|
+
const { status, body } = await fake.call('GET', '/admin/project-home/contract', {
|
|
194
|
+
query: new URLSearchParams({ cwd: dir }),
|
|
195
|
+
});
|
|
196
|
+
expect(status).toBe(400);
|
|
197
|
+
expect(body.ok).toBe(false);
|
|
198
|
+
expect(body.error).toMatch(/not a Session Manager project/);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ─── validate-summary ──────────────────────────────────────────────────
|
|
202
|
+
test('validate-summary: accepts a well-formed summary', async () => {
|
|
203
|
+
const cwd = await mkProjectCwd();
|
|
204
|
+
const fake = makeFakeAdminHttp();
|
|
205
|
+
registerAdminRoute(fake);
|
|
206
|
+
|
|
207
|
+
const { status, body } = await fake.call('POST', '/admin/project-home/validate-summary', {
|
|
208
|
+
body: { cwd, summary: validSummary() },
|
|
209
|
+
});
|
|
210
|
+
expect(status).toBe(200);
|
|
211
|
+
expect(body.valid).toBe(true);
|
|
212
|
+
expect(body.errors).toEqual([]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('validate-summary: rejects a malformed summary with per-field errors', async () => {
|
|
216
|
+
const cwd = await mkProjectCwd();
|
|
217
|
+
const fake = makeFakeAdminHttp();
|
|
218
|
+
registerAdminRoute(fake);
|
|
219
|
+
|
|
220
|
+
const bad = validSummary({ identity: { ...validSummary().identity, name: '' } });
|
|
221
|
+
const { status, body } = await fake.call('POST', '/admin/project-home/validate-summary', {
|
|
222
|
+
body: { cwd, summary: bad },
|
|
223
|
+
});
|
|
224
|
+
expect(status).toBe(200);
|
|
225
|
+
expect(body.valid).toBe(false);
|
|
226
|
+
expect(body.errors.length).toBeGreaterThan(0);
|
|
227
|
+
expect(body.errors[0]).toHaveProperty('field');
|
|
228
|
+
expect(body.errors[0]).toHaveProperty('message');
|
|
229
|
+
expect(body.errors.some((e) => e.field === 'identity.name')).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ─── render ────────────────────────────────────────────────────────────
|
|
233
|
+
test('render: happy path writes summary.json, picks.json, all 5 lens htmls, and manifest.json', async () => {
|
|
234
|
+
const cwd = await mkProjectCwd();
|
|
235
|
+
const fake = makeFakeAdminHttp();
|
|
236
|
+
registerAdminRoute(fake);
|
|
237
|
+
|
|
238
|
+
const contract = await fake.call('GET', '/admin/project-home/contract', { query: new URLSearchParams({ cwd }) });
|
|
239
|
+
const picks = picksFromCatalog(contract.body.catalog);
|
|
240
|
+
const summary = validSummary();
|
|
241
|
+
|
|
242
|
+
const { status, body } = await fake.call('POST', '/admin/project-home/render', {
|
|
243
|
+
body: { cwd, summary, picks },
|
|
244
|
+
});
|
|
245
|
+
expect(status).toBe(200);
|
|
246
|
+
expect(body.ok).toBe(true);
|
|
247
|
+
expect(typeof body.generatedAt).toBe('string');
|
|
248
|
+
expect(Number.isNaN(Date.parse(body.generatedAt))).toBe(false);
|
|
249
|
+
expect(Array.isArray(body.filesWritten)).toBe(true);
|
|
250
|
+
|
|
251
|
+
const dir = path.join(cwd, 'session-manager-operations', 'project-pages');
|
|
252
|
+
expect(fs.existsSync(path.join(dir, 'summary.json'))).toBe(true);
|
|
253
|
+
expect(fs.existsSync(path.join(dir, 'picks.json'))).toBe(true);
|
|
254
|
+
for (const lens of ['home', 'marketing', 'feature', 'architecture', 'brief']) {
|
|
255
|
+
expect(fs.existsSync(path.join(dir, 'output', `${lens}.html`))).toBe(true);
|
|
256
|
+
}
|
|
257
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'output', 'manifest.json'), 'utf8'));
|
|
258
|
+
expect(manifest.generatedAt).toBe(body.generatedAt);
|
|
259
|
+
|
|
260
|
+
expect(JSON.parse(fs.readFileSync(path.join(dir, 'summary.json'), 'utf8'))).toEqual(summary);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('render: a malformed summary is rejected and writes NOTHING', async () => {
|
|
264
|
+
const cwd = await mkProjectCwd();
|
|
265
|
+
const fake = makeFakeAdminHttp();
|
|
266
|
+
registerAdminRoute(fake);
|
|
267
|
+
|
|
268
|
+
const bad = validSummary({ architecture: { ...validSummary().architecture, summary: '' } });
|
|
269
|
+
const { status, body } = await fake.call('POST', '/admin/project-home/render', {
|
|
270
|
+
body: { cwd, summary: bad, picks: {} },
|
|
271
|
+
});
|
|
272
|
+
expect(status).toBe(400);
|
|
273
|
+
expect(body.ok).toBe(false);
|
|
274
|
+
expect(body.valid).toBe(false);
|
|
275
|
+
expect(body.errors.length).toBeGreaterThan(0);
|
|
276
|
+
|
|
277
|
+
const dir = path.join(cwd, 'session-manager-operations', 'project-pages');
|
|
278
|
+
expect(fs.existsSync(dir)).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('render: a malformed picks shape is rejected with a field error and writes NOTHING', async () => {
|
|
282
|
+
const cwd = await mkProjectCwd();
|
|
283
|
+
const fake = makeFakeAdminHttp();
|
|
284
|
+
registerAdminRoute(fake);
|
|
285
|
+
|
|
286
|
+
const { status, body } = await fake.call('POST', '/admin/project-home/render', {
|
|
287
|
+
body: { cwd, summary: validSummary(), picks: { home: { hero: 42 } } },
|
|
288
|
+
});
|
|
289
|
+
expect(status).toBe(400);
|
|
290
|
+
expect(body.ok).toBe(false);
|
|
291
|
+
expect(body.errors.some((e) => e.field === 'picks.home.hero')).toBe(true);
|
|
292
|
+
|
|
293
|
+
const dir = path.join(cwd, 'session-manager-operations', 'project-pages');
|
|
294
|
+
expect(fs.existsSync(dir)).toBe(false);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('GET routes (contract, status) never widen the write boundary — only render does', async () => {
|
|
298
|
+
// mkProjectCwd() (used by every other test here) calls config.addAllowedRoot
|
|
299
|
+
// itself so validatePath succeeds for an arbitrary os.tmpdir() location —
|
|
300
|
+
// which would defeat this specific assertion. Use a cwd that's readable
|
|
301
|
+
// via validatePath's own default allowed root (os.homedir()) WITHOUT any
|
|
302
|
+
// addAllowedRoot call, so a write only succeeds if a route handler itself
|
|
303
|
+
// called it — matching prdCreate.test.cjs's / prdMigration.test.cjs's own
|
|
304
|
+
// "homedir-rooted tmp cwd" pattern for exactly this kind of check.
|
|
305
|
+
const cwd = fs.realpathSync(await fsp.mkdtemp(path.join(os.homedir(), '.sm-project-home-admin-')));
|
|
306
|
+
tmpDirs.push(cwd);
|
|
307
|
+
fs.mkdirSync(path.join(cwd, 'session-manager-operations'), { recursive: true });
|
|
308
|
+
|
|
309
|
+
const fake = makeFakeAdminHttp();
|
|
310
|
+
registerAdminRoute(fake);
|
|
311
|
+
|
|
312
|
+
await fake.call('GET', '/admin/project-home/contract', { query: new URLSearchParams({ cwd }) });
|
|
313
|
+
await fake.call('GET', '/admin/project-home/status', { query: new URLSearchParams({ cwd }) });
|
|
314
|
+
|
|
315
|
+
// A write through config.cjs for this cwd must still be refused — the
|
|
316
|
+
// GET routes above must not have called config.addAllowedRoot for it.
|
|
317
|
+
const probePath = path.join(cwd, 'session-manager-operations', 'project-pages', 'probe.json');
|
|
318
|
+
await expect(config.writeJson(probePath, { a: 1 }, { writer: 'project-home' })).rejects.toThrow(
|
|
319
|
+
/Write outside allowed write boundaries/,
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('render: two concurrent renders for the same cwd are serialized, not interleaved', async () => {
|
|
324
|
+
const cwd = await mkProjectCwd();
|
|
325
|
+
const fake = makeFakeAdminHttp();
|
|
326
|
+
registerAdminRoute(fake);
|
|
327
|
+
|
|
328
|
+
const contract = await fake.call('GET', '/admin/project-home/contract', { query: new URLSearchParams({ cwd }) });
|
|
329
|
+
const picks = picksFromCatalog(contract.body.catalog);
|
|
330
|
+
|
|
331
|
+
// Distinct summaries so writes for render A vs render B can be told apart
|
|
332
|
+
// by content — identity.oneLine is echoed into every lens's html output
|
|
333
|
+
// (see renderer.cjs's htmlDocument/proj.oneLine usage).
|
|
334
|
+
const summaryA = validSummary({ identity: { ...validSummary().identity, oneLine: 'MARKER_RENDER_A' } });
|
|
335
|
+
const summaryB = validSummary({ identity: { ...validSummary().identity, oneLine: 'MARKER_RENDER_B' } });
|
|
336
|
+
|
|
337
|
+
const events = [];
|
|
338
|
+
let releaseGate;
|
|
339
|
+
const gate = new Promise((resolve) => { releaseGate = resolve; });
|
|
340
|
+
let gateArmed = false;
|
|
341
|
+
|
|
342
|
+
function labelForArgs(args) {
|
|
343
|
+
const text = typeof args[1] === 'string' ? args[1] : JSON.stringify(args[1]);
|
|
344
|
+
if (text.includes('MARKER_RENDER_A')) return 'A';
|
|
345
|
+
if (text.includes('MARKER_RENDER_B')) return 'B';
|
|
346
|
+
return 'unknown';
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const realWriteJson = config.writeJson.bind(config);
|
|
350
|
+
const realWriteTextAtomic = config.writeTextAtomic.bind(config);
|
|
351
|
+
|
|
352
|
+
async function instrumented(real, args) {
|
|
353
|
+
const label = labelForArgs(args);
|
|
354
|
+
events.push({ label, phase: 'start' });
|
|
355
|
+
// Only the very first write of the whole test is gated — this is render
|
|
356
|
+
// A's first write, deep inside its own Promise.all of writes. Holding it
|
|
357
|
+
// open gives render B a real window to start writing if serialization
|
|
358
|
+
// were absent; every other write (A's remaining writes, all of B's
|
|
359
|
+
// writes) proceeds unblocked.
|
|
360
|
+
if (!gateArmed) {
|
|
361
|
+
gateArmed = true;
|
|
362
|
+
await gate;
|
|
363
|
+
}
|
|
364
|
+
const result = await real(...args);
|
|
365
|
+
events.push({ label, phase: 'end' });
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const writeJsonSpy = vi.spyOn(config, 'writeJson').mockImplementation((...args) => instrumented(realWriteJson, args));
|
|
370
|
+
const writeTextAtomicSpy = vi
|
|
371
|
+
.spyOn(config, 'writeTextAtomic')
|
|
372
|
+
.mockImplementation((...args) => instrumented(realWriteTextAtomic, args));
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
const renderA = fake.call('POST', '/admin/project-home/render', { body: { cwd, summary: summaryA, picks } });
|
|
376
|
+
const renderB = fake.call('POST', '/admin/project-home/render', { body: { cwd, summary: summaryB, picks } });
|
|
377
|
+
|
|
378
|
+
// Yield microtasks (no wall-clock wait) until render A's first write has
|
|
379
|
+
// registered, then release it.
|
|
380
|
+
for (let i = 0; i < 1000 && events.length === 0; i += 1) {
|
|
381
|
+
await Promise.resolve();
|
|
382
|
+
}
|
|
383
|
+
expect(events.length).toBeGreaterThan(0);
|
|
384
|
+
releaseGate();
|
|
385
|
+
|
|
386
|
+
const [resA, resB] = await Promise.all([renderA, renderB]);
|
|
387
|
+
expect(resA.status).toBe(200);
|
|
388
|
+
expect(resB.status).toBe(200);
|
|
389
|
+
|
|
390
|
+
const labels = events.map((e) => e.label);
|
|
391
|
+
const firstA = labels.indexOf('A');
|
|
392
|
+
const lastA = labels.lastIndexOf('A');
|
|
393
|
+
const firstB = labels.indexOf('B');
|
|
394
|
+
const lastB = labels.lastIndexOf('B');
|
|
395
|
+
expect(firstA).toBeGreaterThanOrEqual(0);
|
|
396
|
+
expect(firstB).toBeGreaterThanOrEqual(0);
|
|
397
|
+
// Serialized means one render's entire write span is fully outside the
|
|
398
|
+
// other's — never interleaved.
|
|
399
|
+
expect(lastA < firstB || lastB < firstA).toBe(true);
|
|
400
|
+
|
|
401
|
+
const dir = path.join(cwd, 'session-manager-operations', 'project-pages');
|
|
402
|
+
const outputFiles = fs.readdirSync(path.join(dir, 'output')).sort();
|
|
403
|
+
expect(outputFiles).toEqual([
|
|
404
|
+
'architecture.html',
|
|
405
|
+
'brief.html',
|
|
406
|
+
'feature.html',
|
|
407
|
+
'home.html',
|
|
408
|
+
'manifest.json',
|
|
409
|
+
'marketing.html',
|
|
410
|
+
]);
|
|
411
|
+
} finally {
|
|
412
|
+
writeJsonSpy.mockRestore();
|
|
413
|
+
writeTextAtomicSpy.mockRestore();
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// ─── status ────────────────────────────────────────────────────────────
|
|
418
|
+
test('status: a project with no output dir yet returns a well-formed all-absent response, not an error', async () => {
|
|
419
|
+
const cwd = await mkProjectCwd();
|
|
420
|
+
const fake = makeFakeAdminHttp();
|
|
421
|
+
registerAdminRoute(fake);
|
|
422
|
+
|
|
423
|
+
const { status, body } = await fake.call('GET', '/admin/project-home/status', {
|
|
424
|
+
query: new URLSearchParams({ cwd }),
|
|
425
|
+
});
|
|
426
|
+
expect(status).toBe(200);
|
|
427
|
+
expect(body.ok).toBe(true);
|
|
428
|
+
expect(body.summary.exists).toBe(false);
|
|
429
|
+
expect(body.picks.exists).toBe(false);
|
|
430
|
+
expect(body.manifest.exists).toBe(false);
|
|
431
|
+
expect(body.manifest.generatedAt).toBe(null);
|
|
432
|
+
for (const lens of ['home', 'marketing', 'feature', 'architecture', 'brief']) {
|
|
433
|
+
expect(body.output[lens].exists).toBe(false);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test('status: reflects what render() just wrote', async () => {
|
|
438
|
+
const cwd = await mkProjectCwd();
|
|
439
|
+
const fake = makeFakeAdminHttp();
|
|
440
|
+
registerAdminRoute(fake);
|
|
441
|
+
|
|
442
|
+
const contract = await fake.call('GET', '/admin/project-home/contract', { query: new URLSearchParams({ cwd }) });
|
|
443
|
+
const picks = picksFromCatalog(contract.body.catalog);
|
|
444
|
+
await fake.call('POST', '/admin/project-home/render', { body: { cwd, summary: validSummary(), picks } });
|
|
445
|
+
|
|
446
|
+
const { body } = await fake.call('GET', '/admin/project-home/status', { query: new URLSearchParams({ cwd }) });
|
|
447
|
+
expect(body.summary.exists).toBe(true);
|
|
448
|
+
expect(body.picks.exists).toBe(true);
|
|
449
|
+
expect(body.manifest.exists).toBe(true);
|
|
450
|
+
expect(typeof body.manifest.generatedAt).toBe('string');
|
|
451
|
+
for (const lens of ['home', 'marketing', 'feature', 'architecture', 'brief']) {
|
|
452
|
+
expect(body.output[lens].exists).toBe(true);
|
|
453
|
+
expect(typeof body.output[lens].mtimeMs).toBe('number');
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// ─── bilkoHost interaction (PRD 1093) ─────────────────────────────────
|
|
458
|
+
// bilkoHost.cjs reads opsPath(cwd, 'project-pages', 'output', 'marketing.html')
|
|
459
|
+
// (around line 107) and opsPath(cwd, 'project-pages', 'output', `${lens}.html`)
|
|
460
|
+
// for project-page-lens documents (around line 219). Prove render()'s real
|
|
461
|
+
// output satisfies both read paths, so the publish half of the pipeline
|
|
462
|
+
// doesn't silently break the next time this file's output layout changes.
|
|
463
|
+
test('render output lands exactly where bilkoHost.cjs expects it', async () => {
|
|
464
|
+
const cwd = await mkProjectCwd();
|
|
465
|
+
fs.writeFileSync(path.join(cwd, 'package.json'), JSON.stringify({ name: 'demo-app', version: '1.0.0' }));
|
|
466
|
+
const fake = makeFakeAdminHttp();
|
|
467
|
+
registerAdminRoute(fake);
|
|
468
|
+
|
|
469
|
+
const contract = await fake.call('GET', '/admin/project-home/contract', { query: new URLSearchParams({ cwd }) });
|
|
470
|
+
const picks = picksFromCatalog(contract.body.catalog);
|
|
471
|
+
await fake.call('POST', '/admin/project-home/render', { body: { cwd, summary: validSummary(), picks } });
|
|
472
|
+
|
|
473
|
+
const bilkoHost = require('../bilkoHost.cjs');
|
|
474
|
+
const hostState = await bilkoHost.get({ cwd });
|
|
475
|
+
expect(hostState.hasMarketingPage).toBe(true);
|
|
476
|
+
|
|
477
|
+
await bilkoHost.addDocument({
|
|
478
|
+
cwd,
|
|
479
|
+
subpath: 'feature',
|
|
480
|
+
title: 'Feature',
|
|
481
|
+
source: { kind: 'project-page-lens', lens: 'feature' },
|
|
482
|
+
});
|
|
483
|
+
const result = await bilkoHost.prepareBundle({ cwd, slug: 'demo-app' });
|
|
484
|
+
expect(fs.existsSync(path.join(result.distPath, 'feature', 'index.html'))).toBe(true);
|
|
485
|
+
});
|
|
@@ -18,7 +18,16 @@ const fsp = require('node:fs/promises');
|
|
|
18
18
|
const os = require('node:os');
|
|
19
19
|
const path = require('node:path');
|
|
20
20
|
const config = require('../config.cjs');
|
|
21
|
-
const {
|
|
21
|
+
const {
|
|
22
|
+
get,
|
|
23
|
+
DEFAULT_HOME_PATH,
|
|
24
|
+
readDefaultHomeHtml,
|
|
25
|
+
attachWindow,
|
|
26
|
+
watchOutput,
|
|
27
|
+
unwatchOutput,
|
|
28
|
+
closeAllOutputWatchers,
|
|
29
|
+
_outputWatchers,
|
|
30
|
+
} = require('../projectPages.cjs');
|
|
22
31
|
|
|
23
32
|
const tmpDirs = [];
|
|
24
33
|
afterEach(async () => {
|
|
@@ -149,3 +158,66 @@ test('the shipped default is honest: no fabricated project-specific content, and
|
|
|
149
158
|
expect(html).toContain('Generate My Project Home');
|
|
150
159
|
expect(html.toLowerCase()).not.toContain('lorem ipsum');
|
|
151
160
|
});
|
|
161
|
+
|
|
162
|
+
// ── ephemeral cwd: get() must fall back, never throw/reject ────────────────
|
|
163
|
+
test('get() with an ephemeral cwd (os.tmpdir() itself) returns the shipped default instead of throwing', async () => {
|
|
164
|
+
config.addAllowedRoot(os.tmpdir());
|
|
165
|
+
const result = await get({ cwd: os.tmpdir() });
|
|
166
|
+
expect(result.output).not.toBeNull();
|
|
167
|
+
expect(result.output.isDefault).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ── watcher ──────────────────────────────────────────────────────────────
|
|
171
|
+
afterEach(() => {
|
|
172
|
+
closeAllOutputWatchers();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('watchOutput() on an ephemeral cwd refuses instead of crashing', async () => {
|
|
176
|
+
config.addAllowedRoot(os.tmpdir());
|
|
177
|
+
const result = await watchOutput(os.tmpdir());
|
|
178
|
+
expect(result).toEqual({ ok: false, reason: 'ephemeral' });
|
|
179
|
+
expect(_outputWatchers.size).toBe(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('watchOutput() pushes project-pages:changed when manifest.json appears, and unwatchOutput() tears it down', async () => {
|
|
183
|
+
const cwd = await mkTmpCwd();
|
|
184
|
+
const sent = [];
|
|
185
|
+
const fakeWindow = {
|
|
186
|
+
isDestroyed: () => false,
|
|
187
|
+
webContents: {
|
|
188
|
+
isDestroyed: () => false,
|
|
189
|
+
isCrashed: () => false,
|
|
190
|
+
send: (channel, payload) => sent.push({ channel, payload }),
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
attachWindow(fakeWindow);
|
|
194
|
+
|
|
195
|
+
const watchResult = await watchOutput(cwd);
|
|
196
|
+
expect(watchResult).toEqual({ ok: true });
|
|
197
|
+
expect(_outputWatchers.size).toBe(1);
|
|
198
|
+
|
|
199
|
+
writeOutput(cwd, {
|
|
200
|
+
home: 'HOME',
|
|
201
|
+
marketing: 'MARKETING',
|
|
202
|
+
feature: 'FEATURE',
|
|
203
|
+
architecture: 'ARCHITECTURE',
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Bounded poll for the debounced (awaitWriteFinish) push instead of a bare
|
|
207
|
+
// sleep — chokidar's stability window is 50ms, so this gives it generous
|
|
208
|
+
// headroom without hanging the suite on a broken watcher.
|
|
209
|
+
const deadline = Date.now() + 5000;
|
|
210
|
+
while (sent.length === 0 && Date.now() < deadline) {
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
expect(sent.length).toBeGreaterThan(0);
|
|
215
|
+
const last = sent[sent.length - 1];
|
|
216
|
+
expect(last.channel).toBe('project-pages:changed');
|
|
217
|
+
expect(last.payload.cwd).toBe(fs.realpathSync(cwd));
|
|
218
|
+
expect(last.payload.output.isDefault).toBe(false);
|
|
219
|
+
expect(last.payload.output.home).toBe('HOME');
|
|
220
|
+
|
|
221
|
+
unwatchOutput(cwd);
|
|
222
|
+
expect(_outputWatchers.size).toBe(0);
|
|
223
|
+
}, 10_000);
|
|
@@ -173,6 +173,60 @@ test('writeRcaReport: skips when there is no run directory to write into', async
|
|
|
173
173
|
expect(res.reason).toBe('no-run-dir');
|
|
174
174
|
});
|
|
175
175
|
|
|
176
|
+
// ─── recovery actions ────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
test('RECOVERY_ACTIONS: every FAILURE_CLASSES member has an entry from the closed set', () => {
|
|
179
|
+
const CLOSED_SET = new Set(['archive', 'resume-and-commit', 'verify-and-close', 'investigate']);
|
|
180
|
+
for (const failureClass of Object.values(rcaReport.FAILURE_CLASSES)) {
|
|
181
|
+
expect(rcaReport.RECOVERY_ACTIONS).toHaveProperty(failureClass);
|
|
182
|
+
expect(CLOSED_SET.has(rcaReport.RECOVERY_ACTIONS[failureClass])).toBe(true);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('recoveryActionFor: maps each known failure class to its expected action', () => {
|
|
187
|
+
const { FAILURE_CLASSES, recoveryActionFor } = rcaReport;
|
|
188
|
+
expect(recoveryActionFor(FAILURE_CLASSES.ALREADY_SHIPPED)).toBe('archive');
|
|
189
|
+
expect(recoveryActionFor(FAILURE_CLASSES.UNCOMMITTED)).toBe('resume-and-commit');
|
|
190
|
+
expect(recoveryActionFor(FAILURE_CLASSES.NO_SENTINEL)).toBe('verify-and-close');
|
|
191
|
+
expect(recoveryActionFor(FAILURE_CLASSES.SELF_QUEUE)).toBe('investigate');
|
|
192
|
+
expect(recoveryActionFor(FAILURE_CLASSES.STUCK_LOOP)).toBe('investigate');
|
|
193
|
+
expect(recoveryActionFor(FAILURE_CLASSES.POST_AC_OVERRUN)).toBe('investigate');
|
|
194
|
+
expect(recoveryActionFor(FAILURE_CLASSES.TRANSCRIPT_ERRORS)).toBe('investigate');
|
|
195
|
+
expect(recoveryActionFor(FAILURE_CLASSES.UNKNOWN)).toBe('investigate');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('recoveryActionFor: defaults to investigate for an unrecognised class', () => {
|
|
199
|
+
expect(rcaReport.recoveryActionFor('totally-made-up-class')).toBe('investigate');
|
|
200
|
+
expect(rcaReport.recoveryActionFor(undefined)).toBe('investigate');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('writeRcaReport: markdown carries a machine-readable recovery-action line matching recoveryActionFor, for each failure class', async () => {
|
|
204
|
+
const cwd = makeProject();
|
|
205
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
206
|
+
writeRun(runDir, 'testslug');
|
|
207
|
+
writePrd(cwd, 'testslug');
|
|
208
|
+
|
|
209
|
+
const cases = [
|
|
210
|
+
{ verdict: 'pass_no_commit', logTail: 'The work was already implemented in a prior commit.', expectedClass: rcaReport.FAILURE_CLASSES.ALREADY_SHIPPED },
|
|
211
|
+
{ verdict: 'transcript_errors', logTail: 'Launching skill: session-manager-dev:develop', expectedClass: rcaReport.FAILURE_CLASSES.SELF_QUEUE },
|
|
212
|
+
{ verdict: 'transcript_errors', logTail: 'while true; do sleep 1; done', expectedClass: rcaReport.FAILURE_CLASSES.STUCK_LOOP },
|
|
213
|
+
{ verdict: 'uncommitted_changes', logTail: 'plain log tail', expectedClass: rcaReport.FAILURE_CLASSES.UNCOMMITTED },
|
|
214
|
+
{ verdict: 'no_verdict_sentinel', logTail: 'plain log tail', expectedClass: rcaReport.FAILURE_CLASSES.NO_SENTINEL },
|
|
215
|
+
{ verdict: 'transcript_errors', logTail: 'plain log tail', expectedClass: rcaReport.FAILURE_CLASSES.TRANSCRIPT_ERRORS },
|
|
216
|
+
{ verdict: 'some_other_verdict', logTail: 'plain log tail', expectedClass: rcaReport.FAILURE_CLASSES.UNKNOWN },
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
for (const { verdict, logTail, expectedClass } of cases) {
|
|
220
|
+
writeRun(runDir, 'testslug', { logLines: logTail.split('\n') });
|
|
221
|
+
const res = await rcaReport.writeRcaReport({ job: baseJob({ cwd }), runDir, verdict });
|
|
222
|
+
expect(res.failureClass).toBe(expectedClass);
|
|
223
|
+
const expectedAction = rcaReport.recoveryActionFor(expectedClass);
|
|
224
|
+
expect(res.recoveryAction).toBe(expectedAction);
|
|
225
|
+
const body = fs.readFileSync(res.path, 'utf8');
|
|
226
|
+
expect(body).toContain(`recovery-action: ${expectedAction}`);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
176
230
|
test('writeRcaReport: SM_RCA_DISABLE=1 skips without writing', async () => {
|
|
177
231
|
process.env.SM_RCA_DISABLE = '1';
|
|
178
232
|
const cwd = makeProject();
|