@sabaiway/agent-workflow-kit 1.12.0 → 1.13.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/CHANGELOG.md +23 -0
- package/README.md +7 -3
- package/SKILL.md +70 -37
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/templates/AGENTS.md +2 -3
- package/tools/detect-backends.mjs +7 -6
- package/tools/engine-source.mjs +10 -5
- package/tools/engine-source.test.mjs +50 -0
- package/tools/family-registry.mjs +27 -1
- package/tools/family-registry.test.mjs +58 -0
- package/tools/inject-methodology.mjs +237 -110
- package/tools/inject-methodology.test.mjs +128 -12
- package/tools/recipes.mjs +276 -0
- package/tools/recipes.test.mjs +363 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import {
|
|
8
|
+
RECIPES,
|
|
9
|
+
BACKEND_ROLES,
|
|
10
|
+
BACKEND_META,
|
|
11
|
+
DISPLAY_ALIASES,
|
|
12
|
+
planRecipe,
|
|
13
|
+
recommendRecipe,
|
|
14
|
+
formatRecipes,
|
|
15
|
+
} from './recipes.mjs';
|
|
16
|
+
import { READY, NEEDS_SKILL, NEEDS_CLI, NEEDS_CREDENTIALS, DEGRADED } from './detect-backends.mjs';
|
|
17
|
+
|
|
18
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const SCRIPT = join(HERE, 'recipes.mjs');
|
|
20
|
+
const REPO_ROOT = join(HERE, '..', '..');
|
|
21
|
+
|
|
22
|
+
const CODEX = 'codex-cli-bridge';
|
|
23
|
+
const AGY = 'antigravity-cli-bridge';
|
|
24
|
+
const RECIPE_IDS = ['solo', 'reviewed', 'council', 'delegated'];
|
|
25
|
+
|
|
26
|
+
// A synthetic detector fixture — the planner only consumes { name, readiness } off each entry, built
|
|
27
|
+
// from the REAL readiness vocabulary (no `missing` — that is a probe-axis state, not a readiness).
|
|
28
|
+
const detect = (codexReadiness, agyReadiness) => [
|
|
29
|
+
{ name: CODEX, readiness: codexReadiness },
|
|
30
|
+
{ name: AGY, readiness: agyReadiness },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const readManifest = (name) => JSON.parse(readFileSync(join(REPO_ROOT, name, 'capability.json'), 'utf8'));
|
|
34
|
+
|
|
35
|
+
// ── RECIPES shape ────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
describe('RECIPES — the four named patterns', () => {
|
|
38
|
+
it('is exactly the four recipes, in lattice order', () => {
|
|
39
|
+
assert.deepEqual(RECIPES.map((r) => r.id), RECIPE_IDS);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('each recipe declares a role (or null for Solo), a minBackends count, and a degradation target', () => {
|
|
43
|
+
for (const r of RECIPES) {
|
|
44
|
+
assert.ok(typeof r.id === 'string' && r.id.length > 0);
|
|
45
|
+
assert.ok('role' in r, `${r.id} declares a role key`);
|
|
46
|
+
assert.ok(Number.isInteger(r.minBackends), `${r.id} declares minBackends`);
|
|
47
|
+
assert.ok('degradesTo' in r, `${r.id} declares a degradation target`);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('Solo is the floor (no role, no backend, no degradation target)', () => {
|
|
52
|
+
const solo = RECIPES.find((r) => r.id === 'solo');
|
|
53
|
+
assert.equal(solo.role, null);
|
|
54
|
+
assert.equal(solo.minBackends, 0);
|
|
55
|
+
assert.equal(solo.degradesTo, null);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('Reviewed/Council need review; Delegated needs execute; degradation chains terminate at Solo', () => {
|
|
59
|
+
const by = Object.fromEntries(RECIPES.map((r) => [r.id, r]));
|
|
60
|
+
assert.equal(by.reviewed.role, 'review');
|
|
61
|
+
assert.equal(by.reviewed.minBackends, 1);
|
|
62
|
+
assert.equal(by.reviewed.degradesTo, 'solo');
|
|
63
|
+
assert.equal(by.council.role, 'review');
|
|
64
|
+
assert.equal(by.council.minBackends, 2);
|
|
65
|
+
assert.equal(by.council.degradesTo, 'reviewed');
|
|
66
|
+
assert.equal(by.delegated.role, 'execute');
|
|
67
|
+
assert.equal(by.delegated.minBackends, 1);
|
|
68
|
+
assert.equal(by.delegated.degradesTo, 'solo');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ── drift guards ───────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
describe('role-coverage drift-guard — every recipe role ∈ union of the bridges provides[]', () => {
|
|
75
|
+
it('no recipe demands a role no backend provides', () => {
|
|
76
|
+
const union = new Set([...readManifest(CODEX).provides, ...readManifest(AGY).provides]);
|
|
77
|
+
for (const r of RECIPES) {
|
|
78
|
+
if (r.role !== null) assert.ok(union.has(r.role), `recipe ${r.id} role "${r.role}" is provided by some backend`);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('BACKEND_ROLES drift-guard — keyed by status.name, equals each bridge provides[]', () => {
|
|
84
|
+
it('is keyed by the manifest names the detector emits (not the display aliases)', () => {
|
|
85
|
+
assert.deepEqual(Object.keys(BACKEND_ROLES).sort(), [AGY, CODEX].sort());
|
|
86
|
+
});
|
|
87
|
+
it('matches each bridge capability.json provides[]', () => {
|
|
88
|
+
assert.deepEqual(BACKEND_ROLES[CODEX], readManifest(CODEX).provides);
|
|
89
|
+
assert.deepEqual(BACKEND_ROLES[AGY], readManifest(AGY).provides);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('BACKEND_META drift-guard — cost/quota mirror the manifests; agy carries a health advisory', () => {
|
|
94
|
+
it('cost + quota equal each bridge capability.json', () => {
|
|
95
|
+
for (const name of [CODEX, AGY]) {
|
|
96
|
+
const m = readManifest(name);
|
|
97
|
+
assert.equal(BACKEND_META[name].cost, m.cost);
|
|
98
|
+
assert.deepEqual(BACKEND_META[name].quota, m.quota);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
it('the agy health advisory (Issue-001) is present as static project knowledge', () => {
|
|
102
|
+
assert.equal(typeof BACKEND_META[AGY].health, 'string');
|
|
103
|
+
assert.ok(BACKEND_META[AGY].health.length > 0);
|
|
104
|
+
// codex carries no standing health caveat
|
|
105
|
+
assert.ok(!BACKEND_META[CODEX].health);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('DISPLAY_ALIASES — the manifest-name → human-alias map', () => {
|
|
110
|
+
it('maps both bridges to their short aliases', () => {
|
|
111
|
+
assert.equal(DISPLAY_ALIASES[CODEX], 'codex');
|
|
112
|
+
assert.equal(DISPLAY_ALIASES[AGY], 'agy');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── engine ⟷ kit recipe-name parity (cross-package read in the monorepo) ───────────
|
|
117
|
+
|
|
118
|
+
describe('engine↔kit recipe-name parity — the four ids appear in the engine canon', () => {
|
|
119
|
+
const engineRefs = ['orchestration.md', 'orchestration-slot.md'].map((f) =>
|
|
120
|
+
readFileSync(join(REPO_ROOT, 'agent-workflow-engine', 'references', f), 'utf8').toLowerCase(),
|
|
121
|
+
);
|
|
122
|
+
for (const id of RECIPE_IDS) {
|
|
123
|
+
it(`"${id}" appears in both engine orchestration files`, () => {
|
|
124
|
+
for (const text of engineRefs) assert.ok(text.includes(id), `engine canon names "${id}"`);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// The engine narrative (orchestration.md) hardcodes the bridges' role vocabulary as prose; keep it in
|
|
130
|
+
// lockstep with the manifests so a future `provides[]` change forces the narrative to be updated too.
|
|
131
|
+
describe('engine narrative ⟷ manifest role-vocabulary parity', () => {
|
|
132
|
+
const orchestration = readFileSync(join(REPO_ROOT, 'agent-workflow-engine', 'references', 'orchestration.md'), 'utf8');
|
|
133
|
+
const norm = (s) => s.replace(/\s+/g, ''); // whitespace-insensitive: prose has `["a", "b"]`, JSON has `["a","b"]`
|
|
134
|
+
|
|
135
|
+
for (const name of [CODEX, AGY]) {
|
|
136
|
+
it(`orchestration.md renders ${name}'s provides[] from the manifest`, () => {
|
|
137
|
+
const provides = readManifest(name).provides;
|
|
138
|
+
assert.ok(
|
|
139
|
+
norm(orchestration).includes(norm(`provides: ${JSON.stringify(provides)}`)),
|
|
140
|
+
`orchestration.md §1 must render ${name} provides ${JSON.stringify(provides)} (drifted from the manifest)`,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
it('the agy health advisory (Issue-001) is consistent between BACKEND_META and the engine narrative', () => {
|
|
146
|
+
// Flatten whitespace so a prose line-wrap (e.g. "substantive\nprompts") doesn't hide the substring.
|
|
147
|
+
const flat = orchestration.replace(/\s+/g, ' ').toLowerCase();
|
|
148
|
+
assert.match(flat, /stall on substantive prompts/, 'the engine narrates the stall advisory');
|
|
149
|
+
assert.match(flat, /issue-001/, 'the engine narrative ties it to Issue-001');
|
|
150
|
+
assert.match(flat, /prefer .?codex/, 'the engine narrates the prefer-codex remedy');
|
|
151
|
+
// BACKEND_META carries the same advisory facts (kit-side), tying the two representations together.
|
|
152
|
+
const health = BACKEND_META[AGY].health.toLowerCase();
|
|
153
|
+
assert.ok(health.includes('stall on substantive prompts') && health.includes('issue-001') && health.includes('codex'));
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ── planRecipe ─────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
const dispatchBackends = (plan) => plan.dispatch.map((d) => d.backend);
|
|
160
|
+
const notesText = (plan) => plan.notes.join(' :: ');
|
|
161
|
+
|
|
162
|
+
describe('planRecipe — all backends ready', () => {
|
|
163
|
+
const det = detect(READY, READY);
|
|
164
|
+
|
|
165
|
+
it('Solo: no degradation, no dispatch', () => {
|
|
166
|
+
const p = planRecipe('solo', det);
|
|
167
|
+
assert.equal(p.effective, 'solo');
|
|
168
|
+
assert.equal(p.degraded, false);
|
|
169
|
+
assert.deepEqual(p.dispatch, []);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('Reviewed: picks codex over agy (deterministic tie-break), not degraded', () => {
|
|
173
|
+
const p = planRecipe('reviewed', det);
|
|
174
|
+
assert.equal(p.effective, 'reviewed');
|
|
175
|
+
assert.equal(p.degraded, false);
|
|
176
|
+
assert.deepEqual(dispatchBackends(p), [CODEX]);
|
|
177
|
+
// codex chosen → the agy health caveat is NOT attached
|
|
178
|
+
assert.ok(!notesText(p).includes(BACKEND_META[AGY].health));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('Council: both backends review; the agy health caveat + the two-quota note are attached', () => {
|
|
182
|
+
const p = planRecipe('council', det);
|
|
183
|
+
assert.equal(p.effective, 'council');
|
|
184
|
+
assert.equal(p.degraded, false);
|
|
185
|
+
assert.deepEqual(dispatchBackends(p), [CODEX, AGY]);
|
|
186
|
+
assert.ok(notesText(p).includes(BACKEND_META[AGY].health), 'agy health note present when agy is used');
|
|
187
|
+
assert.match(notesText(p), /two backends|both backends|two .*quota/i);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('Delegated: codex executes the bounded sub-task', () => {
|
|
191
|
+
const p = planRecipe('delegated', det);
|
|
192
|
+
assert.equal(p.effective, 'delegated');
|
|
193
|
+
assert.equal(p.degraded, false);
|
|
194
|
+
assert.deepEqual(p.dispatch, [{ role: 'execute', backend: CODEX, display: 'codex' }]);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('planRecipe — codex only (agy needs-skill)', () => {
|
|
199
|
+
const det = detect(READY, NEEDS_SKILL);
|
|
200
|
+
|
|
201
|
+
it('Council → Reviewed(codex) with a stated reason', () => {
|
|
202
|
+
const p = planRecipe('council', det);
|
|
203
|
+
assert.equal(p.effective, 'reviewed');
|
|
204
|
+
assert.equal(p.degraded, true);
|
|
205
|
+
assert.equal(p.degradation[0].from, 'council');
|
|
206
|
+
assert.equal(p.degradation[0].to, 'reviewed');
|
|
207
|
+
assert.match(p.degradation[0].reason, /not installed/i);
|
|
208
|
+
assert.deepEqual(dispatchBackends(p), [CODEX]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('Delegated stays Delegated (codex provides execute and is ready)', () => {
|
|
212
|
+
const p = planRecipe('delegated', det);
|
|
213
|
+
assert.equal(p.effective, 'delegated');
|
|
214
|
+
assert.equal(p.degraded, false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('Reviewed → codex', () => {
|
|
218
|
+
assert.deepEqual(dispatchBackends(planRecipe('reviewed', det)), [CODEX]);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('planRecipe — agy only (codex needs-skill)', () => {
|
|
223
|
+
const det = detect(NEEDS_SKILL, READY);
|
|
224
|
+
|
|
225
|
+
it('Council → Reviewed(agy)', () => {
|
|
226
|
+
const p = planRecipe('council', det);
|
|
227
|
+
assert.equal(p.effective, 'reviewed');
|
|
228
|
+
assert.equal(p.degraded, true);
|
|
229
|
+
assert.deepEqual(dispatchBackends(p), [AGY]);
|
|
230
|
+
assert.ok(notesText(p).includes(BACKEND_META[AGY].health), 'agy health note present when agy is the reviewer');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('Delegated → Solo (no backend provides execute) with the reason stated', () => {
|
|
234
|
+
const p = planRecipe('delegated', det);
|
|
235
|
+
assert.equal(p.effective, 'solo');
|
|
236
|
+
assert.equal(p.degraded, true);
|
|
237
|
+
assert.match(p.degradation[0].reason, /execute/i);
|
|
238
|
+
assert.deepEqual(p.dispatch, []);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('Reviewed → agy', () => {
|
|
242
|
+
assert.deepEqual(dispatchBackends(planRecipe('reviewed', det)), [AGY]);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('planRecipe — none installed (both needs-skill)', () => {
|
|
247
|
+
const det = detect(NEEDS_SKILL, NEEDS_SKILL);
|
|
248
|
+
for (const id of ['reviewed', 'council', 'delegated']) {
|
|
249
|
+
it(`${id} → Solo, reason names the not-installed bridge skill`, () => {
|
|
250
|
+
const p = planRecipe(id, det);
|
|
251
|
+
assert.equal(p.effective, 'solo');
|
|
252
|
+
assert.equal(p.degraded, true);
|
|
253
|
+
assert.match(p.degradation.map((d) => d.reason).join(' '), /not installed/i);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe('planRecipe — agy degraded (wrapper not on PATH)', () => {
|
|
259
|
+
const det = detect(READY, DEGRADED);
|
|
260
|
+
|
|
261
|
+
it('Reviewed → codex (agy not dispatchable); no agy health note (agy unused)', () => {
|
|
262
|
+
const p = planRecipe('reviewed', det);
|
|
263
|
+
assert.equal(p.effective, 'reviewed');
|
|
264
|
+
assert.equal(p.degraded, false);
|
|
265
|
+
assert.deepEqual(dispatchBackends(p), [CODEX]);
|
|
266
|
+
assert.ok(!notesText(p).includes(BACKEND_META[AGY].health));
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('Council → Reviewed(codex); the degradation reason is the wrapper one (distinct from the health note)', () => {
|
|
270
|
+
const p = planRecipe('council', det);
|
|
271
|
+
assert.equal(p.effective, 'reviewed');
|
|
272
|
+
assert.equal(p.degraded, true);
|
|
273
|
+
assert.match(p.degradation[0].reason, /PATH|wrapper/i);
|
|
274
|
+
assert.deepEqual(dispatchBackends(p), [CODEX]);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('planRecipe — purity + determinism', () => {
|
|
279
|
+
it('is deterministic: same detection → deeply-equal plan', () => {
|
|
280
|
+
const det = detect(READY, NEEDS_CREDENTIALS);
|
|
281
|
+
assert.deepEqual(planRecipe('council', det), planRecipe('council', det));
|
|
282
|
+
});
|
|
283
|
+
it('does not mutate the detection input', () => {
|
|
284
|
+
const det = detect(READY, READY);
|
|
285
|
+
const snapshot = JSON.parse(JSON.stringify(det));
|
|
286
|
+
planRecipe('council', det);
|
|
287
|
+
assert.deepEqual(det, snapshot);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ── recommendRecipe ──────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
describe('recommendRecipe — never blank; the everyday default', () => {
|
|
294
|
+
it('both ready → Council available, Reviewed the everyday default', () => {
|
|
295
|
+
const r = recommendRecipe(detect(READY, READY));
|
|
296
|
+
assert.equal(r.recipe, 'council');
|
|
297
|
+
assert.match(r.clause, /council/i);
|
|
298
|
+
assert.match(r.clause, /reviewed/i);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('one ready → Reviewed', () => {
|
|
302
|
+
const r = recommendRecipe(detect(READY, NEEDS_SKILL));
|
|
303
|
+
assert.equal(r.recipe, 'reviewed');
|
|
304
|
+
assert.match(r.clause, /reviewed/i);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('none installed → Solo + a setup pointer', () => {
|
|
308
|
+
const r = recommendRecipe(detect(NEEDS_SKILL, NEEDS_SKILL));
|
|
309
|
+
assert.equal(r.recipe, 'solo');
|
|
310
|
+
assert.match(r.clause, /solo/i);
|
|
311
|
+
assert.match(r.clause, /\/agent-workflow-kit setup/);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('present-but-not-ready → Solo with the specific remedy', () => {
|
|
315
|
+
const r = recommendRecipe(detect(NEEDS_CLI, NEEDS_SKILL));
|
|
316
|
+
assert.equal(r.recipe, 'solo');
|
|
317
|
+
assert.match(r.clause, /CLI|cli/);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('the clause is never empty', () => {
|
|
321
|
+
for (const det of [
|
|
322
|
+
detect(READY, READY),
|
|
323
|
+
detect(READY, NEEDS_SKILL),
|
|
324
|
+
detect(NEEDS_SKILL, NEEDS_SKILL),
|
|
325
|
+
detect(NEEDS_CREDENTIALS, DEGRADED),
|
|
326
|
+
]) {
|
|
327
|
+
assert.ok(recommendRecipe(det).clause.trim().length > 0);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('present-but-not-ready tie-break is deterministic (codex before agy) regardless of detection order', () => {
|
|
332
|
+
// Both present-but-not-ready at the SAME readiness rank → the remedy must name codex, not whichever
|
|
333
|
+
// the detector happened to emit first (mirrors the dispatch-path priority).
|
|
334
|
+
const forward = recommendRecipe([{ name: CODEX, readiness: DEGRADED }, { name: AGY, readiness: DEGRADED }]);
|
|
335
|
+
const reversed = recommendRecipe([{ name: AGY, readiness: DEGRADED }, { name: CODEX, readiness: DEGRADED }]);
|
|
336
|
+
assert.equal(forward.clause, reversed.clause, 'order-independent');
|
|
337
|
+
assert.match(forward.clause, /codex/, 'ties break to codex');
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// ── CLI / formatRecipes ──────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
describe('formatRecipes — deterministic advisor text', () => {
|
|
344
|
+
it('renders the four recipes + a recommendation deterministically', () => {
|
|
345
|
+
const det = detect(READY, NEEDS_SKILL);
|
|
346
|
+
const once = formatRecipes(det);
|
|
347
|
+
assert.equal(once, formatRecipes(det), 'same detection → identical text');
|
|
348
|
+
for (const title of ['Solo', 'Reviewed', 'Council', 'Delegated']) assert.match(once, new RegExp(title));
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe('recipes.mjs CLI — read-only, exit 0', () => {
|
|
353
|
+
it('prints the recipes and exits 0', () => {
|
|
354
|
+
const out = execFileSync(process.execPath, [SCRIPT], { encoding: 'utf8', env: { ...process.env, PATH: '' } });
|
|
355
|
+
for (const title of ['Solo', 'Reviewed', 'Council', 'Delegated']) assert.match(out, new RegExp(title));
|
|
356
|
+
});
|
|
357
|
+
it('--json emits parseable JSON with the recommendation', () => {
|
|
358
|
+
const out = execFileSync(process.execPath, [SCRIPT, '--json'], { encoding: 'utf8', env: { ...process.env, PATH: '' } });
|
|
359
|
+
const parsed = JSON.parse(out);
|
|
360
|
+
assert.ok(Array.isArray(parsed.recipes));
|
|
361
|
+
assert.ok(parsed.recommendation && typeof parsed.recommendation.recipe === 'string');
|
|
362
|
+
});
|
|
363
|
+
});
|