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.
@@ -0,0 +1,308 @@
1
+ import path from 'path';
2
+
3
+ // Generic, domain-agnostic HTML renderer for orchestrator-shaped result payloads.
4
+ //
5
+ // The renderer knows ONLY about generic orchestrator concepts: a document has optional
6
+ // `commands` and optional nested `sections` (recursive). It has no notion of workspaces,
7
+ // monorepos, nx, etc. — any such labels arrive purely as data (section.title, section.meta,
8
+ // section.statusLabel) and are rendered opaquely.
9
+ //
10
+ // Columns are discovered as the union of keys across all command entries, so new JSON fields
11
+ // appear automatically. Known keys get rich formatting (badges, bars, links); unknown keys
12
+ // render as plain text.
13
+
14
+ export function escapeHtml(s) {
15
+ if (s == null) return '';
16
+ return String(s)
17
+ .replace(/&/g, '&')
18
+ .replace(/</g, '&lt;')
19
+ .replace(/>/g, '&gt;')
20
+ .replace(/"/g, '&quot;');
21
+ }
22
+
23
+ export function formatMs(ms) {
24
+ if (ms == null || ms === 0) return '—';
25
+ if (ms < 1000) return `${ms}ms`;
26
+ const s = (ms / 1000).toFixed(1);
27
+ return `${s}s`;
28
+ }
29
+
30
+ // Keys that are folded into the synthetic Status column or the Gantt rather than shown raw.
31
+ const HANDLED_KEYS = new Set(['success', 'startedAt', 'skipReason']);
32
+
33
+ // Preferred left-to-right order for known columns (others appended alphabetically).
34
+ const KNOWN_ORDER = ['command', 'phase', 'durationMs', 'memoryKb', 'logFile'];
35
+
36
+ const COLUMN_LABELS = {
37
+ command: 'Command',
38
+ phase: 'Phase',
39
+ durationMs: 'Duration',
40
+ memoryKb: 'Memory',
41
+ logFile: 'Log',
42
+ };
43
+
44
+ function humanizeKey(key) {
45
+ if (COLUMN_LABELS[key]) return COLUMN_LABELS[key];
46
+ return key
47
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
48
+ .replace(/[_-]+/g, ' ')
49
+ .replace(/^./, (c) => c.toUpperCase());
50
+ }
51
+
52
+ // Map a command/section to a generic status kind + label.
53
+ function statusOf(entry) {
54
+ if (entry.statusKind) {
55
+ return { kind: entry.statusKind, label: entry.statusLabel ?? entry.statusKind };
56
+ }
57
+ if (entry.success === null || entry.success === undefined) {
58
+ // For commands: started-but-not-finished is "running"; otherwise unknown → muted.
59
+ if (entry.startedAt && entry.durationMs == null) return { kind: 'running', label: 'Running' };
60
+ if (entry.success === null) return { kind: 'running', label: 'Running' };
61
+ return { kind: 'muted', label: '—' };
62
+ }
63
+ if (entry.success) return { kind: 'ok', label: 'OK' };
64
+ return { kind: 'fail', label: entry.skipReason || 'Failed' };
65
+ }
66
+
67
+ // Collect every command entry in the document (top-level + all nested sections).
68
+ function collectCommands(payload) {
69
+ const all = [];
70
+ const visit = (node) => {
71
+ for (const c of node.commands || []) all.push(c);
72
+ for (const s of node.sections || []) visit(s);
73
+ };
74
+ visit(payload);
75
+ return all;
76
+ }
77
+
78
+ function pct(value, max) {
79
+ return max > 0 && value != null ? (value / max) * 100 : 0;
80
+ }
81
+
82
+ function barCellWrap(text, percent, kind) {
83
+ return `<div class="cellbar"><span>${text}</span><div class="bar ${kind}" style="width:${percent.toFixed(1)}%"></div></div>`;
84
+ }
85
+
86
+ // Render a single command cell for a given column key.
87
+ function renderCell(key, c, ctx) {
88
+ const v = c[key];
89
+ switch (key) {
90
+ case 'command':
91
+ return `<code>${escapeHtml(v)}</code>`;
92
+ case 'phase':
93
+ return v != null ? escapeHtml(v) : '—';
94
+ case 'durationMs':
95
+ return v != null ? barCellWrap(formatMs(v), pct(v, ctx.maxDuration), 'dur') : '—';
96
+ case 'memoryKb':
97
+ return v != null ? barCellWrap(`${(v / 1024).toFixed(1)} MB`, pct(v, ctx.maxMemory), 'mem') : '—';
98
+ case 'logFile':
99
+ return v
100
+ ? `<a class="logref" href="file://${escapeHtml(path.resolve(process.cwd(), v))}" title="${escapeHtml(v)}"><code>${escapeHtml(v)}</code></a>`
101
+ : '—';
102
+ default:
103
+ if (v == null) return '—';
104
+ return escapeHtml(typeof v === 'object' ? JSON.stringify(v) : v);
105
+ }
106
+ }
107
+
108
+ function renderCommandsTable(commands, columns, ctx) {
109
+ if (!commands || commands.length === 0) return '';
110
+ const headerCells = ['<th>Status</th>', ...columns.map((k) => `<th>${escapeHtml(humanizeKey(k))}</th>`)];
111
+ const rows = commands
112
+ .map((c) => {
113
+ const st = statusOf(c);
114
+ const cells = columns.map((k) => `<td>${renderCell(k, c, ctx)}</td>`).join('');
115
+ return `<tr class="${st.kind}"><td><span class="badge ${st.kind}">${escapeHtml(st.label)}</span></td>${cells}</tr>`;
116
+ })
117
+ .join('');
118
+ return `<table><thead><tr>${headerCells.join('')}</tr></thead><tbody>${rows}</tbody></table>`;
119
+ }
120
+
121
+ function renderPhasesTable(phases, ctx) {
122
+ if (!phases || phases.length === 0) return '';
123
+ const rows = phases
124
+ .map((p) => {
125
+ const st = statusOf(p);
126
+ return `<tr class="${st.kind}"><td>${escapeHtml(p.name)}</td><td><span class="badge ${st.kind}">${escapeHtml(st.label)}</span></td><td>${barCellWrap(formatMs(p.durationMs), pct(p.durationMs, ctx.maxDuration), 'dur')}</td></tr>`;
127
+ })
128
+ .join('');
129
+ return `<section><h3>Phases</h3><table><thead><tr><th>Phase</th><th>Status</th><th>Duration</th></tr></thead><tbody>${rows}</tbody></table></section>`;
130
+ }
131
+
132
+ // Critical-path Gantt for one command list (uses observed startedAt + durationMs).
133
+ function renderGantt(commands, overallDurationMs, hasPhases) {
134
+ const timed = (commands || [])
135
+ .filter((c) => c.startedAt && c.durationMs != null)
136
+ .map((c) => ({ ...c, _start: Date.parse(c.startedAt) }))
137
+ .filter((c) => Number.isFinite(c._start))
138
+ .sort((a, b) => a._start - b._start);
139
+ if (timed.length === 0) return '';
140
+
141
+ const runStart = Math.min(...timed.map((c) => c._start));
142
+ const maxEnd = Math.max(...timed.map((c) => c._start + c.durationMs));
143
+ const spanMs = overallDurationMs != null && overallDurationMs > 0 ? overallDurationMs : Math.max(1, maxEnd - runStart);
144
+
145
+ // Critical chain: longest command per phase (phases run sequentially, commands within run parallel).
146
+ const criticalKeys = new Set();
147
+ if (hasPhases) {
148
+ const byPhase = new Map();
149
+ for (const c of timed) {
150
+ const arr = byPhase.get(c.phase) || [];
151
+ arr.push(c);
152
+ byPhase.set(c.phase, arr);
153
+ }
154
+ for (const arr of byPhase.values()) {
155
+ const top = arr.reduce((a, b) => (b.durationMs > a.durationMs ? b : a));
156
+ criticalKeys.add(top.command);
157
+ }
158
+ } else {
159
+ timed.forEach((c) => criticalKeys.add(c.command));
160
+ }
161
+ const criticalTotal = timed.filter((c) => criticalKeys.has(c.command)).reduce((sum, c) => sum + c.durationMs, 0);
162
+
163
+ const ganttRow = (c) => {
164
+ const offsetPct = ((c._start - runStart) / spanMs) * 100;
165
+ const widthPct = Math.max((c.durationMs / spanMs) * 100, 0.5);
166
+ const crit = criticalKeys.has(c.command);
167
+ 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>`;
169
+ };
170
+
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>`;
172
+ }
173
+
174
+ function renderMeta(meta) {
175
+ if (!meta || typeof meta !== 'object') return '';
176
+ const items = Object.entries(meta)
177
+ .filter(([, v]) => v != null && v !== '')
178
+ .map(([k, v]) => `<span class="meta-item"><span class="meta-k">${escapeHtml(humanizeKey(k))}</span> ${escapeHtml(typeof v === 'object' ? JSON.stringify(v) : v)}</span>`)
179
+ .join('');
180
+ return items ? `<div class="meta">${items}</div>` : '';
181
+ }
182
+
183
+ // Render one section as a collapsible block.
184
+ function renderSection(section, columns, ctx) {
185
+ const st = statusOf(section);
186
+ const hasPhases = (section.phases || []).length > 0;
187
+ const dur = section.overallDurationMs != null ? ` · ${formatMs(section.overallDurationMs)}` : '';
188
+ const inner =
189
+ renderMeta(section.meta) +
190
+ renderPhasesTable(section.phases, ctx) +
191
+ renderGantt(section.commands, section.overallDurationMs, hasPhases) +
192
+ renderCommandsTable(section.commands, columns, ctx) +
193
+ (section.sections || []).map((s) => renderSection(s, columns, ctx)).join('');
194
+ 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>`;
195
+ }
196
+
197
+ const STYLES = `
198
+ * { box-sizing: border-box; }
199
+ body { font-family: system-ui, sans-serif; margin: 1rem 2rem; background: #1a1a1a; color: #e0e0e0; }
200
+ h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
201
+ h3 { font-size: 1rem; color: #a0a0a0; margin: 0.75rem 0 0.4rem; }
202
+ .summary { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
203
+ .summary .card { background: #2a2a2a; padding: 1rem 1.25rem; border-radius: 8px; min-width: 140px; }
204
+ .summary .card.ok { border-left: 4px solid #22c55e; }
205
+ .summary .card.fail { border-left: 4px solid #ef4444; }
206
+ .summary .card.running { border-left: 4px solid #3b82f6; }
207
+ .summary .card.warn { border-left: 4px solid #f59e0b; }
208
+ .summary .card.muted { border-left: 4px solid #666; }
209
+ .summary .label { font-size: 0.75rem; text-transform: uppercase; color: #888; }
210
+ .summary .value { font-size: 1.25rem; font-weight: 600; }
211
+ section { margin-bottom: 1rem; }
212
+ table { width: 100%; border-collapse: collapse; background: #2a2a2a; border-radius: 8px; overflow: hidden; margin-bottom: 0.5rem; }
213
+ th, td { padding: 0.5rem 0.75rem; text-align: left; vertical-align: top; }
214
+ th { background: #333; color: #888; font-weight: 600; font-size: 0.8rem; }
215
+ tr.fail { background: rgba(239,68,68,0.08); }
216
+ tr.running { background: rgba(59,130,246,0.08); }
217
+ .badge { padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.8rem; white-space: nowrap; }
218
+ .badge.ok { background: #22c55e; color: #0f0f0f; }
219
+ .badge.fail { background: #ef4444; color: #fff; }
220
+ .badge.running { background: #3b82f6; color: #fff; }
221
+ .badge.warn { background: #f59e0b; color: #0f0f0f; }
222
+ .badge.muted { background: #555; color: #ddd; }
223
+ .cellbar { min-width: 90px; }
224
+ .cellbar span { font-size: 0.85em; }
225
+ .bar { height: 6px; background: #3b82f6; border-radius: 4px; min-width: 2px; margin-top: 2px; }
226
+ .bar.mem { background: #8b5cf6; }
227
+ code { font-size: 0.9em; background: #333; padding: 0.1rem 0.3rem; border-radius: 4px; }
228
+ a.logref { color: #60a5fa; text-decoration: none; }
229
+ a.logref:hover { text-decoration: underline; }
230
+ .muted { color: #888; font-size: 0.85rem; margin: 0 0 0.6rem; }
231
+ .gantt { background: #2a2a2a; border-radius: 8px; padding: 0.6rem 0.75rem; }
232
+ .gantt-row { display: flex; align-items: center; gap: 0.5rem; padding: 2px 0; }
233
+ .gantt-label { width: 280px; flex: 0 0 280px; font-family: ui-monospace, monospace; font-size: 0.78rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #bbb; }
234
+ .gantt-track { position: relative; flex: 1; height: 16px; background: #1f1f1f; border-radius: 4px; }
235
+ .gantt-bar { position: absolute; top: 0; height: 16px; background: #3b82f6; border-radius: 4px; min-width: 2px; display: flex; align-items: center; overflow: hidden; }
236
+ .gantt-bar.crit { background: #f59e0b; }
237
+ .gantt-bar.failed { background: #ef4444; }
238
+ .gantt-dur { font-size: 0.65rem; color: #0f0f0f; padding: 0 4px; white-space: nowrap; }
239
+ details.section { background: #232323; border-radius: 8px; margin-bottom: 0.6rem; padding: 0.25rem 0.75rem; }
240
+ details.section > summary { cursor: pointer; padding: 0.5rem 0; display: flex; align-items: center; gap: 0.6rem; }
241
+ .section-title { font-weight: 600; }
242
+ .section-dur { color: #888; font-size: 0.85rem; }
243
+ .section-body { padding: 0.25rem 0 0.5rem; }
244
+ .meta { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 0.5rem; }
245
+ .meta-item { font-size: 0.8rem; color: #bbb; }
246
+ .meta-k { color: #777; text-transform: uppercase; font-size: 0.7rem; }
247
+ `;
248
+
249
+ export function renderReportHtml(payload) {
250
+ const { success, timestamp, overallDurationMs, title } = payload;
251
+ const topCommands = payload.commands || [];
252
+ const sections = payload.sections || [];
253
+ const hasTopPhases = (payload.phases || []).length > 0;
254
+
255
+ // Global column set + bar scaling across every command in the document.
256
+ const allCommands = collectCommands(payload);
257
+ const present = new Set();
258
+ for (const c of allCommands) for (const k of Object.keys(c)) if (!HANDLED_KEYS.has(k)) present.add(k);
259
+ const ordered = KNOWN_ORDER.filter((k) => present.has(k));
260
+ const extras = [...present].filter((k) => !KNOWN_ORDER.includes(k)).sort();
261
+ const columns = [...ordered, ...extras];
262
+
263
+ const ctx = {
264
+ maxDuration: Math.max(0, ...allCommands.map((c) => c.durationMs || 0)),
265
+ maxMemory: Math.max(0, ...allCommands.map((c) => c.memoryKb || 0)),
266
+ };
267
+
268
+ const top = statusOf({ success });
269
+ const statusLabel = success === null ? 'Running…' : success ? 'Success' : 'Failed';
270
+
271
+ const topBlocks =
272
+ renderPhasesTable(payload.phases, ctx) +
273
+ renderGantt(topCommands, overallDurationMs, hasTopPhases) +
274
+ (topCommands.length > 0
275
+ ? `<section><h3>Commands</h3>${renderCommandsTable(topCommands, columns, ctx)}</section>`
276
+ : '');
277
+
278
+ const sectionBlocks = sections.map((s) => renderSection(s, columns, ctx)).join('');
279
+
280
+ return `<!DOCTYPE html>
281
+ <html lang="en">
282
+ <head>
283
+ <meta charset="utf-8">
284
+ <title>${escapeHtml(title || 'Scripts Orchestrator Report')}</title>
285
+ <style>${STYLES}</style>
286
+ </head>
287
+ <body>
288
+ <h1>${escapeHtml(title || 'Scripts Orchestrator Report')}</h1>
289
+ <div class="summary">
290
+ <div class="card ${top.kind}">
291
+ <div class="label">Status</div>
292
+ <div class="value">${statusLabel}</div>
293
+ </div>
294
+ <div class="card">
295
+ <div class="label">Timestamp</div>
296
+ <div class="value" style="font-size:0.9rem">${escapeHtml(timestamp)}</div>
297
+ </div>
298
+ ${overallDurationMs != null ? `
299
+ <div class="card">
300
+ <div class="label">${success === null ? 'Elapsed' : 'Total time'}</div>
301
+ <div class="value">${formatMs(overallDurationMs)}</div>
302
+ </div>` : ''}
303
+ </div>
304
+ ${topBlocks}
305
+ ${sectionBlocks}
306
+ </body>
307
+ </html>`;
308
+ }
@@ -0,0 +1,88 @@
1
+ import { renderReportHtml } from './report-html.js';
2
+
3
+ describe('renderReportHtml', () => {
4
+ test('renders a flat payload with a commands table and status badges', () => {
5
+ const html = renderReportHtml({
6
+ success: true,
7
+ timestamp: '2026-06-17T00:00:00.000Z',
8
+ overallDurationMs: 5000,
9
+ commands: [
10
+ { command: 'build', success: true, durationMs: 2000, logFile: 'logs/build.log' },
11
+ { command: 'lint', success: false, durationMs: 1000 },
12
+ ],
13
+ });
14
+ expect(html).toContain('<!DOCTYPE html>');
15
+ expect(html).toContain('Total time');
16
+ expect(html).toContain('<code>build</code>');
17
+ expect(html).toContain('badge ok');
18
+ expect(html).toContain('badge fail');
19
+ expect(html).toContain('logs/build.log'); // log link rendered
20
+ });
21
+
22
+ test('running (success: null) renders a Running state, not a failure', () => {
23
+ const html = renderReportHtml({
24
+ success: null,
25
+ timestamp: '2026-06-17T00:00:00.000Z',
26
+ overallDurationMs: 1000,
27
+ commands: [{ command: 'build', success: null, startedAt: '2026-06-17T00:00:00.000Z' }],
28
+ });
29
+ expect(html).toContain('Running…');
30
+ expect(html).toContain('Elapsed'); // elapsed label while running
31
+ expect(html).toContain('badge running');
32
+ expect(html).not.toContain('badge fail');
33
+ });
34
+
35
+ test('renders nested sections as collapsible blocks with their titles', () => {
36
+ const html = renderReportHtml({
37
+ success: false,
38
+ timestamp: '2026-06-17T00:00:00.000Z',
39
+ title: 'Monorepo Quality Report',
40
+ sections: [
41
+ { title: 'Global checks', success: true, commands: [{ command: 'lint', success: true, durationMs: 100 }] },
42
+ {
43
+ title: 'apps/web',
44
+ success: false,
45
+ statusKind: 'fail',
46
+ meta: { path: 'apps/web', state: 'RUNNING' },
47
+ commands: [{ command: 'test', success: false, durationMs: 200 }],
48
+ },
49
+ ],
50
+ });
51
+ expect(html).toContain('Monorepo Quality Report');
52
+ expect(html).toContain('<details');
53
+ expect(html).toContain('Global checks');
54
+ expect(html).toContain('apps/web');
55
+ // meta values rendered opaquely
56
+ expect(html).toContain('RUNNING');
57
+ });
58
+
59
+ test('columns are the union of keys; unknown keys become plain text columns', () => {
60
+ const html = renderReportHtml({
61
+ success: true,
62
+ timestamp: 't',
63
+ commands: [
64
+ { command: 'a', success: true, durationMs: 10, scope: 'global' },
65
+ { command: 'b', success: true, memoryKb: 2048 }, // no durationMs, has memory
66
+ ],
67
+ });
68
+ // unknown key 'scope' becomes a humanized column header + value
69
+ expect(html).toContain('<th>Scope</th>');
70
+ expect(html).toContain('global');
71
+ // memory column present because at least one row has it
72
+ expect(html).toContain('<th>Memory</th>');
73
+ expect(html).toContain('MB');
74
+ // raw 'success'/'startedAt' are never raw columns
75
+ expect(html).not.toContain('<th>Success</th>');
76
+ expect(html).not.toContain('<th>Started At</th>');
77
+ });
78
+
79
+ test('statusKind drives the badge color for custom states', () => {
80
+ const html = renderReportHtml({
81
+ success: true,
82
+ timestamp: 't',
83
+ sections: [{ title: 'cached', statusKind: 'warn', statusLabel: 'NX CACHE', commands: [] }],
84
+ });
85
+ expect(html).toContain('badge warn');
86
+ expect(html).toContain('NX CACHE');
87
+ });
88
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scripts-orchestrator",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
4
4
  "description": "A powerful script orchestrator for running parallel commands with dependency management, background processes, and health checks",
5
5
  "main": "lib/index.js",
6
6
  "type": "module",