scripts-orchestrator 2.15.1 → 3.5.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.
@@ -46,6 +46,7 @@ export class ProcessManager {
46
46
  startedByScript,
47
47
  process_tracking,
48
48
  kill_command,
49
+ prefix = 'npm run',
49
50
  }) {
50
51
  this.logger.verbose(`Adding background process: ${command} (${url})`);
51
52
  this.backgroundProcessesDetails.push({
@@ -54,6 +55,7 @@ export class ProcessManager {
54
55
  startedByScript,
55
56
  process_tracking,
56
57
  kill_command,
58
+ prefix,
57
59
  });
58
60
  }
59
61
 
@@ -93,7 +95,13 @@ export class ProcessManager {
93
95
  env = null,
94
96
  reportTime = false,
95
97
  reportMemory = false,
98
+ prefix = 'npm run',
96
99
  }) {
100
+ // Resolve how the command is invoked. A non-empty prefix (e.g. 'npm run') is
101
+ // prepended to the command name; an empty/false prefix runs the command verbatim
102
+ // as a regular shell command. `displayCmd` is what we surface in logs.
103
+ const commandPrefix = prefix ? String(prefix).trim() : '';
104
+ const displayCmd = commandPrefix ? `${commandPrefix} ${cmd}` : cmd;
97
105
  const baseDir = this.logFolder
98
106
  ? path.resolve(this.logFolder)
99
107
  : process.cwd();
@@ -131,12 +139,12 @@ export class ProcessManager {
131
139
  const startTime = Date.now();
132
140
  let timeOutputPath = null;
133
141
  // Build command with environment variables if provided
134
- let fullCommand = `npm run ${cmd}`;
142
+ let fullCommand = displayCmd;
135
143
  if (env && Object.keys(env).length > 0) {
136
144
  const envStr = Object.entries(env)
137
145
  .map(([key, value]) => `${key}=${value}`)
138
146
  .join(' ');
139
- fullCommand = `${envStr} npm run ${cmd}`;
147
+ fullCommand = `${envStr} ${displayCmd}`;
140
148
  }
141
149
  const useTimeWrapper =
142
150
  reportMemory && !background && (process.platform === 'linux' || process.platform === 'darwin');
@@ -286,6 +294,7 @@ export class ProcessManager {
286
294
  url: healthCheck?.url,
287
295
  startedByScript: true,
288
296
  kill_command,
297
+ prefix: commandPrefix,
289
298
  });
290
299
 
291
300
  this.logger.verbose(`Unreferencing process ${processGroupId}`);
@@ -293,7 +302,7 @@ export class ProcessManager {
293
302
 
294
303
  this.logger.stopTask(cmd);
295
304
  this.logger.verbose(
296
- `Background process started: npm run ${cmd} (PGID: ${processGroupId})`,
305
+ `Background process started: ${displayCmd} (PGID: ${processGroupId})`,
297
306
  );
298
307
  return {
299
308
  success: true,
@@ -304,7 +313,7 @@ export class ProcessManager {
304
313
  } catch (error) {
305
314
  if (attempt === maxAttempts) {
306
315
  this.logger.error(
307
- `Failed to start background process: npm run ${cmd}`,
316
+ `Failed to start background process: ${displayCmd}`,
308
317
  );
309
318
  this.logger.verbose(
310
319
  `Final verification attempt failed: ${error.message}`,
@@ -382,7 +391,7 @@ export class ProcessManager {
382
391
 
383
392
  if (code !== 0) {
384
393
  this.logger.error(
385
- `Failed: npm run ${cmd} ❌${durationStr} (exit code: ${code})`,
394
+ `Failed: ${displayCmd} ❌${durationStr} (exit code: ${code})`,
386
395
  );
387
396
  this.logger.verbose(`Process output: ${output}`);
388
397
  resolve({
@@ -392,7 +401,7 @@ export class ProcessManager {
392
401
  memoryKb,
393
402
  });
394
403
  } else {
395
- this.logger.success(`Completed: npm run ${cmd} ✅${durationStr}`);
404
+ this.logger.success(`Completed: ${displayCmd} ✅${durationStr}`);
396
405
  resolve({
397
406
  success: true,
398
407
  output,
@@ -507,13 +516,14 @@ export class ProcessManager {
507
516
  );
508
517
 
509
518
  const killPromises = commandProcesses.map(
510
- async ({ command, pgid, url, startedByScript, kill_command }) => {
519
+ async ({ command, pgid, url, startedByScript, kill_command, prefix }) => {
511
520
  await this.cleanupProcess({
512
521
  command,
513
522
  pgid,
514
523
  url,
515
524
  startedByScript,
516
525
  kill_command,
526
+ prefix,
517
527
  });
518
528
  },
519
529
  );
@@ -529,7 +539,7 @@ export class ProcessManager {
529
539
  );
530
540
  }
531
541
 
532
- async cleanupProcess({ command, pgid, url, startedByScript, kill_command }) {
542
+ async cleanupProcess({ command, pgid, url, startedByScript, kill_command, prefix = 'npm run' }) {
533
543
  if (!startedByScript) {
534
544
  this.logger.verbose(
535
545
  `- Skipping cleanup for ${command} (${url}) as it was not started by this script`,
@@ -544,13 +554,15 @@ export class ProcessManager {
544
554
  // Try custom kill command first if specified
545
555
  if (kill_command) {
546
556
  try {
557
+ const killDisplay = prefix ? `${String(prefix).trim()} ${kill_command}` : kill_command;
547
558
  this.logger.verbose(
548
- `- Using custom kill command: npm run ${kill_command}`,
559
+ `- Using custom kill command: ${killDisplay}`,
549
560
  );
550
561
  const result = await this.runCommand({
551
562
  cmd: kill_command,
552
563
  logFile: null,
553
564
  background: false,
565
+ prefix,
554
566
  });
555
567
  if (result.success) {
556
568
  this.logger.verbose(
@@ -1,4 +1,6 @@
1
1
  import path from 'path';
2
+ import fs from 'fs';
3
+ import os from 'os';
2
4
  import { ProcessManager } from './process-manager.js';
3
5
 
4
6
  describe('ProcessManager.getLogPath', () => {
@@ -20,3 +22,59 @@ describe('ProcessManager.getLogPath', () => {
20
22
  expect(result).toBe(path.resolve(override));
21
23
  });
22
24
  });
25
+
26
+ describe('ProcessManager.runCommand prefix handling', () => {
27
+ let tmpDir;
28
+ let prevCwd;
29
+
30
+ beforeEach(() => {
31
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'so-prefix-'));
32
+ prevCwd = process.cwd();
33
+ process.chdir(tmpDir);
34
+ });
35
+
36
+ afterEach(() => {
37
+ process.chdir(prevCwd);
38
+ fs.rmSync(tmpDir, { recursive: true, force: true });
39
+ });
40
+
41
+ test('runs a regular bash command verbatim when prefix is disabled', async () => {
42
+ const pm = new ProcessManager();
43
+ pm.setLogFolder(tmpDir);
44
+ const marker = 'orchestrator-raw-bash-ok';
45
+ const result = await pm.runCommand({
46
+ cmd: `echo ${marker}`,
47
+ background: false,
48
+ prefix: '',
49
+ });
50
+ expect(result.success).toBe(true);
51
+ const logPath = pm.getLogPath(`echo ${marker}`);
52
+ expect(fs.readFileSync(logPath, 'utf8')).toContain(marker);
53
+ });
54
+
55
+ test('honors a custom prefix by invoking it (failure surfaces a non-zero exit)', async () => {
56
+ const pm = new ProcessManager();
57
+ pm.setLogFolder(tmpDir);
58
+ // With prefix 'npm run' and no package.json script, the command must fail —
59
+ // proving the prefix is actually prepended rather than the command run raw.
60
+ const result = await pm.runCommand({
61
+ cmd: 'definitely-not-a-script',
62
+ background: false,
63
+ prefix: 'npm run',
64
+ });
65
+ expect(result.success).toBe(false);
66
+ });
67
+
68
+ test('supports multi-token shell commands (pipes, &&) when run raw', async () => {
69
+ const pm = new ProcessManager();
70
+ pm.setLogFolder(tmpDir);
71
+ const result = await pm.runCommand({
72
+ cmd: 'printf "a\\nb\\nc\\n" | grep b',
73
+ background: false,
74
+ prefix: '',
75
+ });
76
+ expect(result.success).toBe(true);
77
+ const logPath = pm.getLogPath('printf');
78
+ expect(fs.readFileSync(logPath, 'utf8').trim()).toBe('b');
79
+ });
80
+ });
@@ -27,6 +27,45 @@ export function formatMs(ms) {
27
27
  return `${s}s`;
28
28
  }
29
29
 
30
+ // Compact memory formatting (input in KB): MB up to ~1 GB, then GB.
31
+ export function formatMem(kb) {
32
+ if (kb == null) return '—';
33
+ const mb = kb / 1024;
34
+ if (mb < 1024) return `${mb.toFixed(mb < 10 ? 1 : 0)} MB`;
35
+ return `${(mb / 1024).toFixed(1)} GB`;
36
+ }
37
+
38
+ // Default heat thresholds (fraction of the heaviest command in the run).
39
+ export const DEFAULT_HEAT_THRESHOLDS = { mid: 0.33, high: 0.66 };
40
+
41
+ // Normalize caller-supplied thresholds, clamping to (0,1) and ensuring mid < high.
42
+ // Falls back to a default for any missing/invalid value.
43
+ export function normalizeHeatThresholds(t) {
44
+ const def = DEFAULT_HEAT_THRESHOLDS;
45
+ const clamp = (v, fallback) => (Number.isFinite(v) && v > 0 && v < 1 ? v : fallback);
46
+ let mid = clamp(t?.mid, def.mid);
47
+ let high = clamp(t?.high, def.high);
48
+ if (mid >= high) { mid = def.mid; high = def.high; }
49
+ return { mid, high };
50
+ }
51
+
52
+ // Classify a value relative to the maximum in the run (low / mid / high), so heavy or
53
+ // slow commands can be flagged green→amber→red. Thresholds (fractions of the run's max)
54
+ // are configurable; defaults are used otherwise. Returns null when no value was recorded.
55
+ export function heatLevel(value, max, thresholds = DEFAULT_HEAT_THRESHOLDS) {
56
+ if (value == null || !(max > 0)) return null;
57
+ const frac = value / max;
58
+ if (frac >= thresholds.high) return 'high';
59
+ if (frac >= thresholds.mid) return 'mid';
60
+ return 'low';
61
+ }
62
+
63
+ // Classify a command's memory use relative to the heaviest in the run, so the
64
+ // Gantt can flag which bars are too memory-hungry to run alongside others.
65
+ export function memoryHeat(memoryKb, maxMemory, thresholds = DEFAULT_HEAT_THRESHOLDS) {
66
+ return heatLevel(memoryKb, maxMemory, thresholds);
67
+ }
68
+
30
69
  // Keys that are folded into the synthetic Status column or the Gantt rather than shown raw.
31
70
  const HANDLED_KEYS = new Set(['success', 'startedAt', 'skipReason']);
32
71
 
@@ -83,6 +122,24 @@ function barCellWrap(text, percent, kind) {
83
122
  return `<div class="cellbar"><span>${text}</span><div class="bar ${kind}" style="width:${percent.toFixed(1)}%"></div></div>`;
84
123
  }
85
124
 
125
+ // Duration cell: green→amber→red heat scale relative to the slowest command in the run,
126
+ // applied to both the bar and the value text, so the slowest commands stand out at a glance.
127
+ function durCellWrap(durationMs, maxDuration, thresholds) {
128
+ const heat = heatLevel(durationMs, maxDuration, thresholds);
129
+ const heatCls = heat ? ` dur-${heat}` : '';
130
+ const percent = pct(durationMs, maxDuration);
131
+ return `<div class="cellbar"><span class="durval${heatCls}">${formatMs(durationMs)}</span><div class="bar dur${heatCls}" style="width:${percent.toFixed(1)}%"></div></div>`;
132
+ }
133
+
134
+ // Memory cell: same green→amber→red heat scale as the Gantt, applied to both the
135
+ // bar and the value text, so the table and the Gantt read identically.
136
+ function memCellWrap(memoryKb, maxMemory, thresholds) {
137
+ const heat = memoryHeat(memoryKb, maxMemory, thresholds);
138
+ const heatCls = heat ? ` mem-${heat}` : '';
139
+ const percent = pct(memoryKb, maxMemory);
140
+ return `<div class="cellbar"><span class="memval${heatCls}">${formatMem(memoryKb)}</span><div class="bar mem${heatCls}" style="width:${percent.toFixed(1)}%"></div></div>`;
141
+ }
142
+
86
143
  // Render a single command cell for a given column key.
87
144
  function renderCell(key, c, ctx) {
88
145
  const v = c[key];
@@ -92,9 +149,9 @@ function renderCell(key, c, ctx) {
92
149
  case 'phase':
93
150
  return v != null ? escapeHtml(v) : '—';
94
151
  case 'durationMs':
95
- return v != null ? barCellWrap(formatMs(v), pct(v, ctx.maxDuration), 'dur') : '—';
152
+ return v != null ? durCellWrap(v, ctx.maxDuration, ctx.durationHeatThresholds) : '—';
96
153
  case 'memoryKb':
97
- return v != null ? barCellWrap(`${(v / 1024).toFixed(1)} MB`, pct(v, ctx.maxMemory), 'mem') : '—';
154
+ return v != null ? memCellWrap(v, ctx.maxMemory, ctx.heatThresholds) : '—';
98
155
  case 'logFile':
99
156
  return v
100
157
  ? `<a class="logref" href="file://${escapeHtml(path.resolve(process.cwd(), v))}" title="${escapeHtml(v)}"><code>${escapeHtml(v)}</code></a>`
@@ -130,7 +187,7 @@ function renderPhasesTable(phases, ctx) {
130
187
  }
131
188
 
132
189
  // Critical-path Gantt for one command list (uses observed startedAt + durationMs).
133
- function renderGantt(commands, overallDurationMs, hasPhases) {
190
+ function renderGantt(commands, overallDurationMs, hasPhases, ctx = {}) {
134
191
  const timed = (commands || [])
135
192
  .filter((c) => c.startedAt && c.durationMs != null)
136
193
  .map((c) => ({ ...c, _start: Date.parse(c.startedAt) }))
@@ -160,15 +217,29 @@ function renderGantt(commands, overallDurationMs, hasPhases) {
160
217
  }
161
218
  const criticalTotal = timed.filter((c) => criticalKeys.has(c.command)).reduce((sum, c) => sum + c.durationMs, 0);
162
219
 
220
+ const anyMemory = timed.some((c) => c.memoryKb != null);
221
+
163
222
  const ganttRow = (c) => {
164
223
  const offsetPct = ((c._start - runStart) / spanMs) * 100;
165
224
  const widthPct = Math.max((c.durationMs / spanMs) * 100, 0.5);
166
225
  const crit = criticalKeys.has(c.command);
167
226
  const cls = c.success === false ? 'failed' : crit ? 'crit' : '';
168
- return `<div class="gantt-row"><div class="gantt-label" title="${escapeHtml(c.command)}${c.phase ? ` — ${escapeHtml(c.phase)}` : ''}">${crit ? '★ ' : ''}${escapeHtml(c.command)}</div><div class="gantt-track"><div class="gantt-bar ${cls}" style="left:${offsetPct.toFixed(2)}%;width:${widthPct.toFixed(2)}%" title="${escapeHtml(c.command)} — ${formatMs(c.durationMs)}"><span class="gantt-dur">${formatMs(c.durationMs)}</span></div></div></div>`;
227
+ const heat = memoryHeat(c.memoryKb, ctx.maxMemory, ctx.heatThresholds);
228
+ const heatCls = heat ? ` mem-${heat}` : '';
229
+ const memTitle = c.memoryKb != null ? ` · ${formatMem(c.memoryKb)}` : '';
230
+ const memChip = anyMemory
231
+ ? `<div class="gantt-mem${heat ? ` mem-${heat}` : ''}">${c.memoryKb != null ? formatMem(c.memoryKb) : '—'}</div>`
232
+ : '';
233
+ return `<div class="gantt-row"><div class="gantt-label" title="${escapeHtml(c.command)}${c.phase ? ` — ${escapeHtml(c.phase)}` : ''}">${crit ? '★ ' : ''}${escapeHtml(c.command)}</div>${memChip}<div class="gantt-track"><div class="gantt-bar ${cls}${heatCls}" style="left:${offsetPct.toFixed(2)}%;width:${widthPct.toFixed(2)}%" title="${escapeHtml(c.command)} — ${formatMs(c.durationMs)}${memTitle}"><span class="gantt-dur">${formatMs(c.durationMs)}</span></div></div></div>`;
169
234
  };
170
235
 
171
- return `<section><h3>Actual Critical Path</h3><p class="muted">★ marks each phase's bottleneck — the chain that drives wall-clock time. Critical path ≈ <strong>${formatMs(criticalTotal)}</strong>${overallDurationMs != null ? ` of ${formatMs(overallDurationMs)} total` : ''}.</p><div class="gantt">${timed.map(ganttRow).join('')}</div></section>`;
236
+ const t = ctx.heatThresholds || DEFAULT_HEAT_THRESHOLDS;
237
+ const memLegend = anyMemory
238
+ ? ' Bar outline + memory column show peak RSS (green→red, relative to the heaviest command:' +
239
+ ` amber ≥ ${Math.round(t.mid * 100)}%, red ≥ ${Math.round(t.high * 100)}%) so you can spot which` +
240
+ ' parallel commands are too memory-hungry to overlap.'
241
+ : '';
242
+ return `<section><h3>Actual Critical Path</h3><p class="muted">★ marks each phase's bottleneck — the chain that drives wall-clock time. Critical path ≈ <strong>${formatMs(criticalTotal)}</strong>${overallDurationMs != null ? ` of ${formatMs(overallDurationMs)} total` : ''}.${memLegend}</p><div class="gantt">${timed.map(ganttRow).join('')}</div></section>`;
172
243
  }
173
244
 
174
245
  function renderMeta(meta) {
@@ -188,7 +259,7 @@ function renderSection(section, columns, ctx) {
188
259
  const inner =
189
260
  renderMeta(section.meta) +
190
261
  renderPhasesTable(section.phases, ctx) +
191
- renderGantt(section.commands, section.overallDurationMs, hasPhases) +
262
+ renderGantt(section.commands, section.overallDurationMs, hasPhases, ctx) +
192
263
  renderCommandsTable(section.commands, columns, ctx) +
193
264
  (section.sections || []).map((s) => renderSection(s, columns, ctx)).join('');
194
265
  return `<details class="section" open><summary><span class="badge ${st.kind}">${escapeHtml(st.label)}</span> <span class="section-title">${escapeHtml(section.title || 'Section')}</span><span class="section-dur">${dur}</span></summary><div class="section-body">${inner}</div></details>`;
@@ -224,6 +295,20 @@ tr.running { background: rgba(59,130,246,0.08); }
224
295
  .cellbar span { font-size: 0.85em; }
225
296
  .bar { height: 6px; background: #3b82f6; border-radius: 4px; min-width: 2px; margin-top: 2px; }
226
297
  .bar.mem { background: #8b5cf6; }
298
+ /* Duration heat: same green→amber→red scale, relative to the slowest command. */
299
+ .bar.dur.dur-low { background: #22c55e; }
300
+ .bar.dur.dur-mid { background: #f59e0b; }
301
+ .bar.dur.dur-high { background: #ef4444; }
302
+ .durval.dur-low { color: #22c55e; }
303
+ .durval.dur-mid { color: #f59e0b; }
304
+ .durval.dur-high { color: #ef4444; font-weight: 600; }
305
+ /* Memory heat: same green→amber→red scale as the Gantt's bar outline. */
306
+ .bar.mem.mem-low { background: #22c55e; }
307
+ .bar.mem.mem-mid { background: #f59e0b; }
308
+ .bar.mem.mem-high { background: #ef4444; }
309
+ .memval.mem-low { color: #22c55e; }
310
+ .memval.mem-mid { color: #f59e0b; }
311
+ .memval.mem-high { color: #ef4444; font-weight: 600; }
227
312
  code { font-size: 0.9em; background: #333; padding: 0.1rem 0.3rem; border-radius: 4px; }
228
313
  a.logref { color: #60a5fa; text-decoration: none; }
229
314
  a.logref:hover { text-decoration: underline; }
@@ -235,6 +320,14 @@ a.logref:hover { text-decoration: underline; }
235
320
  .gantt-bar { position: absolute; top: 0; height: 16px; background: #3b82f6; border-radius: 4px; min-width: 2px; display: flex; align-items: center; overflow: hidden; }
236
321
  .gantt-bar.crit { background: #f59e0b; }
237
322
  .gantt-bar.failed { background: #ef4444; }
323
+ /* Memory heat: an inset ring on the bar (doesn't change layout or overlap neighbours). */
324
+ .gantt-bar.mem-low { box-shadow: inset 0 0 0 2px #22c55e; }
325
+ .gantt-bar.mem-mid { box-shadow: inset 0 0 0 2px #f59e0b; }
326
+ .gantt-bar.mem-high { box-shadow: inset 0 0 0 2px #ef4444; }
327
+ .gantt-mem { width: 64px; flex: 0 0 64px; text-align: right; font-family: ui-monospace, monospace; font-size: 0.7rem; color: #888; }
328
+ .gantt-mem.mem-low { color: #22c55e; }
329
+ .gantt-mem.mem-mid { color: #f59e0b; }
330
+ .gantt-mem.mem-high { color: #ef4444; font-weight: 600; }
238
331
  .gantt-dur { font-size: 0.65rem; color: #0f0f0f; padding: 0 4px; white-space: nowrap; }
239
332
  details.section { background: #232323; border-radius: 8px; margin-bottom: 0.6rem; padding: 0.25rem 0.75rem; }
240
333
  details.section > summary { cursor: pointer; padding: 0.5rem 0; display: flex; align-items: center; gap: 0.6rem; }
@@ -263,6 +356,9 @@ export function renderReportHtml(payload) {
263
356
  const ctx = {
264
357
  maxDuration: Math.max(0, ...allCommands.map((c) => c.durationMs || 0)),
265
358
  maxMemory: Math.max(0, ...allCommands.map((c) => c.memoryKb || 0)),
359
+ // Heat thresholds travel in the payload so re-rendering a saved JSON (--render) honours them.
360
+ heatThresholds: normalizeHeatThresholds(payload.memoryHeat),
361
+ durationHeatThresholds: normalizeHeatThresholds(payload.durationHeat),
266
362
  };
267
363
 
268
364
  const top = statusOf({ success });
@@ -270,7 +366,7 @@ export function renderReportHtml(payload) {
270
366
 
271
367
  const topBlocks =
272
368
  renderPhasesTable(payload.phases, ctx) +
273
- renderGantt(topCommands, overallDurationMs, hasTopPhases) +
369
+ renderGantt(topCommands, overallDurationMs, hasTopPhases, ctx) +
274
370
  (topCommands.length > 0
275
371
  ? `<section><h3>Commands</h3>${renderCommandsTable(topCommands, columns, ctx)}</section>`
276
372
  : '');
@@ -85,4 +85,124 @@ describe('renderReportHtml', () => {
85
85
  expect(html).toContain('badge warn');
86
86
  expect(html).toContain('NX CACHE');
87
87
  });
88
+
89
+ test('Memory table cell uses the same green→red heat scale as the Gantt', () => {
90
+ const html = renderReportHtml({
91
+ success: true,
92
+ timestamp: 't',
93
+ overallDurationMs: 10000,
94
+ commands: [
95
+ { command: 'light', success: true, durationMs: 2000, memoryKb: 100 * 1024 },
96
+ { command: 'heavy', success: true, durationMs: 3000, memoryKb: 2048 * 1024 },
97
+ ],
98
+ });
99
+ // heaviest cell heat-coloured red; lightest green — both bar and value text
100
+ expect(html).toContain('bar mem mem-high');
101
+ expect(html).toContain('bar mem mem-low');
102
+ expect(html).toContain('memval mem-high');
103
+ expect(html).toContain('memval mem-low');
104
+ });
105
+
106
+ test('memoryHeat thresholds are configurable via payload.memoryHeat', () => {
107
+ // With a low high-threshold (0.4), a command at 50% of peak should read as high.
108
+ const payload = {
109
+ success: true,
110
+ timestamp: 't',
111
+ overallDurationMs: 10000,
112
+ memoryHeat: { mid: 0.2, high: 0.4 },
113
+ commands: [
114
+ { command: 'mid', success: true, startedAt: '2026-06-17T00:00:00.000Z', durationMs: 1000, memoryKb: 500 * 1024 }, // 50% of peak
115
+ { command: 'peak', success: true, startedAt: '2026-06-17T00:00:01.000Z', durationMs: 1000, memoryKb: 1000 * 1024 },
116
+ ],
117
+ };
118
+ const html = renderReportHtml(payload);
119
+ // 50% ≥ configured high (40%) → high; default thresholds (66%) would have made it 'mid'.
120
+ expect(html).toContain('bar mem mem-high');
121
+ expect(html).not.toContain('bar mem mem-mid');
122
+ // legend reflects the configured thresholds
123
+ expect(html).toContain('amber ≥ 20%, red ≥ 40%');
124
+ });
125
+
126
+ test('invalid memoryHeat thresholds fall back to defaults', () => {
127
+ const html = renderReportHtml({
128
+ success: true,
129
+ timestamp: 't',
130
+ overallDurationMs: 10000,
131
+ memoryHeat: { mid: 0.9, high: 0.1 }, // mid >= high → invalid, use defaults
132
+ commands: [
133
+ { command: 'a', success: true, startedAt: '2026-06-17T00:00:00.000Z', durationMs: 1000, memoryKb: 500 * 1024 },
134
+ { command: 'b', success: true, startedAt: '2026-06-17T00:00:01.000Z', durationMs: 1000, memoryKb: 1000 * 1024 },
135
+ ],
136
+ });
137
+ // default thresholds restored: 50% of peak → mid (≥33%, <66%)
138
+ expect(html).toContain('amber ≥ 33%, red ≥ 66%');
139
+ expect(html).toContain('bar mem mem-mid');
140
+ });
141
+
142
+ test('Duration table cell uses a green→red heat scale relative to the slowest command', () => {
143
+ const html = renderReportHtml({
144
+ success: true,
145
+ timestamp: 't',
146
+ overallDurationMs: 10000,
147
+ commands: [
148
+ { command: 'quick', success: true, durationMs: 500, memoryKb: 100 * 1024 },
149
+ { command: 'slow', success: true, durationMs: 9000, memoryKb: 200 * 1024 },
150
+ ],
151
+ });
152
+ // slowest cell heat-coloured red; quickest green — both bar and value text
153
+ expect(html).toContain('bar dur dur-high');
154
+ expect(html).toContain('bar dur dur-low');
155
+ expect(html).toContain('durval dur-high');
156
+ expect(html).toContain('durval dur-low');
157
+ });
158
+
159
+ test('durationHeat thresholds are configurable via payload.durationHeat', () => {
160
+ // With a low high-threshold (0.4), a command at 50% of the slowest should read as high.
161
+ const html = renderReportHtml({
162
+ success: true,
163
+ timestamp: 't',
164
+ overallDurationMs: 10000,
165
+ durationHeat: { mid: 0.2, high: 0.4 },
166
+ commands: [
167
+ { command: 'mid', success: true, durationMs: 500, memoryKb: 100 * 1024 }, // 50% of slowest
168
+ { command: 'slow', success: true, durationMs: 1000, memoryKb: 100 * 1024 },
169
+ ],
170
+ });
171
+ // 50% ≥ configured high (40%) → high; default thresholds (66%) would have made it 'mid'.
172
+ expect(html).toContain('bar dur dur-high');
173
+ expect(html).not.toContain('bar dur dur-mid');
174
+ });
175
+
176
+ test('Gantt flags memory-heavy commands with a heat ring and memory chip', () => {
177
+ const html = renderReportHtml({
178
+ success: true,
179
+ timestamp: 't',
180
+ overallDurationMs: 10000,
181
+ commands: [
182
+ { command: 'light', success: true, startedAt: '2026-06-17T00:00:00.000Z', durationMs: 2000, memoryKb: 100 * 1024 },
183
+ { command: 'heavy', success: true, startedAt: '2026-06-17T00:00:01.000Z', durationMs: 3000, memoryKb: 2048 * 1024 },
184
+ ],
185
+ });
186
+ // heaviest command gets the high-heat ring + bold chip; lightest gets low
187
+ expect(html).toContain('mem-high');
188
+ expect(html).toContain('mem-low');
189
+ // memory value surfaced on the Gantt row itself (no table lookup needed)
190
+ expect(html).toContain('class="gantt-mem');
191
+ expect(html).toContain('2.0 GB');
192
+ });
193
+
194
+ test('Gantt omits the memory chip entirely when no command reports memory', () => {
195
+ const html = renderReportHtml({
196
+ success: true,
197
+ timestamp: 't',
198
+ overallDurationMs: 10000,
199
+ commands: [
200
+ { command: 'a', success: true, startedAt: '2026-06-17T00:00:00.000Z', durationMs: 2000 },
201
+ ],
202
+ });
203
+ // no rendered chip element and no heat ring on the bar
204
+ expect(html).not.toContain('class="gantt-mem');
205
+ expect(html).not.toContain('gantt-bar mem-');
206
+ expect(html).not.toContain('gantt-bar crit mem-');
207
+ });
88
208
  });