claude-code-session-manager 0.65.0 → 0.66.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-Bzg89D5Y.js +3 -0
- package/dist/assets/{History-DB-9-zwc.js → History-CNH9vA0A.js} +2 -2
- package/dist/assets/{Hooks-DnxqMRrR.js → Hooks-CyTksPza.js} +3 -3
- package/dist/assets/{HostBilko-Cw6JocV7.js → HostBilko-DXQVDNHn.js} +1 -1
- package/dist/assets/{Library-BYSB0dmY.js → Library-9E2UOIsm.js} +1 -1
- package/dist/assets/{ListDetail-BfKqnL0r.js → ListDetail-CQWU_Yn5.js} +1 -1
- package/dist/assets/{MarkdownEditor-Cs-l5JOv.js → MarkdownEditor-DQgfpSef.js} +1 -1
- package/dist/assets/{McpServers-bBqj3hGg.js → McpServers-r3qwDIj2.js} +2 -2
- package/dist/assets/{Memory-DzPGXT5J.js → Memory-BaxOpj-3.js} +6 -6
- package/dist/assets/{Panel-BX6UZ-W0.js → Panel-BhFD8Lqo.js} +1 -1
- package/dist/assets/{Permissions-Db7S4T3Z.js → Permissions-Cj-mODQQ.js} +3 -3
- package/dist/assets/{Plugins-CIuey9R3.js → Plugins-DEL3Fqng.js} +2 -2
- package/dist/assets/{ProvenanceBadge-FPWNWc_-.js → ProvenanceBadge-CIAg6-JQ.js} +1 -1
- package/dist/assets/Scheduler-YOuZKkES.js +14 -0
- package/dist/assets/{ScopeSwitcher-DU7M_q5-.js → ScopeSwitcher-DZ_3gEus.js} +1 -1
- package/dist/assets/{Settings-DEptXcEY.js → Settings-B0x4oflz.js} +3 -3
- package/dist/assets/{SkillReferenceGraph-CprLcOCe.js → SkillReferenceGraph-CoIwsol8.js} +1 -1
- package/dist/assets/{Skills-CGN56X1i.js → Skills-CN8R6AWn.js} +2 -2
- package/dist/assets/{SystemPrompt-DIEK7FpJ.js → SystemPrompt-DceChpAi.js} +1 -1
- package/dist/assets/{TagLibrary-q66s4N3i.js → TagLibrary-BKmz2W7B.js} +1 -1
- package/dist/assets/{TiptapBody-I22aArc2.js → TiptapBody-W7n5SwPM.js} +1 -1
- package/dist/assets/{Toggle-D9sapSAv.js → Toggle-QuxlVHGI.js} +1 -1
- package/dist/assets/{index-LlWpj2VJ.css → index-14dBLqE_.css} +1 -1
- package/dist/assets/{index-BRwaw_1W.js → index-TejhHSzN.js} +526 -525
- package/dist/assets/{settingsSchema-CTc4qelV.js → settingsSchema-DNqx6BKJ.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +41 -13
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +10 -0
- package/src/main/__tests__/agentLibrary.test.cjs +40 -0
- package/src/main/__tests__/flatPrdTickSweep.test.cjs +110 -0
- package/src/main/__tests__/prdAdminRouteParity.test.cjs +68 -0
- package/src/main/__tests__/prdAdminRoutes.test.cjs +311 -0
- package/src/main/__tests__/prdCreate.test.cjs +7 -2
- package/src/main/__tests__/prdMigration.test.cjs +17 -0
- package/src/main/__tests__/prdMigrationLegacyAdopt.test.cjs +91 -0
- package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +109 -0
- package/src/main/__tests__/scheduleJobSchema.test.cjs +127 -0
- package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +65 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +152 -0
- package/src/main/__tests__/scheduleJobTransitionsGrep.test.cjs +59 -0
- package/src/main/__tests__/scheduler-reconcile-invalid-repair.test.cjs +203 -0
- package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +196 -0
- package/src/main/agentLibrary.cjs +40 -2
- package/src/main/index.cjs +2 -0
- package/src/main/ipcSchemas.cjs +60 -0
- package/src/main/lib/localAdminHttp.cjs +10 -3
- package/src/main/lib/prdAdminRoutes.cjs +175 -0
- package/src/main/lib/prdCreate.cjs +37 -2
- package/src/main/lib/prdFrontmatter.cjs +179 -1
- package/src/main/lib/prdMigration.cjs +82 -5
- package/src/main/lib/queueStore.cjs +41 -7
- package/src/main/lib/scheduleJobSchema.cjs +114 -0
- package/src/main/lib/scheduleJobTransitions.cjs +164 -0
- package/src/main/scheduler/prdParser.cjs +7 -0
- package/src/main/scheduler.cjs +649 -137
- package/src/preload/api.d.ts +54 -2
- package/src/preload/index.cjs +12 -0
- package/dist/assets/AgentLibrary-DYriNDGf.js +0 -1
- package/dist/assets/Scheduler-X5y252Qw.js +0 -14
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdAdminRoutes.test.cjs — unit tests for PRD 1024's admin HTTP routes
|
|
3
|
+
* (list/get/update/archive/cancel/retag) and the scheduler.cjs `remote`
|
|
4
|
+
* methods they delegate to. Exercises real HTTP round-trips against a real
|
|
5
|
+
* (temp-HOME) filesystem, matching prdCreate.test.cjs's pattern for the
|
|
6
|
+
* create-prd route.
|
|
7
|
+
*
|
|
8
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/prdAdminRoutes.test.cjs
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
import { test, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const fsp = require('node:fs/promises');
|
|
16
|
+
const os = require('node:os');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
const http = require('node:http');
|
|
19
|
+
|
|
20
|
+
let tmpHome;
|
|
21
|
+
let originalHome;
|
|
22
|
+
let scheduler;
|
|
23
|
+
let prdAdminRoutes;
|
|
24
|
+
let config;
|
|
25
|
+
let createAdminHttp;
|
|
26
|
+
|
|
27
|
+
beforeAll(() => {
|
|
28
|
+
originalHome = process.env.HOME;
|
|
29
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-admin-prd-routes-'));
|
|
30
|
+
process.env.HOME = tmpHome;
|
|
31
|
+
|
|
32
|
+
// Load AFTER HOME is stubbed — scheduler.cjs/config.cjs snapshot
|
|
33
|
+
// os.homedir() into module-level constants at load time, not lazily.
|
|
34
|
+
scheduler = require('../scheduler.cjs');
|
|
35
|
+
prdAdminRoutes = require('../lib/prdAdminRoutes.cjs');
|
|
36
|
+
config = require('../config.cjs');
|
|
37
|
+
({ createAdminHttp } = require('../lib/localAdminHttp.cjs'));
|
|
38
|
+
|
|
39
|
+
if (!scheduler.PRDS_DIR.startsWith(tmpHome)) {
|
|
40
|
+
throw new Error(`refusing to run: PRDS_DIR (${scheduler.PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// allProjectCwds()/activeProjectCwds() (queueStore.cjs's stateCwds, and
|
|
44
|
+
// prdLocations.cjs's resolvePrdsDirs) discover project cwds by scanning
|
|
45
|
+
// ~/.claude/projects/*/*.jsonl for a `cwd` field — fake one project
|
|
46
|
+
// transcript pointing at the real on-disk fixture cwd this file uses,
|
|
47
|
+
// same pattern as scheduler-verify-prd-path.test.cjs.
|
|
48
|
+
const projDir = path.join(tmpHome, '.claude', 'projects', 'fake-project-slug');
|
|
49
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
50
|
+
fs.writeFileSync(
|
|
51
|
+
path.join(projDir, 'session.jsonl'),
|
|
52
|
+
`${JSON.stringify({ cwd: path.join(tmpHome, 'fake-project-cwd') })}\n`,
|
|
53
|
+
'utf8',
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(() => {
|
|
58
|
+
process.env.HOME = originalHome;
|
|
59
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function projectCwd() {
|
|
63
|
+
const cwd = path.join(tmpHome, 'fake-project-cwd');
|
|
64
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
65
|
+
config.addAllowedRoot(cwd);
|
|
66
|
+
return cwd;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function uniqueSlug(prefix) {
|
|
70
|
+
return `${prefix}-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function writePrdFixture({ cwd, epicId, slug, extra = '' }) {
|
|
74
|
+
const prdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', epicId, 'prds');
|
|
75
|
+
await fsp.mkdir(prdsDir, { recursive: true });
|
|
76
|
+
const filePath = path.join(prdsDir, `${slug}.md`);
|
|
77
|
+
const body = `---\ntitle: fixture title\ncwd: ${cwd}\nestimateMinutes: 10\n${extra}---\n\n# Goal\n\ndo the thing\n`;
|
|
78
|
+
await fsp.writeFile(filePath, body, 'utf8');
|
|
79
|
+
return filePath;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function request(port, { method = 'GET', path: reqPath, token, body }) {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const headers = {};
|
|
85
|
+
if (token !== undefined) headers.Authorization = `Bearer ${token}`;
|
|
86
|
+
let payload;
|
|
87
|
+
if (body !== undefined) {
|
|
88
|
+
payload = JSON.stringify(body);
|
|
89
|
+
headers['Content-Type'] = 'application/json';
|
|
90
|
+
headers['Content-Length'] = Buffer.byteLength(payload);
|
|
91
|
+
}
|
|
92
|
+
const req = http.request({ hostname: '127.0.0.1', port, method, path: reqPath, headers }, (res) => {
|
|
93
|
+
const chunks = [];
|
|
94
|
+
res.on('data', (c) => chunks.push(c));
|
|
95
|
+
res.on('end', () => {
|
|
96
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
97
|
+
let json = null;
|
|
98
|
+
try { json = JSON.parse(text); } catch { /* leave null */ }
|
|
99
|
+
resolve({ status: res.statusCode, json, text });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
req.on('error', reject);
|
|
103
|
+
if (payload) req.write(payload);
|
|
104
|
+
req.end();
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function startAdmin() {
|
|
109
|
+
const prevE2e = process.env.SM_E2E;
|
|
110
|
+
process.env.SM_E2E = '1';
|
|
111
|
+
const admin = createAdminHttp();
|
|
112
|
+
prdAdminRoutes.registerAdminRoute(admin, scheduler.remote);
|
|
113
|
+
const { port, token } = await admin.start();
|
|
114
|
+
if (prevE2e === undefined) delete process.env.SM_E2E; else process.env.SM_E2E = prevE2e;
|
|
115
|
+
return { admin, port, token };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
test('GET /admin/scheduler/prds without token returns 401', async () => {
|
|
119
|
+
const { admin, port } = await startAdmin();
|
|
120
|
+
try {
|
|
121
|
+
const res = await request(port, { path: '/admin/scheduler/prds' });
|
|
122
|
+
expect(res.status).toBe(401);
|
|
123
|
+
} finally {
|
|
124
|
+
await admin.stop();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('GET /admin/scheduler/prds lists a live PRD with status null (no queue row yet), filterable by cwd', async () => {
|
|
129
|
+
const cwd = projectCwd();
|
|
130
|
+
const epicId = 'epic-list';
|
|
131
|
+
const slug = uniqueSlug('list-prds');
|
|
132
|
+
await writePrdFixture({ cwd, epicId, slug });
|
|
133
|
+
|
|
134
|
+
const { admin, port, token } = await startAdmin();
|
|
135
|
+
try {
|
|
136
|
+
const res = await request(port, { path: `/admin/scheduler/prds?cwd=${encodeURIComponent(cwd)}`, token });
|
|
137
|
+
expect(res.status).toBe(200);
|
|
138
|
+
expect(res.json.ok).toBe(true);
|
|
139
|
+
const entry = res.json.prds.find((p) => p.slug === slug);
|
|
140
|
+
expect(entry).toBeTruthy();
|
|
141
|
+
expect(entry.status).toBe(null);
|
|
142
|
+
expect(entry.epicId).toBe(epicId);
|
|
143
|
+
} finally {
|
|
144
|
+
await admin.stop();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('GET /admin/scheduler/prd?slug=&cwd= returns parsed frontmatter + body', async () => {
|
|
149
|
+
const cwd = projectCwd();
|
|
150
|
+
const epicId = 'epic-get';
|
|
151
|
+
const slug = uniqueSlug('get-prd');
|
|
152
|
+
await writePrdFixture({ cwd, epicId, slug });
|
|
153
|
+
|
|
154
|
+
const { admin, port, token } = await startAdmin();
|
|
155
|
+
try {
|
|
156
|
+
const res = await request(port, { path: `/admin/scheduler/prd?slug=${slug}&cwd=${encodeURIComponent(cwd)}`, token });
|
|
157
|
+
expect(res.status).toBe(200);
|
|
158
|
+
expect(res.json.ok).toBe(true);
|
|
159
|
+
expect(res.json.frontmatter.title).toBe('fixture title');
|
|
160
|
+
expect(res.json.body).toMatch(/do the thing/);
|
|
161
|
+
} finally {
|
|
162
|
+
await admin.stop();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('GET /admin/scheduler/prd for a nonexistent slug returns 404', async () => {
|
|
167
|
+
const cwd = projectCwd();
|
|
168
|
+
const { admin, port, token } = await startAdmin();
|
|
169
|
+
try {
|
|
170
|
+
const res = await request(port, { path: `/admin/scheduler/prd?slug=${uniqueSlug('nope')}&cwd=${encodeURIComponent(cwd)}`, token });
|
|
171
|
+
expect(res.status).toBe(404);
|
|
172
|
+
expect(res.json.ok).toBe(false);
|
|
173
|
+
} finally {
|
|
174
|
+
await admin.stop();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('POST /admin/scheduler/update-prd edits a not-yet-queued PRD and preserves unrecognized frontmatter keys', async () => {
|
|
179
|
+
const cwd = projectCwd();
|
|
180
|
+
const epicId = 'epic-update';
|
|
181
|
+
const slug = uniqueSlug('update-prd');
|
|
182
|
+
await writePrdFixture({ cwd, epicId, slug, extra: 'dependsOn: [some-other-slug]\n' });
|
|
183
|
+
|
|
184
|
+
const { admin, port, token } = await startAdmin();
|
|
185
|
+
try {
|
|
186
|
+
const res = await request(port, {
|
|
187
|
+
method: 'POST', path: '/admin/scheduler/update-prd', token,
|
|
188
|
+
body: { slug, cwd, frontmatter: { title: 'edited title', estimateMinutes: 25 } },
|
|
189
|
+
});
|
|
190
|
+
expect(res.status).toBe(200);
|
|
191
|
+
expect(res.json.ok).toBe(true);
|
|
192
|
+
|
|
193
|
+
const raw = await fsp.readFile(path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', epicId, 'prds', `${slug}.md`), 'utf8');
|
|
194
|
+
expect(raw).toMatch(/title: edited title/);
|
|
195
|
+
expect(raw).toMatch(/estimateMinutes: 25/);
|
|
196
|
+
// Unrecognized key round-trips verbatim.
|
|
197
|
+
expect(raw).toMatch(/dependsOn: \[some-other-slug\]/);
|
|
198
|
+
} finally {
|
|
199
|
+
await admin.stop();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('POST /admin/scheduler/update-prd refuses a slug whose job is running, naming the current status', async () => {
|
|
204
|
+
const cwd = projectCwd();
|
|
205
|
+
const epicId = 'epic-update-running';
|
|
206
|
+
const slug = uniqueSlug('update-prd-running');
|
|
207
|
+
await writePrdFixture({ cwd, epicId, slug });
|
|
208
|
+
|
|
209
|
+
await scheduler.writeQueue({
|
|
210
|
+
jobs: [{ slug, title: 'x', cwd, status: 'running', runtime: {} }],
|
|
211
|
+
config: {},
|
|
212
|
+
paused: null,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const { admin, port, token } = await startAdmin();
|
|
216
|
+
try {
|
|
217
|
+
const res = await request(port, {
|
|
218
|
+
method: 'POST', path: '/admin/scheduler/update-prd', token,
|
|
219
|
+
body: { slug, cwd, frontmatter: { title: 'should not land' } },
|
|
220
|
+
});
|
|
221
|
+
expect(res.status).toBe(409);
|
|
222
|
+
expect(res.json.ok).toBe(false);
|
|
223
|
+
expect(res.json.error).toMatch(/running/);
|
|
224
|
+
} finally {
|
|
225
|
+
await admin.stop();
|
|
226
|
+
await scheduler.writeQueue({ jobs: [], config: {}, paused: null });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('POST /admin/scheduler/cancel-job cancels a pending job (no cancelled status exists — lands as failed)', async () => {
|
|
231
|
+
const cwd = projectCwd();
|
|
232
|
+
const slug = uniqueSlug('cancel-pending');
|
|
233
|
+
await scheduler.writeQueue({
|
|
234
|
+
jobs: [{ slug, title: 'x', cwd, status: 'pending' }],
|
|
235
|
+
config: {},
|
|
236
|
+
paused: null,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const { admin, port, token } = await startAdmin();
|
|
240
|
+
try {
|
|
241
|
+
const res = await request(port, { method: 'POST', path: '/admin/scheduler/cancel-job', token, body: { slug } });
|
|
242
|
+
expect(res.status).toBe(200);
|
|
243
|
+
expect(res.json.ok).toBe(true);
|
|
244
|
+
expect(res.json.status).toBe('failed');
|
|
245
|
+
expect(res.json.wasRunning).toBe(false);
|
|
246
|
+
} finally {
|
|
247
|
+
await admin.stop();
|
|
248
|
+
await scheduler.writeQueue({ jobs: [], config: {}, paused: null });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('POST /admin/scheduler/cancel-job refuses an already-terminal job', async () => {
|
|
253
|
+
const cwd = projectCwd();
|
|
254
|
+
const slug = uniqueSlug('cancel-terminal');
|
|
255
|
+
await scheduler.writeQueue({
|
|
256
|
+
jobs: [{ slug, title: 'x', cwd, status: 'completed' }],
|
|
257
|
+
config: {},
|
|
258
|
+
paused: null,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const { admin, port, token } = await startAdmin();
|
|
262
|
+
try {
|
|
263
|
+
const res = await request(port, { method: 'POST', path: '/admin/scheduler/cancel-job', token, body: { slug } });
|
|
264
|
+
expect(res.status).toBe(409);
|
|
265
|
+
expect(res.json.ok).toBe(false);
|
|
266
|
+
expect(res.json.error).toMatch(/terminal/);
|
|
267
|
+
} finally {
|
|
268
|
+
await admin.stop();
|
|
269
|
+
await scheduler.writeQueue({ jobs: [], config: {}, paused: null });
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('POST /admin/scheduler/archive-prd moves a PRD to prds-archived/', async () => {
|
|
274
|
+
const cwd = projectCwd();
|
|
275
|
+
const epicId = 'epic-archive';
|
|
276
|
+
const slug = uniqueSlug('archive-prd');
|
|
277
|
+
const filePath = await writePrdFixture({ cwd, epicId, slug });
|
|
278
|
+
|
|
279
|
+
const { admin, port, token } = await startAdmin();
|
|
280
|
+
try {
|
|
281
|
+
const res = await request(port, { method: 'POST', path: '/admin/scheduler/archive-prd', token, body: { slugs: [slug] } });
|
|
282
|
+
expect(res.status).toBe(200);
|
|
283
|
+
expect(res.json.ok).toBe(true);
|
|
284
|
+
expect(res.json.archived).toBe(1);
|
|
285
|
+
expect(fs.existsSync(filePath)).toBe(false);
|
|
286
|
+
} finally {
|
|
287
|
+
await admin.stop();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('POST /admin/scheduler/retag-prd rewrites estimateMinutes frontmatter', async () => {
|
|
292
|
+
const cwd = projectCwd();
|
|
293
|
+
const epicId = 'epic-retag';
|
|
294
|
+
const slug = uniqueSlug('retag-prd');
|
|
295
|
+
const filePath = await writePrdFixture({ cwd, epicId, slug });
|
|
296
|
+
|
|
297
|
+
const { admin, port, token } = await startAdmin();
|
|
298
|
+
try {
|
|
299
|
+
const res = await request(port, {
|
|
300
|
+
method: 'POST', path: '/admin/scheduler/retag-prd', token,
|
|
301
|
+
body: { items: [{ slug, estimateMinutes: 42 }] },
|
|
302
|
+
});
|
|
303
|
+
expect(res.status).toBe(200);
|
|
304
|
+
expect(res.json.ok).toBe(true);
|
|
305
|
+
expect(res.json.retagged).toBe(1);
|
|
306
|
+
const raw = await fsp.readFile(filePath, 'utf8');
|
|
307
|
+
expect(raw).toMatch(/estimateMinutes: 42/);
|
|
308
|
+
} finally {
|
|
309
|
+
await admin.stop();
|
|
310
|
+
}
|
|
311
|
+
});
|
|
@@ -221,7 +221,7 @@ test('POST /admin/scheduler/create-prd without token returns 401', async () => {
|
|
|
221
221
|
}
|
|
222
222
|
});
|
|
223
223
|
|
|
224
|
-
test('POST /admin/scheduler/create-prd with a valid payload writes a file with frontmatter + standards appended, returns {nn, filename,
|
|
224
|
+
test('POST /admin/scheduler/create-prd with a valid payload writes a file with frontmatter + standards appended, returns {nn, filename, enqueued:false}', async () => {
|
|
225
225
|
const prdsDir = await mkTmpPrdsDir();
|
|
226
226
|
const remote = makeFakeRemoteWithPrdsDir(prdsDir);
|
|
227
227
|
const { admin, port, token } = await startWithRemote(remote);
|
|
@@ -230,7 +230,12 @@ test('POST /admin/scheduler/create-prd with a valid payload writes a file with f
|
|
|
230
230
|
method: 'POST', path: '/admin/scheduler/create-prd', token, body: validCreateBody(),
|
|
231
231
|
});
|
|
232
232
|
expect(res.status).toBe(200);
|
|
233
|
-
|
|
233
|
+
// Never a 'status' field, and never the literal 'queued' — this route
|
|
234
|
+
// only writes the PRD file; the queue row is derived by the next
|
|
235
|
+
// reconcile() pass, not created here. See prdCreate.cjs's createPrd.
|
|
236
|
+
expect(res.json.status).toBeUndefined();
|
|
237
|
+
expect(res.json.enqueued).toBe(false);
|
|
238
|
+
expect(typeof res.json.note).toBe('string');
|
|
234
239
|
expect(typeof res.json.nn).toBe('number');
|
|
235
240
|
expect(res.json.filename).toMatch(/^\d+-add-widget-frobnication\.md$/);
|
|
236
241
|
|
|
@@ -250,6 +250,23 @@ test('consolidateFlatPrds archives a file with no job at all', async () => {
|
|
|
250
250
|
expect(fs.existsSync(path.join(opsRoot(root), 'prds-archived', '701-orphan.md'))).toBe(true);
|
|
251
251
|
});
|
|
252
252
|
|
|
253
|
+
// PRD 1024: an UNRECOGNIZED/invalid status (the exact 2026-08-07 1021/1022
|
|
254
|
+
// incident shape) must protect the file, not archive it — reconcile() is
|
|
255
|
+
// what repairs such a row back to 'pending', and this consolidation must
|
|
256
|
+
// never race that repair by moving the row's only source out from under it.
|
|
257
|
+
for (const invalidStatus of ['queued', 'cancelled', 'bogus-status', '']) {
|
|
258
|
+
test(`consolidateFlatPrds does NOT archive a file whose job has the unrecognized status "${invalidStatus}"`, async () => {
|
|
259
|
+
const root = newRoot();
|
|
260
|
+
const flat = await makeFlatProject(root, ['950-invalid-status.md']);
|
|
261
|
+
await writeQueue(root, [{ slug: '950-invalid-status', status: invalidStatus }]);
|
|
262
|
+
|
|
263
|
+
const r = await consolidateFlatPrds(root);
|
|
264
|
+
|
|
265
|
+
expect(r.moved).toBe(0);
|
|
266
|
+
expect(fs.existsSync(path.join(flat, '950-invalid-status.md'))).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
253
270
|
test('consolidateFlatPrds mixed dir: only the live one survives', async () => {
|
|
254
271
|
const root = newRoot();
|
|
255
272
|
const flat = await makeFlatProject(root, ['800-live.md', '801-done.md', '802-none.md']);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdMigrationLegacyAdopt.test.cjs — legacyAdoptExistingPrds() (PRD-authoring-
|
|
3
|
+
* lockdown rollout migration): every PRD .md already on disk when the
|
|
4
|
+
* provenance gate ships must be stamped `createdVia: legacy-adopted` before
|
|
5
|
+
* reconcile() ever runs its quarantine check against it, so the rollout
|
|
6
|
+
* itself never quarantines pre-existing work.
|
|
7
|
+
*
|
|
8
|
+
* HOME-isolated (mirrors scheduler-reconcile-quarantine.test.cjs): stamping
|
|
9
|
+
* relies on resolvePrdsDirs(), which discovers projects via
|
|
10
|
+
* ~/.claude/projects transcripts.
|
|
11
|
+
*
|
|
12
|
+
* Run: timeout 60 npx vitest run src/main/__tests__/prdMigrationLegacyAdopt.test.cjs
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
import { test, expect, beforeAll, afterAll } from 'vitest';
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
let tmpHome;
|
|
23
|
+
let originalHome;
|
|
24
|
+
let legacyAdoptExistingPrds;
|
|
25
|
+
let parsePrdFile;
|
|
26
|
+
|
|
27
|
+
beforeAll(() => {
|
|
28
|
+
originalHome = process.env.HOME;
|
|
29
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-legacy-adopt-home-'));
|
|
30
|
+
process.env.HOME = tmpHome;
|
|
31
|
+
({ legacyAdoptExistingPrds } = require('../lib/prdMigration.cjs'));
|
|
32
|
+
({ parsePrdFile } = require('../lib/prdFrontmatter.cjs'));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
process.env.HOME = originalHome;
|
|
37
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function registerActiveProject(cwd) {
|
|
41
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
42
|
+
const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
|
|
43
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
44
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeFixtureProject(prefix) {
|
|
48
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
49
|
+
registerActiveProject(cwd);
|
|
50
|
+
const prdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'prds');
|
|
51
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
52
|
+
return { cwd, prdsDir };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test('stamps a pre-existing unstamped PRD as createdVia: legacy-adopted', async () => {
|
|
56
|
+
const { cwd, prdsDir } = makeFixtureProject('sm-legacy-adopt-proj-');
|
|
57
|
+
try {
|
|
58
|
+
const prdPath = path.join(prdsDir, '01-old.md');
|
|
59
|
+
fs.writeFileSync(prdPath, `---\ntitle: Old PRD\ncwd: ${cwd}\nestimateMinutes: 15\n---\n\n# Goal\nOld work.\n`, 'utf8');
|
|
60
|
+
|
|
61
|
+
const result = await legacyAdoptExistingPrds();
|
|
62
|
+
expect(result.stamped).toBeGreaterThanOrEqual(1);
|
|
63
|
+
expect(result.failed).toEqual([]);
|
|
64
|
+
|
|
65
|
+
const { frontmatter } = parsePrdFile(fs.readFileSync(prdPath, 'utf8'));
|
|
66
|
+
expect(frontmatter.createdVia).toBe('legacy-adopted');
|
|
67
|
+
expect(typeof frontmatter.issuedAt).toBe('string');
|
|
68
|
+
} finally {
|
|
69
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('is idempotent — never overwrites an already-stamped PRD', async () => {
|
|
74
|
+
const { cwd, prdsDir } = makeFixtureProject('sm-legacy-adopt-idempotent-');
|
|
75
|
+
try {
|
|
76
|
+
const prdPath = path.join(prdsDir, '02-already-stamped.md');
|
|
77
|
+
fs.writeFileSync(
|
|
78
|
+
prdPath,
|
|
79
|
+
`---\ntitle: Already stamped\ncwd: ${cwd}\nestimateMinutes: 15\ncreatedVia: scheduler-api\nissuedAt: 2026-08-01T00:00:00.000Z\n---\n\n# Goal\nDone.\n`,
|
|
80
|
+
'utf8',
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
await legacyAdoptExistingPrds();
|
|
84
|
+
|
|
85
|
+
const { frontmatter } = parsePrdFile(fs.readFileSync(prdPath, 'utf8'));
|
|
86
|
+
expect(frontmatter.createdVia).toBe('scheduler-api'); // unchanged
|
|
87
|
+
expect(frontmatter.issuedAt).toBe('2026-08-01T00:00:00.000Z'); // unchanged
|
|
88
|
+
} finally {
|
|
89
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reconcileFlatPrdSweep.test.cjs — end-to-end proof that `reconcile()` itself
|
|
3
|
+
* (not just `consolidateAllFlatPrds()` in isolation) never turns a
|
|
4
|
+
* hand-written flat-dir PRD into a queue job, regardless of which of
|
|
5
|
+
* reconcile's several callers (tickQueue's poll, job completion, the
|
|
6
|
+
* schedule:state/schedule:rescan IPC handlers, rescheduleTimer) triggered
|
|
7
|
+
* the pass.
|
|
8
|
+
*
|
|
9
|
+
* A code-review pass on this PRD flagged that gating only `tickQueue()` left
|
|
10
|
+
* every OTHER caller of `reconcile()` free to still turn a freshly
|
|
11
|
+
* hand-written flat-dir PRD into a `pending` job — the fix moved the sweep
|
|
12
|
+
* inside `reconcile()` itself instead. This test exercises the real
|
|
13
|
+
* `reconcile()` (not a stand-in) against a real on-disk flat-dir PRD with no
|
|
14
|
+
* pre-existing queue row (exactly the state of a file dropped there moments
|
|
15
|
+
* ago) and asserts it never appears in `state.jobs` afterward.
|
|
16
|
+
*
|
|
17
|
+
* HOME-isolated (mirrors scheduler-reconcile-invalid-repair.test.cjs): stub
|
|
18
|
+
* HOME to a mkdtemp dir before requiring scheduler.cjs, and register the
|
|
19
|
+
* fixture project as "active" via a fake ~/.claude/projects transcript —
|
|
20
|
+
* reconcile's PRD discovery (prdLocations.cjs) only scans cwds it can find
|
|
21
|
+
* that way.
|
|
22
|
+
*
|
|
23
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/reconcileFlatPrdSweep.test.cjs
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
import { test, expect, beforeAll, afterAll } from 'vitest';
|
|
29
|
+
const fs = require('node:fs');
|
|
30
|
+
const os = require('node:os');
|
|
31
|
+
const path = require('node:path');
|
|
32
|
+
|
|
33
|
+
let tmpHome;
|
|
34
|
+
let originalHome;
|
|
35
|
+
let scheduler;
|
|
36
|
+
|
|
37
|
+
beforeAll(() => {
|
|
38
|
+
originalHome = process.env.HOME;
|
|
39
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-reconcile-flatsweep-home-'));
|
|
40
|
+
process.env.HOME = tmpHome;
|
|
41
|
+
|
|
42
|
+
scheduler = require('../scheduler.cjs');
|
|
43
|
+
|
|
44
|
+
if (!scheduler.PRDS_DIR.startsWith(tmpHome)) {
|
|
45
|
+
throw new Error(`refusing to run: PRDS_DIR (${scheduler.PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterAll(() => {
|
|
50
|
+
process.env.HOME = originalHome;
|
|
51
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function registerActiveProject(cwd) {
|
|
55
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
56
|
+
const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
|
|
57
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
58
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function makeFixtureProject(prefix) {
|
|
62
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
63
|
+
registerActiveProject(cwd);
|
|
64
|
+
const opsRoot = path.join(cwd, 'session-manager-operations');
|
|
65
|
+
const flatPrdsDir = path.join(opsRoot, 'scheduler', 'prds');
|
|
66
|
+
const stateDir = path.join(opsRoot, 'scheduler', 'state');
|
|
67
|
+
fs.mkdirSync(flatPrdsDir, { recursive: true });
|
|
68
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
69
|
+
return { cwd, flatPrdsDir, stateDir, archiveDir: path.join(opsRoot, 'scheduler', 'prds-archived') };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test('reconcile() never turns a hand-written flat-dir PRD into a job, no matter which caller triggers it', async () => {
|
|
73
|
+
const { cwd, flatPrdsDir, archiveDir } = makeFixtureProject('sm-reconcile-flatsweep-proj-');
|
|
74
|
+
const filename = '1500-hand-written-bypass.md';
|
|
75
|
+
fs.writeFileSync(
|
|
76
|
+
path.join(flatPrdsDir, filename),
|
|
77
|
+
`---\ntitle: Hand-written bypass\ncwd: ${cwd}\nestimateMinutes: 10\n---\n\n# Goal\n\nSomething.\n`,
|
|
78
|
+
'utf8',
|
|
79
|
+
);
|
|
80
|
+
// No queue.json at all — the exact state of a PRD dropped on disk moments
|
|
81
|
+
// ago, before any prior reconcile() pass has scanned it into a job.
|
|
82
|
+
|
|
83
|
+
const state = { jobs: [], invalidJobs: [], paused: null };
|
|
84
|
+
await scheduler.reconcile(state);
|
|
85
|
+
|
|
86
|
+
const found = state.jobs.find((j) => j.slug === '1500-hand-written-bypass');
|
|
87
|
+
expect(found).toBeUndefined();
|
|
88
|
+
expect(fs.existsSync(path.join(flatPrdsDir, filename))).toBe(false);
|
|
89
|
+
expect(fs.existsSync(path.join(archiveDir, filename))).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('reconcile() still onboards a PRD written to the canonical Epic-scoped dir (the sweep only touches the flat dir)', async () => {
|
|
93
|
+
const { cwd } = makeFixtureProject('sm-reconcile-flatsweep-epic-proj-');
|
|
94
|
+
const epicId = 'epic-abc';
|
|
95
|
+
const epicPrdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', epicId, 'prds');
|
|
96
|
+
fs.mkdirSync(epicPrdsDir, { recursive: true });
|
|
97
|
+
fs.writeFileSync(
|
|
98
|
+
path.join(epicPrdsDir, '1501-canonical.md'),
|
|
99
|
+
`---\ntitle: Canonical\ncwd: ${cwd}\nestimateMinutes: 10\nsourcePromptId: ${epicId}\ncreatedVia: scheduler-api\nissuedAt: 2026-08-07T00:00:00.000Z\n---\n\n# Goal\n\nSomething.\n`,
|
|
100
|
+
'utf8',
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const state = { jobs: [], invalidJobs: [], paused: null };
|
|
104
|
+
await scheduler.reconcile(state);
|
|
105
|
+
|
|
106
|
+
const found = state.jobs.find((j) => j.slug === '1501-canonical');
|
|
107
|
+
expect(found).toBeDefined();
|
|
108
|
+
expect(found.status).toBe('pending');
|
|
109
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduleJobSchema.test.cjs — unit tests for the canonical ScheduleJob zod
|
|
3
|
+
* schema, its assertValidScheduleJob fail-closed helper, and queueStore.cjs's
|
|
4
|
+
* shapeJobs on-read quarantine gate.
|
|
5
|
+
*
|
|
6
|
+
* Reproduces the 2026-08-07 incident: PRDs 1021/1022 (social-signals-trader)
|
|
7
|
+
* sat unscheduled for 4+ hours because their queue.json rows carried
|
|
8
|
+
* `"status": "queued"` — a value absent from ScheduleJobStatus — which every
|
|
9
|
+
* picker silently skipped since none of them treat an unrecognized status as
|
|
10
|
+
* an error.
|
|
11
|
+
*
|
|
12
|
+
* Run: timeout 300 npx vitest run src/main/__tests__/scheduleJobSchema.test.cjs
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
const {
|
|
22
|
+
ScheduleJobSchema,
|
|
23
|
+
JOB_STATUSES,
|
|
24
|
+
assertValidScheduleJob,
|
|
25
|
+
} = require('../lib/scheduleJobSchema.cjs');
|
|
26
|
+
const { shapeJobs } = require('../lib/queueStore.cjs');
|
|
27
|
+
|
|
28
|
+
function validJob(overrides = {}) {
|
|
29
|
+
return {
|
|
30
|
+
slug: '1021-social-signals',
|
|
31
|
+
title: 'Wire up social signals trader',
|
|
32
|
+
cwd: '/home/bilko/Projects/social-signals-trader',
|
|
33
|
+
parallelGroup: 1021,
|
|
34
|
+
estimateMinutes: 30,
|
|
35
|
+
bodyPreview: 'Do the thing.',
|
|
36
|
+
status: 'pending',
|
|
37
|
+
runId: null,
|
|
38
|
+
startedAt: null,
|
|
39
|
+
finishedAt: null,
|
|
40
|
+
exitCode: null,
|
|
41
|
+
error: null,
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test('JOB_STATUSES matches ScheduleJobStatus', () => {
|
|
47
|
+
expect(JOB_STATUSES).toEqual(['pending', 'running', 'investigating', 'completed', 'failed', 'needs_review', 'quarantined']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('a valid full job record parses', () => {
|
|
51
|
+
const full = validJob({
|
|
52
|
+
sessionId: 'c1b2a3d4-0000-0000-0000-000000000000',
|
|
53
|
+
runtime: { pid: 123, runId: 'run-1', startedAt: '2026-08-07T00:00:00.000Z' },
|
|
54
|
+
verifierVerdict: 'halt',
|
|
55
|
+
dependsOn: ['1020-prereq'],
|
|
56
|
+
originSessionId: 'orig-session',
|
|
57
|
+
sourceTabId: 'tab-1',
|
|
58
|
+
sourcePromptId: 'prompt-1',
|
|
59
|
+
epicId: 'epic-1',
|
|
60
|
+
});
|
|
61
|
+
expect(() => assertValidScheduleJob(full)).not.toThrow();
|
|
62
|
+
expect(ScheduleJobSchema.safeParse(full).success).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('a job with an invalid status value fails', () => {
|
|
66
|
+
const badStatus = validJob({ status: 'queued' });
|
|
67
|
+
expect(() => assertValidScheduleJob(badStatus)).toThrow(/status/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a job missing a required field fails', () => {
|
|
71
|
+
const { slug, ...missingSlug } = validJob();
|
|
72
|
+
expect(() => assertValidScheduleJob(missingSlug)).toThrow(/slug/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ---------- shapeJobs (the on-read quarantine gate) ----------
|
|
76
|
+
|
|
77
|
+
let tmpFile;
|
|
78
|
+
let errorSpy;
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
tmpFile = path.join(
|
|
82
|
+
os.tmpdir(),
|
|
83
|
+
`scheduleJobSchema-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`
|
|
84
|
+
);
|
|
85
|
+
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
try { fs.unlinkSync(tmpFile); } catch { /* ok if absent */ }
|
|
90
|
+
errorSpy.mockRestore();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a row with status "queued" (the 1021/1022 incident) is quarantined, logged, and absent from jobs', () => {
|
|
94
|
+
fs.writeFileSync(tmpFile, JSON.stringify({
|
|
95
|
+
jobs: [
|
|
96
|
+
validJob({ slug: '1021-social-signals', status: 'queued' }),
|
|
97
|
+
validJob({ slug: '1022-social-signals', status: 'queued' }),
|
|
98
|
+
validJob({ slug: '1023-healthy', status: 'pending' }),
|
|
99
|
+
],
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
const raw = fs.readFileSync(tmpFile, 'utf8');
|
|
103
|
+
const { jobs, invalid } = shapeJobs(raw, tmpFile);
|
|
104
|
+
|
|
105
|
+
expect(jobs.map((j) => j.slug)).toEqual(['1023-healthy']);
|
|
106
|
+
expect(invalid.map((i) => i.slug)).toEqual(['1021-social-signals', '1022-social-signals']);
|
|
107
|
+
expect(invalid[0].issues).toMatch(/status/);
|
|
108
|
+
expect(invalid[0].file).toBe(tmpFile);
|
|
109
|
+
|
|
110
|
+
expect(errorSpy).toHaveBeenCalledTimes(2);
|
|
111
|
+
const firstCallArgs = errorSpy.mock.calls[0][0];
|
|
112
|
+
expect(firstCallArgs).toContain(tmpFile);
|
|
113
|
+
expect(firstCallArgs).toContain('1021-social-signals');
|
|
114
|
+
expect(firstCallArgs).toMatch(/status/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('a fully valid row round-trips through shapeJobs unchanged', () => {
|
|
118
|
+
const job = validJob({ slug: '1023-healthy' });
|
|
119
|
+
fs.writeFileSync(tmpFile, JSON.stringify({ jobs: [job] }));
|
|
120
|
+
|
|
121
|
+
const raw = fs.readFileSync(tmpFile, 'utf8');
|
|
122
|
+
const { jobs, invalid } = shapeJobs(raw, tmpFile);
|
|
123
|
+
|
|
124
|
+
expect(invalid).toEqual([]);
|
|
125
|
+
expect(jobs).toEqual([job]);
|
|
126
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
127
|
+
});
|