scripts-orchestrator 2.14.0 → 2.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/index.js +88 -0
- package/lib/index.js +24 -2
- package/lib/orchestrator.js +180 -130
- package/lib/process-manager.js +5 -2
- package/lib/process-manager.test.js +22 -0
- package/lib/recommend-phases.js +251 -0
- package/lib/recommend-phases.test.js +107 -0
- package/lib/report-html.js +308 -0
- package/lib/report-html.test.js +88 -0
- package/package.json +1 -1
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { ProcessManager } from './process-manager.js';
|
|
3
|
+
|
|
4
|
+
describe('ProcessManager.getLogPath', () => {
|
|
5
|
+
test('resolves the default per-command log path under the log folder', () => {
|
|
6
|
+
const pm = new ProcessManager();
|
|
7
|
+
pm.setLogFolder('/tmp/example-logs');
|
|
8
|
+
const result = pm.getLogPath('build -- --outDir dist');
|
|
9
|
+
// Uses only the first word of the command for the filename.
|
|
10
|
+
expect(result).toBe(
|
|
11
|
+
path.join('/tmp/example-logs', 'scripts-orchestrator-logs', 'build.log'),
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('honors a per-command log file override, resolved against cwd', () => {
|
|
16
|
+
const pm = new ProcessManager();
|
|
17
|
+
pm.setLogFolder('/tmp/example-logs');
|
|
18
|
+
const override = './logs/scripts-orchestrator-logs/custom.log';
|
|
19
|
+
const result = pm.getLogPath('lint', override);
|
|
20
|
+
expect(result).toBe(path.resolve(override));
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file recommend-phases.js
|
|
3
|
+
* @description R12 — memory-aware phase recommender (advisory).
|
|
4
|
+
*
|
|
5
|
+
* Reads a results JSON (the same payload the orchestrator writes via `metrics: ['time','memory']`)
|
|
6
|
+
* and proposes a phase layout that keeps each phase's concurrent peak memory under a per-host
|
|
7
|
+
* budget while letting long-running steps overlap. This is the "advisory" first step of R12:
|
|
8
|
+
* it only reports — it does not change how a run is scheduled.
|
|
9
|
+
*
|
|
10
|
+
* Algorithm: First-Fit-Decreasing bin-packing by step duration. Steps are sorted longest-first
|
|
11
|
+
* and each is placed into the earliest phase where adding it keeps Σ(concurrent peak memory) ≤ budget
|
|
12
|
+
* and concurrent step count ≤ core share; otherwise a new phase is opened. Long steps seed phases;
|
|
13
|
+
* short steps fill the gaps beneath them, so makespan (Σ of each phase's longest step) stays near the
|
|
14
|
+
* theoretical floor (the single longest step) without oversubscribing RAM.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import os from 'os';
|
|
18
|
+
import chalk from 'chalk';
|
|
19
|
+
|
|
20
|
+
const KB = 1024;
|
|
21
|
+
const GB = 1024 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the per-host scheduling budget.
|
|
25
|
+
*
|
|
26
|
+
* budget = totalmem × memSafety ÷ fanout (overridable wholesale via budgetMb)
|
|
27
|
+
* coreShare = (cores − 2) ÷ fanout (≥ 1)
|
|
28
|
+
*
|
|
29
|
+
* `fanout` models the workspace-level parallelism (R1's `--parallel=N`): when N workspaces gate
|
|
30
|
+
* concurrently they share the host, so each gets 1/N of RAM and cores.
|
|
31
|
+
*/
|
|
32
|
+
export function computeBudget(opts = {}) {
|
|
33
|
+
const totalMemBytes = opts.totalMemBytes != null ? Number(opts.totalMemBytes) : os.totalmem();
|
|
34
|
+
const cores = opts.cores != null ? Number(opts.cores) : os.cpus().length;
|
|
35
|
+
const fanout = Math.max(1, Number(opts.fanout) || 1);
|
|
36
|
+
const memSafety = opts.memSafety != null ? Number(opts.memSafety) : 0.8;
|
|
37
|
+
const budgetBytes =
|
|
38
|
+
opts.budgetMb != null
|
|
39
|
+
? Number(opts.budgetMb) * 1024 * 1024
|
|
40
|
+
: (totalMemBytes * memSafety) / fanout;
|
|
41
|
+
const coreShare = Math.max(1, Math.floor((cores - 2) / fanout));
|
|
42
|
+
return { totalMemBytes, cores, fanout, memSafety, budgetBytes, coreShare };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extract the steps that actually ran and were timed. Skipped/disabled commands and untimed
|
|
47
|
+
* entries are excluded — they tell us nothing about contention. `memoryKb` defaults to 0 when the
|
|
48
|
+
* memory metric wasn't collected (the report then warns and packs by core share alone).
|
|
49
|
+
*/
|
|
50
|
+
export function usableSteps(payload) {
|
|
51
|
+
const commands = Array.isArray(payload?.commands) ? payload.commands : [];
|
|
52
|
+
return commands
|
|
53
|
+
.filter((c) => c && typeof c.durationMs === 'number' && c.durationMs > 0 && !c.skipReason)
|
|
54
|
+
.map((c) => ({
|
|
55
|
+
command: c.command,
|
|
56
|
+
phase: c.phase != null ? c.phase : '(no phase)',
|
|
57
|
+
durationMs: c.durationMs,
|
|
58
|
+
memoryKb: typeof c.memoryKb === 'number' && c.memoryKb > 0 ? c.memoryKb : 0,
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Group steps by their original phase (first-seen order) and, for each phase, report the
|
|
64
|
+
* concurrent peak memory (Σ of member peaks — conservative, since peaks rarely coincide exactly)
|
|
65
|
+
* and the phase wall-clock (max member duration, because the phase runs them in parallel).
|
|
66
|
+
*/
|
|
67
|
+
export function observedTimeline(steps, budgetBytes) {
|
|
68
|
+
const order = [];
|
|
69
|
+
const byPhase = new Map();
|
|
70
|
+
for (const s of steps) {
|
|
71
|
+
if (!byPhase.has(s.phase)) {
|
|
72
|
+
byPhase.set(s.phase, []);
|
|
73
|
+
order.push(s.phase);
|
|
74
|
+
}
|
|
75
|
+
byPhase.get(s.phase).push(s);
|
|
76
|
+
}
|
|
77
|
+
return order.map((name) => {
|
|
78
|
+
const members = byPhase.get(name);
|
|
79
|
+
const concurrentMemBytes = members.reduce((sum, s) => sum + s.memoryKb * KB, 0);
|
|
80
|
+
const wallclockMs = Math.max(...members.map((s) => s.durationMs));
|
|
81
|
+
return {
|
|
82
|
+
name,
|
|
83
|
+
steps: members,
|
|
84
|
+
concurrentMemBytes,
|
|
85
|
+
wallclockMs,
|
|
86
|
+
overBudget: concurrentMemBytes > budgetBytes,
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* First-Fit-Decreasing bin-packing by duration. A step too large to fit any existing bin under the
|
|
93
|
+
* budget opens a new bin; a single step whose own peak exceeds the budget still gets its own bin
|
|
94
|
+
* (it can't be split) and is flagged.
|
|
95
|
+
*/
|
|
96
|
+
export function packPhases(steps, budgetBytes, coreShare) {
|
|
97
|
+
const sorted = [...steps].sort((a, b) => b.durationMs - a.durationMs);
|
|
98
|
+
const bins = [];
|
|
99
|
+
for (const step of sorted) {
|
|
100
|
+
const stepBytes = step.memoryKb * KB;
|
|
101
|
+
let placed = false;
|
|
102
|
+
for (const bin of bins) {
|
|
103
|
+
if (bin.memBytes + stepBytes <= budgetBytes && bin.steps.length < coreShare) {
|
|
104
|
+
bin.steps.push(step);
|
|
105
|
+
bin.memBytes += stepBytes;
|
|
106
|
+
placed = true;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (!placed) {
|
|
111
|
+
bins.push({ steps: [step], memBytes: stepBytes });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const bin of bins) {
|
|
115
|
+
bin.wallclockMs = Math.max(...bin.steps.map((s) => s.durationMs));
|
|
116
|
+
bin.exceedsBudget = bin.memBytes > budgetBytes; // only possible for a lone oversized step
|
|
117
|
+
}
|
|
118
|
+
return bins;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Produce a full recommendation object from a results payload. Pure — all host inputs are taken from
|
|
123
|
+
* `opts` or `os`, so it's testable without a real machine.
|
|
124
|
+
*/
|
|
125
|
+
export function recommendPhases(payload, opts = {}) {
|
|
126
|
+
const budget = computeBudget(opts);
|
|
127
|
+
const steps = usableSteps(payload);
|
|
128
|
+
const warnings = [];
|
|
129
|
+
|
|
130
|
+
if (steps.length === 0) {
|
|
131
|
+
warnings.push('No completed, timed commands found in the results — nothing to recommend.');
|
|
132
|
+
}
|
|
133
|
+
const hasMemory = steps.some((s) => s.memoryKb > 0);
|
|
134
|
+
if (steps.length > 0 && !hasMemory) {
|
|
135
|
+
warnings.push(
|
|
136
|
+
'No memory metrics in the results — re-run with metrics including "memory" for a meaningful budget. Packing falls back to the core-share limit only.',
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const observed = observedTimeline(steps, budget.budgetBytes);
|
|
141
|
+
const bins = packPhases(steps, budget.budgetBytes, budget.coreShare);
|
|
142
|
+
|
|
143
|
+
const observedMakespanMs = observed.reduce((sum, p) => sum + p.wallclockMs, 0);
|
|
144
|
+
const recommendedMakespanMs = bins.reduce((sum, b) => sum + b.wallclockMs, 0);
|
|
145
|
+
const optimalMakespanMs = steps.length ? Math.max(...steps.map((s) => s.durationMs)) : 0;
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
...budget,
|
|
149
|
+
steps,
|
|
150
|
+
observed,
|
|
151
|
+
observedMakespanMs,
|
|
152
|
+
recommended: { bins, makespanMs: recommendedMakespanMs },
|
|
153
|
+
optimalMakespanMs,
|
|
154
|
+
warnings,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---- formatting helpers ---------------------------------------------------
|
|
159
|
+
|
|
160
|
+
export function fmtDuration(ms) {
|
|
161
|
+
if (ms == null) return '—';
|
|
162
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
163
|
+
const seconds = ms / 1000;
|
|
164
|
+
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
|
165
|
+
const minutes = Math.floor(seconds / 60);
|
|
166
|
+
const rem = Math.round(seconds % 60);
|
|
167
|
+
return `${minutes}m ${rem}s`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function fmtMemKb(kb) {
|
|
171
|
+
if (!kb) return '0';
|
|
172
|
+
const bytes = kb * KB;
|
|
173
|
+
if (bytes >= GB) return `${(bytes / GB).toFixed(2)} GB`;
|
|
174
|
+
return `${Math.round(bytes / (1024 * 1024))} MB`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function stepLine(s) {
|
|
178
|
+
return ` ${fmtDuration(s.durationMs).padStart(8)} ${fmtMemKb(s.memoryKb).padStart(9)} ${s.command}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Render a recommendation as a human-readable, optionally-colored report.
|
|
183
|
+
*/
|
|
184
|
+
export function formatRecommendationReport(rec, { sourcePath = null } = {}) {
|
|
185
|
+
const c = chalk;
|
|
186
|
+
const L = [];
|
|
187
|
+
|
|
188
|
+
L.push(c.bold('🧮 Scripts-Orchestrator — memory-aware phase recommendation (R12, advisory)'));
|
|
189
|
+
if (sourcePath) L.push(c.dim(` Source: ${sourcePath}`));
|
|
190
|
+
L.push(
|
|
191
|
+
` Budget: ${c.yellow(fmtMemKb(rec.budgetBytes / KB))} ` +
|
|
192
|
+
`(RAM ${fmtMemKb(rec.totalMemBytes / KB)} × ${rec.memSafety} ÷ fan-out ${rec.fanout}) · ` +
|
|
193
|
+
`core share: ${c.yellow(String(rec.coreShare))} (${rec.cores} cores − 2 ÷ ${rec.fanout})`,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
for (const w of rec.warnings) L.push(c.yellow(` ⚠ ${w}`));
|
|
197
|
+
if (rec.steps.length === 0) return L.join('\n');
|
|
198
|
+
|
|
199
|
+
// Observed timeline
|
|
200
|
+
L.push('');
|
|
201
|
+
L.push(
|
|
202
|
+
c.bold(
|
|
203
|
+
`Observed timeline — ${rec.steps.length} steps · ${rec.observed.length} phase(s) · makespan ${fmtDuration(rec.observedMakespanMs)}`,
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
for (const ph of rec.observed) {
|
|
207
|
+
const flag = ph.overBudget ? c.red(' ⚠ concurrent peak over budget') : '';
|
|
208
|
+
L.push(
|
|
209
|
+
` ${c.cyan(ph.name)} ${fmtDuration(ph.wallclockMs)} · ${ph.steps.length} step(s) · Σ peak ${fmtMemKb(ph.concurrentMemBytes / KB)}${flag}`,
|
|
210
|
+
);
|
|
211
|
+
for (const s of [...ph.steps].sort((a, b) => b.durationMs - a.durationMs)) L.push(stepLine(s));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Recommended layout
|
|
215
|
+
L.push('');
|
|
216
|
+
L.push(
|
|
217
|
+
c.bold(
|
|
218
|
+
`Recommended layout — First-Fit-Decreasing by duration (≤ ${fmtMemKb(rec.budgetBytes / KB)} mem, ≤ ${rec.coreShare} concurrent)`,
|
|
219
|
+
),
|
|
220
|
+
);
|
|
221
|
+
rec.recommended.bins.forEach((bin, i) => {
|
|
222
|
+
const flag = bin.exceedsBudget ? c.red(' ⚠ lone step exceeds budget (cannot split)') : '';
|
|
223
|
+
L.push(
|
|
224
|
+
` ${c.green('phase ' + (i + 1))} ${fmtDuration(bin.wallclockMs)} · ${bin.steps.length} step(s) · Σ peak ${fmtMemKb(bin.memBytes / KB)}${flag}`,
|
|
225
|
+
);
|
|
226
|
+
for (const s of [...bin.steps].sort((a, b) => b.durationMs - a.durationMs)) L.push(stepLine(s));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Summary
|
|
230
|
+
const saved = rec.observedMakespanMs - rec.recommended.makespanMs;
|
|
231
|
+
L.push('');
|
|
232
|
+
L.push(c.bold('Estimated makespan'));
|
|
233
|
+
L.push(` observed (sequential phases): ${fmtDuration(rec.observedMakespanMs)}`);
|
|
234
|
+
const delta =
|
|
235
|
+
saved > 0
|
|
236
|
+
? c.green(`(−${fmtDuration(saved)})`)
|
|
237
|
+
: saved < 0
|
|
238
|
+
? c.red(`(+${fmtDuration(-saved)})`)
|
|
239
|
+
: c.dim('(no change)');
|
|
240
|
+
L.push(` recommended (packed): ${fmtDuration(rec.recommended.makespanMs)} ${delta}`);
|
|
241
|
+
L.push(` theoretical floor (∞ RAM): ${fmtDuration(rec.optimalMakespanMs)}`);
|
|
242
|
+
L.push('');
|
|
243
|
+
L.push(
|
|
244
|
+
c.dim(
|
|
245
|
+
' Note: advisory only — peaks are per-process maxima summed conservatively, and packing ignores\n' +
|
|
246
|
+
' inter-phase data dependencies (e.g. build → post-build checks). Validate against a real run.',
|
|
247
|
+
),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
return L.join('\n');
|
|
251
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computeBudget,
|
|
3
|
+
usableSteps,
|
|
4
|
+
observedTimeline,
|
|
5
|
+
packPhases,
|
|
6
|
+
recommendPhases,
|
|
7
|
+
formatRecommendationReport,
|
|
8
|
+
} from './recommend-phases.js';
|
|
9
|
+
|
|
10
|
+
const GB_KB = 1024 * 1024; // 1 GB expressed in KB
|
|
11
|
+
|
|
12
|
+
// A small synthetic run loosely modelled on the finalyzerui worktree profile.
|
|
13
|
+
const payload = {
|
|
14
|
+
success: true,
|
|
15
|
+
commands: [
|
|
16
|
+
{ command: 'build', phase: 'build', success: true, durationMs: 123000, memoryKb: 3.5 * GB_KB },
|
|
17
|
+
{ command: 'type-check', phase: 'build', success: true, durationMs: 28000, memoryKb: 1.9 * GB_KB },
|
|
18
|
+
{ command: 'test-ci', phase: 'tests', success: true, durationMs: 92000, memoryKb: 1.3 * GB_KB },
|
|
19
|
+
{ command: 'build-storybook', phase: 'storybook', success: true, durationMs: 97000, memoryKb: 6.8 * GB_KB },
|
|
20
|
+
{ command: 'lint-ci', phase: 'lint', success: true, durationMs: 6500, memoryKb: 0.3 * GB_KB },
|
|
21
|
+
{ command: 'disabled-thing', phase: 'lint', success: true, durationMs: 0, skipReason: 'disabled' },
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('computeBudget', () => {
|
|
26
|
+
test('derives budget and core share from host inputs and fan-out', () => {
|
|
27
|
+
const b = computeBudget({ totalMemBytes: 16 * 1024 ** 3, cores: 10, fanout: 1, memSafety: 0.8 });
|
|
28
|
+
expect(b.budgetBytes).toBeCloseTo(16 * 1024 ** 3 * 0.8);
|
|
29
|
+
expect(b.coreShare).toBe(8); // (10 - 2) / 1
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('fan-out divides both memory and core budgets', () => {
|
|
33
|
+
const b = computeBudget({ totalMemBytes: 32 * 1024 ** 3, cores: 12, fanout: 2, memSafety: 0.8 });
|
|
34
|
+
expect(b.budgetBytes).toBeCloseTo((32 * 1024 ** 3 * 0.8) / 2);
|
|
35
|
+
expect(b.coreShare).toBe(5); // floor((12 - 2) / 2)
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('budgetMb overrides the computed budget; core share never drops below 1', () => {
|
|
39
|
+
const b = computeBudget({ totalMemBytes: 8 * 1024 ** 3, cores: 2, budgetMb: 4096 });
|
|
40
|
+
expect(b.budgetBytes).toBe(4096 * 1024 * 1024);
|
|
41
|
+
expect(b.coreShare).toBe(1);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('usableSteps', () => {
|
|
46
|
+
test('drops skipped/untimed commands and normalises fields', () => {
|
|
47
|
+
const steps = usableSteps(payload);
|
|
48
|
+
expect(steps.map((s) => s.command)).not.toContain('disabled-thing');
|
|
49
|
+
expect(steps).toHaveLength(5);
|
|
50
|
+
expect(steps.every((s) => s.durationMs > 0)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('observedTimeline', () => {
|
|
55
|
+
test('groups by phase, sums concurrent peaks, flags over-budget phases', () => {
|
|
56
|
+
const steps = usableSteps(payload);
|
|
57
|
+
const budgetBytes = 4 * 1024 ** 3; // 4 GB
|
|
58
|
+
const tl = observedTimeline(steps, budgetBytes);
|
|
59
|
+
const build = tl.find((p) => p.name === 'build');
|
|
60
|
+
// build (3.5) + type-check (1.9) = 5.4 GB > 4 GB budget
|
|
61
|
+
expect(build.overBudget).toBe(true);
|
|
62
|
+
expect(build.wallclockMs).toBe(123000); // max of its members
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('packPhases (First-Fit-Decreasing)', () => {
|
|
67
|
+
test('keeps each bin under the memory budget and respects core share', () => {
|
|
68
|
+
const steps = usableSteps(payload);
|
|
69
|
+
const budgetBytes = 12.8 * 1024 ** 3; // 16 GB laptop @ 0.8
|
|
70
|
+
const bins = packPhases(steps, budgetBytes, 8);
|
|
71
|
+
for (const bin of bins) {
|
|
72
|
+
expect(bin.memBytes).toBeLessThanOrEqual(budgetBytes);
|
|
73
|
+
expect(bin.steps.length).toBeLessThanOrEqual(8);
|
|
74
|
+
}
|
|
75
|
+
// longest step seeds the first bin
|
|
76
|
+
expect(bins[0].steps[0].command).toBe('build');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a lone oversized step gets its own bin and is flagged', () => {
|
|
80
|
+
const big = [{ command: 'huge', phase: 'p', durationMs: 1000, memoryKb: 10 * GB_KB }];
|
|
81
|
+
const bins = packPhases(big, 4 * 1024 ** 3, 8);
|
|
82
|
+
expect(bins).toHaveLength(1);
|
|
83
|
+
expect(bins[0].exceedsBudget).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('recommendPhases', () => {
|
|
88
|
+
test('packed makespan beats the sequential-phase makespan on a constrained host', () => {
|
|
89
|
+
const rec = recommendPhases(payload, { totalMemBytes: 16 * 1024 ** 3, cores: 10, fanout: 1 });
|
|
90
|
+
expect(rec.recommended.makespanMs).toBeLessThan(rec.observedMakespanMs);
|
|
91
|
+
expect(rec.optimalMakespanMs).toBe(123000); // single longest step
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('warns when no memory metrics are present', () => {
|
|
95
|
+
const rec = recommendPhases({
|
|
96
|
+
commands: [{ command: 'a', phase: 'p', success: true, durationMs: 1000 }],
|
|
97
|
+
});
|
|
98
|
+
expect(rec.warnings.join(' ')).toMatch(/memory/i);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('empty results produce a graceful warning, no throw', () => {
|
|
102
|
+
const rec = recommendPhases({ commands: [] });
|
|
103
|
+
expect(rec.steps).toHaveLength(0);
|
|
104
|
+
expect(rec.warnings.join(' ')).toMatch(/nothing to recommend/i);
|
|
105
|
+
expect(() => formatRecommendationReport(rec)).not.toThrow();
|
|
106
|
+
});
|
|
107
|
+
});
|