scripts-orchestrator 3.0.0 → 3.7.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,339 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import {
5
+ findRepoRoot,
6
+ discoverWorkspaceDirs,
7
+ aggregateWorkspacesReport,
8
+ writeAggregateReport,
9
+ } from './workspaces.js';
10
+
11
+ // Anchored in the past so freshly-written fixture files always have an mtime AFTER the run
12
+ // started (the staleness check considers both the payload timestamp and the file mtime).
13
+ const RUN_STARTED = '2020-01-01T10:00:00.000Z';
14
+ const RUN_STARTED_MS = Date.parse(RUN_STARTED);
15
+ const fresh = (offsetSec = 10) => new Date(RUN_STARTED_MS + offsetSec * 1000).toISOString();
16
+ const old = (offsetSec = 60) => new Date(RUN_STARTED_MS - offsetSec * 1000).toISOString();
17
+
18
+ let repoRoot;
19
+
20
+ function writeJson(absPath, obj) {
21
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
22
+ fs.writeFileSync(absPath, JSON.stringify(obj, null, 2));
23
+ }
24
+
25
+ function makeWorkspace(relDir, { name, orchestratorScript, results } = {}) {
26
+ const wsDir = path.join(repoRoot, relDir);
27
+ writeJson(path.join(wsDir, 'package.json'), {
28
+ name: name ?? relDir,
29
+ scripts:
30
+ orchestratorScript === undefined
31
+ ? { 'scripts-orchestrator': 'run-gate' }
32
+ : orchestratorScript === null
33
+ ? {}
34
+ : { 'scripts-orchestrator': orchestratorScript },
35
+ });
36
+ if (results) {
37
+ writeJson(
38
+ path.join(wsDir, 'logs/scripts-orchestrator-logs/scripts-orchestrator-results.json'),
39
+ results,
40
+ );
41
+ }
42
+ }
43
+
44
+ beforeEach(() => {
45
+ repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'so-ws-'));
46
+ writeJson(path.join(repoRoot, 'package.json'), {
47
+ name: 'root',
48
+ workspaces: ['packages/*', 'apps/*'],
49
+ });
50
+ });
51
+
52
+ afterEach(() => {
53
+ fs.rmSync(repoRoot, { recursive: true, force: true });
54
+ });
55
+
56
+ describe('discoverWorkspaceDirs', () => {
57
+ test('expands dir/* patterns, skips dotdirs and dirs without package.json', () => {
58
+ makeWorkspace('packages/a');
59
+ makeWorkspace('packages/b');
60
+ makeWorkspace('apps/web');
61
+ fs.mkdirSync(path.join(repoRoot, 'packages/.template'), { recursive: true });
62
+ writeJson(path.join(repoRoot, 'packages/.template/package.json'), { name: 't' });
63
+ fs.mkdirSync(path.join(repoRoot, 'packages/no-pkg'), { recursive: true });
64
+
65
+ const dirs = discoverWorkspaceDirs(repoRoot).map((d) => path.relative(repoRoot, d));
66
+ expect(dirs).toEqual(['apps/web', 'packages/a', 'packages/b']);
67
+ });
68
+ });
69
+
70
+ describe('findRepoRoot', () => {
71
+ test('walks up to the package.json that declares workspaces', () => {
72
+ makeWorkspace('packages/a');
73
+ const found = findRepoRoot(path.join(repoRoot, 'packages/a'));
74
+ expect(found).toBe(path.resolve(repoRoot));
75
+ });
76
+ });
77
+
78
+ describe('aggregateWorkspacesReport', () => {
79
+ function rootResults(commands, success = true) {
80
+ writeJson(
81
+ path.join(repoRoot, 'logs/scripts-orchestrator-logs/scripts-orchestrator-results.json'),
82
+ { success, timestamp: fresh(5), overallDurationMs: 1234, commands },
83
+ );
84
+ }
85
+
86
+ function markRunning() {
87
+ writeJson(path.join(repoRoot, 'logs/.scripts-orchestrator-run.json'), {
88
+ startedAt: RUN_STARTED,
89
+ pid: 123,
90
+ });
91
+ }
92
+
93
+ test('classifies ok / fail workspaces and re-roots their log paths', () => {
94
+ rootResults([
95
+ { command: 'lint', phase: 'global quality checks', success: true },
96
+ ]);
97
+ makeWorkspace('apps/web', {
98
+ name: '@app/web',
99
+ results: {
100
+ success: true,
101
+ timestamp: fresh(),
102
+ commands: [{ command: 'test', success: true, logFile: 'logs/scripts-orchestrator-logs/test.log' }],
103
+ },
104
+ });
105
+ makeWorkspace('packages/a', {
106
+ name: '@pkg/a',
107
+ results: {
108
+ success: false,
109
+ timestamp: fresh(),
110
+ commands: [{ command: 'build', success: false }],
111
+ },
112
+ });
113
+
114
+ const report = aggregateWorkspacesReport({ repoRoot });
115
+ expect(report.inProgress).toBe(false);
116
+ expect(report.success).toBe(false); // a failing workspace fails the roll-up
117
+
118
+ const web = report.sections.find((s) => s.title === '@app/web');
119
+ expect(web.statusLabel).toBe('OK');
120
+ expect(web.commands[0].logFile).toBe('apps/web/logs/scripts-orchestrator-logs/test.log');
121
+
122
+ const a = report.sections.find((s) => s.title === '@pkg/a');
123
+ expect(a.statusLabel).toBe('FAIL');
124
+ });
125
+
126
+ test('success when every scope passes', () => {
127
+ rootResults([{ command: 'lint', phase: 'global quality checks', success: true }]);
128
+ makeWorkspace('apps/web', {
129
+ results: { success: true, timestamp: fresh(), commands: [] },
130
+ });
131
+ const report = aggregateWorkspacesReport({ repoRoot });
132
+ expect(report.success).toBe(true);
133
+ });
134
+
135
+ test('global check failure fails the roll-up', () => {
136
+ rootResults(
137
+ [{ command: 'lint', phase: 'global quality checks', success: false }],
138
+ false,
139
+ );
140
+ makeWorkspace('apps/web', { results: { success: true, timestamp: fresh(), commands: [] } });
141
+ const report = aggregateWorkspacesReport({ repoRoot });
142
+ expect(report.success).toBe(false);
143
+ const global = report.sections.find((s) => s.title === 'Global quality checks');
144
+ expect(global.success).toBe(false);
145
+ expect(global.statusLabel).toBe('FAIL');
146
+ });
147
+
148
+ test('in-progress run: success:null workspace is RUNNING, missing is PENDING', () => {
149
+ markRunning();
150
+ rootResults([{ command: 'lint', phase: 'global quality checks', success: true }], null);
151
+ makeWorkspace('apps/web', {
152
+ results: { success: null, timestamp: fresh(), commands: [{ command: 'test', success: null }] },
153
+ });
154
+ makeWorkspace('packages/a'); // no results yet
155
+
156
+ const report = aggregateWorkspacesReport({ repoRoot });
157
+ expect(report.inProgress).toBe(true);
158
+ expect(report.success).toBeNull();
159
+ const web = report.sections.find((s) => s.title === 'apps/web');
160
+ expect(web.statusLabel).toBe('RUNNING');
161
+ expect(web.commands).toHaveLength(1);
162
+ expect(web.commands[0].command).toBe('test');
163
+ expect(web.meta.note).toMatch(/partial command list/i);
164
+ expect(report.sections.find((s) => s.title === 'packages/a').statusLabel).toBe('PENDING');
165
+ });
166
+
167
+ test('global checks read OK as soon as they finish, even mid-run while a workspace is RUNNING', () => {
168
+ markRunning();
169
+ rootResults([{ command: 'lint', phase: 'global quality checks', success: true }], null);
170
+ makeWorkspace('apps/web', {
171
+ results: { success: null, timestamp: fresh(), commands: [{ command: 'test', success: null }] },
172
+ });
173
+
174
+ const report = aggregateWorkspacesReport({ repoRoot });
175
+ expect(report.inProgress).toBe(true);
176
+ const global = report.sections.find((s) => s.title === 'Global quality checks');
177
+ expect(global.statusLabel).toBe('OK');
178
+ expect(global.success).toBe(true);
179
+ });
180
+
181
+ test('global section is RUNNING while one of its own commands is still in flight', () => {
182
+ markRunning();
183
+ rootResults(
184
+ [
185
+ { command: 'lint', phase: 'global quality checks', success: true },
186
+ { command: 'i18n', phase: 'global quality checks', success: null, startedAt: fresh() },
187
+ ],
188
+ null,
189
+ );
190
+ makeWorkspace('apps/web'); // no results yet
191
+
192
+ const report = aggregateWorkspacesReport({ repoRoot });
193
+ const global = report.sections.find((s) => s.title === 'Global quality checks');
194
+ expect(global.statusLabel).toBe('RUNNING');
195
+ expect(global.success).toBeNull();
196
+ });
197
+
198
+ test('after the run, a success:null results file reads as INTERRUPTED, not running', () => {
199
+ rootResults([{ command: 'lint', phase: 'global quality checks', success: true }]);
200
+ makeWorkspace('apps/web', {
201
+ results: { success: null, timestamp: fresh(), commands: [{ command: 'test', success: null }] },
202
+ });
203
+ const report = aggregateWorkspacesReport({ repoRoot });
204
+ expect(report.inProgress).toBe(false);
205
+ expect(report.sections.find((s) => s.title === 'apps/web').statusLabel).toBe('INTERRUPTED');
206
+ expect(report.success).toBe(false);
207
+ });
208
+
209
+ test('end-of-run fire (no run-state): derives run start from root end − duration, not end', () => {
210
+ // The orchestrator clears the run-state file before its final aggregate, and the root
211
+ // results timestamp is the run's END. A workspace that finished mid-run has a timestamp
212
+ // BEFORE that end — it must read OK, not STALE.
213
+ const runStart = RUN_STARTED_MS;
214
+ const runEnd = runStart + 200 * 1000; // 200s run
215
+ writeJson(
216
+ path.join(repoRoot, 'logs/scripts-orchestrator-logs/scripts-orchestrator-results.json'),
217
+ {
218
+ success: true,
219
+ timestamp: new Date(runEnd).toISOString(),
220
+ overallDurationMs: runEnd - runStart,
221
+ commands: [{ command: 'lint', phase: 'global quality checks', success: true }],
222
+ },
223
+ );
224
+ // Workspace finished 60s into the run — before the root end timestamp.
225
+ makeWorkspace('apps/web', {
226
+ results: {
227
+ success: true,
228
+ timestamp: new Date(runStart + 60 * 1000).toISOString(),
229
+ commands: [{ command: 'test', success: true }],
230
+ },
231
+ });
232
+
233
+ const report = aggregateWorkspacesReport({ repoRoot });
234
+ expect(report.inProgress).toBe(false);
235
+ expect(report.sections.find((s) => s.title === 'apps/web').statusLabel).toBe('OK');
236
+ expect(report.success).toBe(true);
237
+ });
238
+
239
+ test('stale results from a previous run are not counted as this run', () => {
240
+ rootResults([{ command: 'lint', phase: 'global quality checks', success: true }]);
241
+ makeWorkspace('apps/web', {
242
+ results: { success: true, timestamp: old(120), commands: [{ command: 'test', success: true }] },
243
+ });
244
+ const report = aggregateWorkspacesReport({ repoRoot });
245
+ const web = report.sections.find((s) => s.title === 'apps/web');
246
+ expect(web.statusLabel).toBe('STALE');
247
+ expect(web.commands).toHaveLength(0);
248
+ });
249
+
250
+ test('cache replay: stale workspace JSON + passing fan-out phase reads as CACHED with commands', () => {
251
+ // Root results carry the workspace fan-out phase (it passed this run) plus globals.
252
+ writeJson(
253
+ path.join(repoRoot, 'logs/scripts-orchestrator-logs/scripts-orchestrator-results.json'),
254
+ {
255
+ success: true,
256
+ timestamp: fresh(5),
257
+ overallDurationMs: 1234,
258
+ commands: [
259
+ { command: 'lint', phase: 'global quality checks', success: true },
260
+ { command: 'fan-out', phase: 'workspace quality gates', success: true },
261
+ ],
262
+ },
263
+ );
264
+ // Workspace results predate this run (cache replay did not re-execute it / rewrite its JSON).
265
+ makeWorkspace('apps/web', {
266
+ results: { success: true, timestamp: old(600), commands: [{ command: 'test', success: true }] },
267
+ });
268
+
269
+ const report = aggregateWorkspacesReport({ repoRoot });
270
+ const web = report.sections.find((s) => s.title === 'apps/web');
271
+ expect(web.statusLabel).toBe('CACHED');
272
+ expect(web.commands).toHaveLength(1); // last-known (cached) commands surfaced
273
+ expect(web.success).toBe(true);
274
+ expect(report.success).toBe(true); // cached pass does not fail the roll-up
275
+ });
276
+
277
+ test('failed fan-out phase does not turn stale workspaces into CACHED', () => {
278
+ writeJson(
279
+ path.join(repoRoot, 'logs/scripts-orchestrator-logs/scripts-orchestrator-results.json'),
280
+ {
281
+ success: false,
282
+ timestamp: fresh(5),
283
+ overallDurationMs: 1234,
284
+ commands: [{ command: 'fan-out', phase: 'workspace quality gates', success: false }],
285
+ },
286
+ );
287
+ makeWorkspace('apps/web', {
288
+ results: { success: true, timestamp: old(600), commands: [{ command: 'test', success: true }] },
289
+ });
290
+ const report = aggregateWorkspacesReport({ repoRoot });
291
+ expect(report.sections.find((s) => s.title === 'apps/web').statusLabel).toBe('STALE');
292
+ });
293
+
294
+ test('a no-op gate (echo "not applicable" or no script) is marked N/A', () => {
295
+ rootResults([{ command: 'lint', phase: 'global quality checks', success: true }]);
296
+ makeWorkspace('apps/web', { orchestratorScript: 'echo "not applicable"' });
297
+ makeWorkspace('packages/a', { orchestratorScript: null });
298
+ const report = aggregateWorkspacesReport({ repoRoot });
299
+ expect(report.sections.find((s) => s.title === 'apps/web').statusLabel).toBe('N/A');
300
+ expect(report.sections.find((s) => s.title === 'packages/a').statusLabel).toBe('N/A');
301
+ });
302
+
303
+ test('exclude option drops a workspace from the report', () => {
304
+ rootResults([]);
305
+ makeWorkspace('packages/.ignored-by-glob');
306
+ makeWorkspace('packages/a', { results: { success: true, timestamp: fresh(), commands: [] } });
307
+ const report = aggregateWorkspacesReport({ repoRoot, exclude: ['packages/a'] });
308
+ expect(report.sections.find((s) => s.title === 'packages/a')).toBeUndefined();
309
+ });
310
+ });
311
+
312
+ describe('writeAggregateReport', () => {
313
+ test('writes JSON + HTML and injects auto-refresh only while in progress', () => {
314
+ writeJson(path.join(repoRoot, 'logs/.scripts-orchestrator-run.json'), {
315
+ startedAt: RUN_STARTED,
316
+ pid: 1,
317
+ });
318
+ writeJson(
319
+ path.join(repoRoot, 'logs/scripts-orchestrator-logs/scripts-orchestrator-results.json'),
320
+ { success: null, timestamp: fresh(), commands: [] },
321
+ );
322
+ makeWorkspace('apps/web', { results: { success: true, timestamp: fresh(), commands: [] } });
323
+
324
+ const { jsonPath, htmlPath } = writeAggregateReport({ repoRoot, title: 'My Roll-up' });
325
+ expect(fs.existsSync(jsonPath)).toBe(true);
326
+ const html = fs.readFileSync(htmlPath, 'utf8');
327
+ expect(html).toContain('My Roll-up');
328
+ expect(html).toContain('http-equiv="refresh"'); // in progress → live refresh
329
+
330
+ // Once finished (no run-state, root results finalized) the refresh meta is gone.
331
+ fs.rmSync(path.join(repoRoot, 'logs/.scripts-orchestrator-run.json'));
332
+ writeJson(
333
+ path.join(repoRoot, 'logs/scripts-orchestrator-logs/scripts-orchestrator-results.json'),
334
+ { success: true, timestamp: fresh(), commands: [] },
335
+ );
336
+ const res = writeAggregateReport({ repoRoot, title: 'My Roll-up' });
337
+ expect(fs.readFileSync(res.htmlPath, 'utf8')).not.toContain('http-equiv="refresh"');
338
+ });
339
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scripts-orchestrator",
3
- "version": "3.0.0",
3
+ "version": "3.7.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",
@@ -9,6 +9,14 @@ export default {
9
9
  // json_results: './scripts-orchestrator-results.json',
10
10
  // Optional: path for HTML report. CLI --html-results overrides.
11
11
  // html_results: './scripts-orchestrator-results.html',
12
+ // Optional: memory heat thresholds for the HTML report — fractions (0–1) of the run's peak memory.
13
+ // A command at/above `high` is coloured red, at/above `mid` amber, below `mid` green, on both the
14
+ // Gantt and the Memory table column. Defaults to { mid: 0.33, high: 0.66 } if omitted/invalid.
15
+ // memory_heat: { mid: 0.33, high: 0.66 },
16
+ // Optional: duration heat thresholds for the HTML report — fractions (0–1) of the run's slowest
17
+ // command. A command at/above `high` is coloured red, at/above `mid` amber, below `mid` green, in
18
+ // the Duration table column. Defaults to { mid: 0.33, high: 0.66 } if omitted/invalid.
19
+ // duration_heat: { mid: 0.33, high: 0.66 },
12
20
  phases: [
13
21
  {
14
22
  name: 'build',