scripts-orchestrator 2.0.0 → 2.3.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 CHANGED
@@ -39,13 +39,14 @@ Create a configuration file (default: `scripts-orchestrator.config.js`) that def
39
39
 
40
40
  ```javascript
41
41
  {
42
- command: 'command_name', // The npm script to run
43
- description: 'Description', // Optional description
44
- status: 'enabled', // 'enabled' or 'disabled'
45
- attempts: 1, // Number of retry attempts
46
- dependencies: [], // Array of dependent commands
47
- background: false, // Whether to run in background
48
- health_check: { // Health check configuration
42
+ command: 'command_name', // The npm script to run
43
+ description: 'Description', // Optional description
44
+ status: 'enabled', // 'enabled' or 'disabled'
45
+ attempts: 1, // Number of retry attempts
46
+ dependencies: [], // Array of dependent commands
47
+ background: false, // Whether to run in background
48
+ kill_command: 'kill_storybook', // Optional kill command to kill the process
49
+ health_check: { // Health check configuration
49
50
  url: 'http://localhost:port',
50
51
  max_attempts: 20,
51
52
  interval: 2000
@@ -191,7 +192,6 @@ See [versions](./docs/versions.md)
191
192
  ## Roadmap
192
193
  - Better UX to indicate what is happening
193
194
  - Tests to avoid regression
194
- - Retry should append to the log file
195
195
  - Run any shell command rather than assume the command is specified in package.json (? tentative)
196
196
 
197
197
 
@@ -1,4 +1,3 @@
1
- import { spawn } from 'child_process';
2
1
  import { log } from './logger.js';
3
2
 
4
3
  export class HealthCheck {
@@ -6,22 +5,46 @@ export class HealthCheck {
6
5
  this.logger = log;
7
6
  }
8
7
 
8
+ /**
9
+ * Static method to make a simple HTTP/HTTPS request
10
+ * @param {string} url - The URL to check
11
+ * @param {number} timeout - Request timeout in milliseconds (default: 5000)
12
+ * @returns {Promise<{statusCode: number|null, success: boolean, error?: string}>}
13
+ */
14
+ static async makeHttpRequest(url, timeout = 5000) {
15
+ try {
16
+ // Use Node.js http/https module instead of curl for cross-platform compatibility
17
+ const urlObj = new URL(url);
18
+ const isHttps = urlObj.protocol === 'https:';
19
+ const httpModule = isHttps ? await import('https') : await import('http');
20
+
21
+ return await new Promise((resolve) => {
22
+ const req = httpModule.default.get(url, (res) => {
23
+ resolve({ statusCode: res.statusCode, success: true });
24
+ res.destroy(); // Close the response stream
25
+ });
26
+
27
+ req.on('error', (error) => {
28
+ resolve({ statusCode: null, success: false, error: error.message });
29
+ });
30
+
31
+ req.setTimeout(timeout, () => {
32
+ req.destroy();
33
+ resolve({ statusCode: null, success: false, error: 'Timeout' });
34
+ });
35
+ });
36
+ } catch (error) {
37
+ return { statusCode: null, success: false, error: error.message };
38
+ }
39
+ }
40
+
9
41
  async waitForUrl({url, maxAttempts = 20, interval = 2000, silent=false}) {
10
42
  !silent && this.logger.info(`Waiting for ${url} to be available...`);
11
43
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
12
44
  try {
13
- const result = await new Promise((resolve) => {
14
- const curl = spawn('curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', url]);
15
- let output = '';
16
- curl.stdout.on('data', (data) => {
17
- output += data.toString();
18
- });
19
- curl.on('close', (code) => {
20
- resolve({ code, output });
21
- });
22
- });
45
+ const result = await HealthCheck.makeHttpRequest(url, 5000);
23
46
 
24
- if (result.code === 0 && result.output === '200') {
47
+ if (result.success && result.statusCode === 200) {
25
48
  !silent && this.logger.success(`${url} is available`);
26
49
  return true;
27
50
  }
@@ -52,6 +52,7 @@ export class Orchestrator {
52
52
  should_retry,
53
53
  process_tracking = false,
54
54
  health_check,
55
+ kill_command,
55
56
  } = commandConfig;
56
57
 
57
58
  const startTime = Date.now();
@@ -87,6 +88,7 @@ export class Orchestrator {
87
88
  url: checkUrl,
88
89
  startedByScript: false,
89
90
  process_tracking,
91
+ kill_command,
90
92
  });
91
93
  this.commandTimings.set(command, Date.now() - startTime);
92
94
  visited.delete(command);
@@ -144,12 +146,14 @@ export class Orchestrator {
144
146
  await new Promise((resolve) => setTimeout(resolve, 1000));
145
147
  }
146
148
 
147
- const { success, output } = await this.processManager.runCommand(
148
- attempt === 1 ? command : retry_command || command,
149
+ const { success, output } = await this.processManager.runCommand({
150
+ cmd: attempt === 1 ? command : retry_command || command,
149
151
  logFile,
150
152
  background,
151
- health_check,
152
- );
153
+ healthCheck: health_check,
154
+ kill_command,
155
+ isRetry: attempt > 1,
156
+ });
153
157
  commandOutput = output;
154
158
  result = success;
155
159
 
@@ -175,6 +179,16 @@ export class Orchestrator {
175
179
 
176
180
  if (commandFailed) {
177
181
  this.failedCommands.push(command);
182
+
183
+ // Cleanup any background processes for this failed command
184
+ if (background) {
185
+ this.logger.warn(`Command ${command} failed after all attempts. Cleaning up background processes.`);
186
+ try {
187
+ await this.processManager.cleanupCommand(command);
188
+ } catch (cleanupError) {
189
+ this.logger.error(`Failed to cleanup processes for ${command}: ${cleanupError.message}`);
190
+ }
191
+ }
178
192
  }
179
193
 
180
194
  this.commandTimings.set(command, Date.now() - startTime);
@@ -2,6 +2,7 @@ import { spawn } from 'child_process';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
4
  import { log } from './logger.js';
5
+ import { HealthCheck } from './health-check.js';
5
6
 
6
7
 
7
8
  export class ProcessManager {
@@ -11,17 +12,18 @@ export class ProcessManager {
11
12
  this.backgroundProcessesDetails = [];
12
13
  }
13
14
 
14
- addBackgroundProcess({ command, url, startedByScript, process_tracking }) {
15
+ addBackgroundProcess({ command, url, startedByScript, process_tracking, kill_command }) {
15
16
  this.logger.verbose(`Adding background process: ${command} (${url})`);
16
17
  this.backgroundProcessesDetails.push({
17
18
  command,
18
19
  url,
19
20
  startedByScript,
20
21
  process_tracking,
22
+ kill_command,
21
23
  });
22
24
  }
23
25
 
24
- async runCommand(cmd, logFile, background = false, healthCheck = null) {
26
+ async runCommand({ cmd, logFile, background = false, healthCheck = null, kill_command = null, isRetry = false }) {
25
27
  const LOGS_DIR = path.resolve(process.cwd(), 'scripts-orchestrator-logs');
26
28
  const LOG_FILE = logFile || path.join(LOGS_DIR, `${cmd}.log`);
27
29
 
@@ -31,8 +33,12 @@ export class ProcessManager {
31
33
  fs.mkdirSync(LOGS_DIR, { recursive: true });
32
34
  }
33
35
 
34
- this.logger.verbose(`Clearing log file at ${LOG_FILE}`);
35
- fs.writeFileSync(LOG_FILE, ''); // Clear the log file
36
+ if (!isRetry) {
37
+ this.logger.verbose(`Clearing log file at ${LOG_FILE}`);
38
+ fs.writeFileSync(LOG_FILE, ''); // Clear the log file
39
+ } else {
40
+ this.logger.verbose(`Appending to existing log file at ${LOG_FILE} (retry attempt)`);
41
+ }
36
42
  } catch (error) {
37
43
  this.logger.error(`Failed to setup log file: ${error.message}`);
38
44
  return Promise.resolve({ success: false, output: '' });
@@ -42,7 +48,7 @@ export class ProcessManager {
42
48
  this.logger.info(`Running: npm run ${cmd}`);
43
49
 
44
50
  // Create isolated environment for each process
45
- const isolatedEnv = this.createIsolatedEnvironment(cmd);
51
+ const isolatedEnv = this.createIsolatedEnvironment({ command: cmd });
46
52
 
47
53
  const options = {
48
54
  shell: true,
@@ -103,6 +109,7 @@ export class ProcessManager {
103
109
  startTime: Date.now(),
104
110
  url: healthCheck?.url,
105
111
  startedByScript: true,
112
+ kill_command,
106
113
  });
107
114
 
108
115
  this.logger.verbose(`Unreferencing process ${processGroupId}`);
@@ -172,7 +179,7 @@ export class ProcessManager {
172
179
  });
173
180
  }
174
181
 
175
- createIsolatedEnvironment(command) {
182
+ createIsolatedEnvironment({ command }) {
176
183
  // Create a deep copy to avoid any reference sharing
177
184
  const baseEnv = JSON.parse(JSON.stringify(process.env));
178
185
 
@@ -201,121 +208,246 @@ export class ProcessManager {
201
208
 
202
209
  async cleanup() {
203
210
  this.logger.info('\nCleaning up background processes...');
211
+
212
+ // Debug: Log the number of processes we're tracking
213
+ this.logger.info(`- Found ${this.backgroundProcessesDetails.length} background processes to clean up`);
214
+
215
+ // Debug: Log each process details
216
+ this.backgroundProcessesDetails.forEach(({ command, pgid, url, startedByScript, kill_command }, index) => {
217
+ this.logger.verbose(`- Process ${index + 1}: command=${command}, pgid=${pgid}, url=${url}, startedByScript=${startedByScript}, kill_command=${kill_command}`);
218
+ });
219
+
204
220
  const killPromises = this.backgroundProcessesDetails.map(
205
- async ({ command, pgid, url, startedByScript }) => {
206
- if (!startedByScript) {
207
- this.logger.verbose(
208
- `- Skipping cleanup for ${command} (${url}) as it was not started by this script`,
209
- );
221
+ async ({ command, pgid, url, startedByScript, kill_command }) => {
222
+ await this.cleanupProcess({ command, pgid, url, startedByScript, kill_command });
223
+ },
224
+ );
225
+
226
+ await Promise.all(killPromises);
227
+ this.backgroundProcesses = [];
228
+ this.backgroundProcessesDetails = [];
229
+ }
230
+
231
+ async cleanupCommand(commandName) {
232
+ this.logger.info(`\nCleaning up processes for command: ${commandName}`);
233
+
234
+ // Find processes for this specific command
235
+ const commandProcesses = this.backgroundProcessesDetails.filter(
236
+ ({ command }) => command === commandName
237
+ );
238
+
239
+ if (commandProcesses.length === 0) {
240
+ this.logger.verbose(`- No background processes found for command: ${commandName}`);
241
+ return;
242
+ }
243
+
244
+ this.logger.verbose(`- Found ${commandProcesses.length} background processes for command: ${commandName}`);
245
+
246
+ const killPromises = commandProcesses.map(
247
+ async ({ command, pgid, url, startedByScript, kill_command }) => {
248
+ await this.cleanupProcess({ command, pgid, url, startedByScript, kill_command });
249
+ }
250
+ );
251
+
252
+ await Promise.all(killPromises);
253
+
254
+ // Remove the cleaned up processes from our tracking arrays
255
+ this.backgroundProcesses = this.backgroundProcesses.filter(pgid =>
256
+ !commandProcesses.some(proc => proc.pgid === pgid)
257
+ );
258
+ this.backgroundProcessesDetails = this.backgroundProcessesDetails.filter(
259
+ ({ command }) => command !== commandName
260
+ );
261
+ }
262
+
263
+ async cleanupProcess({ command, pgid, url, startedByScript, kill_command }) {
264
+ if (!startedByScript) {
265
+ this.logger.verbose(
266
+ `- Skipping cleanup for ${command} (${url}) as it was not started by this script`,
267
+ );
268
+ return;
269
+ }
270
+
271
+ this.logger.verbose(`- Processing cleanup for ${command} (kill_command: ${kill_command})`);
272
+
273
+ // Try custom kill command first if specified
274
+ if (kill_command) {
275
+ try {
276
+ this.logger.verbose(`- Using custom kill command: npm run ${kill_command}`);
277
+ const result = await this.runCommand({ cmd: kill_command, logFile: null, background: false });
278
+ if (result.success) {
279
+ this.logger.verbose(`- Successfully killed ${command} using custom command`);
210
280
  return;
281
+ } else {
282
+ this.logger.verbose('- Custom kill command failed, falling back to process signals');
211
283
  }
284
+ } catch (error) {
285
+ this.logger.verbose(`- Custom kill command error: ${error.message}, falling back`);
286
+ }
287
+ } else {
288
+ this.logger.verbose(`- No kill_command specified for ${command}, using process signals`);
289
+ }
290
+
291
+ try {
292
+ // First try to kill the process group
293
+ try {
294
+ process.kill(pgid, 0);
295
+ } catch (error) {
296
+ this.logger.verbose(
297
+ `- Process ${command} (PGID: ${pgid}) already terminated`,
298
+ );
299
+ return;
300
+ }
212
301
 
302
+ // Cross-platform process termination
303
+ const isWindows = process.platform === 'win32';
304
+
305
+ if (isWindows) {
306
+ // Windows: use taskkill to terminate process tree
213
307
  try {
214
- // First try to kill the process group
308
+ const killProcess = spawn('taskkill', ['/F', '/T', '/PID', pgid.toString()]);
309
+ await new Promise((resolve) => {
310
+ killProcess.on('close', resolve);
311
+ });
312
+ this.logger.verbose(`- Terminated background process: ${command} (PID: ${pgid})`);
313
+ return;
314
+ } catch (killError) {
315
+ this.logger.verbose(`- Failed to use taskkill, falling back to process.kill: ${killError.message}`);
316
+ }
317
+ }
318
+
319
+ // Unix/Linux/macOS or Windows fallback: Try SIGTERM first
320
+ process.kill(pgid, 'SIGTERM');
321
+
322
+ await new Promise((resolve, reject) => {
323
+ const timeout = setTimeout(() => {
324
+ clearInterval(checkInterval);
325
+ reject(new Error('Process termination timeout'));
326
+ }, 5000);
327
+
328
+ const checkInterval = setInterval(() => {
215
329
  try {
216
330
  process.kill(pgid, 0);
217
331
  } catch (error) {
218
- this.logger.verbose(
219
- `- Process ${command} (PGID: ${pgid}) already terminated`,
220
- );
221
- return;
332
+ clearInterval(checkInterval);
333
+ clearTimeout(timeout);
334
+ resolve();
222
335
  }
336
+ }, 100);
337
+ });
338
+ this.logger.verbose(
339
+ `- Terminated background process: ${command} (PGID: ${pgid})`,
340
+ );
341
+ } catch (error) {
342
+ this.logger.verbose(`- Failed to terminate process group: ${error.message}`);
343
+ }
223
344
 
224
- // Try SIGTERM first
225
- process.kill(pgid, 'SIGTERM');
226
-
227
- await new Promise((resolve, reject) => {
228
- const timeout = setTimeout(() => {
229
- clearInterval(checkInterval);
230
- reject(new Error('Process termination timeout'));
231
- }, 5000);
232
-
233
- const checkInterval = setInterval(() => {
234
- try {
235
- process.kill(pgid, 0);
236
- } catch (error) {
237
- clearInterval(checkInterval);
238
- clearTimeout(timeout);
239
- resolve();
240
- }
241
- }, 100);
242
- });
243
- this.logger.verbose(
244
- `- Terminated background process: ${command} (PGID: ${pgid})`,
245
- );
246
- } catch (error) {
247
- this.logger.verbose(`- Failed to terminate process group: ${error.message}`);
248
- }
249
-
250
- // Check if the URL is still responding after termination attempt
251
- if (url) {
345
+ // Check if the URL is still responding after termination attempt
346
+ if (url) {
347
+ try {
348
+ const urlObj = new URL(url);
349
+ const port = urlObj.port || (urlObj.protocol === 'https:' ? '443' : '80');
350
+
351
+ // Use shared HTTP utility for cross-platform compatibility
352
+ const urlResult = await HealthCheck.makeHttpRequest(url, 2000);
353
+
354
+ if (urlResult.success && urlResult.statusCode === 200) {
355
+ this.logger.verbose(`- URL ${url} is still responding after termination, finding process on port ${port}`);
356
+
357
+ // Find and kill process using the port - cross-platform approach
252
358
  try {
253
- const urlObj = new URL(url);
254
- const port = urlObj.port || (urlObj.protocol === 'https:' ? '443' : '80');
359
+ const isWindows = process.platform === 'win32';
360
+ let findPortCmd, findPortArgs;
361
+
362
+ if (isWindows) {
363
+ // Windows: use netstat
364
+ findPortCmd = 'netstat';
365
+ findPortArgs = ['-ano'];
366
+ } else {
367
+ // Unix/Linux/macOS: use lsof
368
+ findPortCmd = 'lsof';
369
+ findPortArgs = ['-i', `:${port}`, '-t'];
370
+ }
255
371
 
256
- const curl = spawn('curl', ['-s', '-o', '/dev/null', '-w', '%{http_code}', url]);
372
+ const findProcess = spawn(findPortCmd, findPortArgs);
257
373
  const result = await new Promise((resolve) => {
258
374
  let output = '';
259
- curl.stdout.on('data', (data) => {
375
+ findProcess.stdout.on('data', (data) => {
260
376
  output += data.toString();
261
377
  });
262
- curl.on('close', (code) => {
378
+ findProcess.on('close', (code) => {
263
379
  resolve({ code, output });
264
380
  });
265
381
  });
266
382
 
267
- if (result.code === 0 && result.output === '200') {
268
- this.logger.verbose(`- URL ${url} is still responding after termination, finding process on port ${port}`);
383
+ if (result.code === 0 && result.output.trim()) {
384
+ let pids = [];
269
385
 
270
- // Find and kill process using the port
271
- try {
272
- const lsof = spawn('lsof', ['-i', `:${port}`, '-t']);
273
- const result = await new Promise((resolve) => {
274
- let output = '';
275
- lsof.stdout.on('data', (data) => {
276
- output += data.toString();
277
- });
278
- lsof.on('close', (code) => {
279
- resolve({ code, output });
280
- });
281
- });
282
-
283
- if (result.code === 0 && result.output.trim()) {
284
- const pids = result.output.trim().split('\n');
285
- for (const pid of pids) {
286
- try {
287
- process.kill(parseInt(pid), 'SIGKILL');
288
- this.logger.verbose(`- Killed process (PID: ${pid}) using port ${port}`);
289
- } catch (killError) {
290
- if (killError.code !== 'ESRCH') {
291
- this.logger.error(`- Failed to kill process (PID: ${pid}): ${killError.message}`);
292
- }
386
+ if (isWindows) {
387
+ // Parse netstat output to find PIDs for the specific port
388
+ const lines = result.output.split('\n');
389
+ for (const line of lines) {
390
+ if (line.includes(`:${port} `) && line.includes('LISTENING')) {
391
+ const parts = line.trim().split(/\s+/);
392
+ const pid = parts[parts.length - 1];
393
+ if (pid && !isNaN(pid)) {
394
+ pids.push(pid);
293
395
  }
294
396
  }
295
397
  }
296
- } catch (lsofError) {
297
- this.logger.error(`- Failed to find process using port ${port}: ${lsofError.message}`);
398
+ } else {
399
+ // lsof output is already just PIDs
400
+ pids = result.output.trim().split('\n');
401
+ }
402
+
403
+ for (const pid of pids) {
404
+ try {
405
+ if (isWindows) {
406
+ // Windows: use taskkill
407
+ const killProcess = spawn('taskkill', ['/F', '/PID', pid]);
408
+ await new Promise((resolve) => {
409
+ killProcess.on('close', resolve);
410
+ });
411
+ } else {
412
+ // Unix/Linux/macOS: use process.kill
413
+ process.kill(parseInt(pid), 'SIGKILL');
414
+ }
415
+ this.logger.verbose(`- Killed process (PID: ${pid}) using port ${port}`);
416
+ } catch (killError) {
417
+ if (killError.code !== 'ESRCH') {
418
+ this.logger.error(`- Failed to kill process (PID: ${pid}): ${killError.message}`);
419
+ }
420
+ }
298
421
  }
299
422
  }
300
- } catch (error) {
301
- this.logger.verbose(`- URL check failed: ${error.message}`);
423
+ } catch (portError) {
424
+ this.logger.error(`- Failed to find process using port ${port}: ${portError.message}`);
302
425
  }
303
426
  }
427
+ } catch (error) {
428
+ this.logger.verbose(`- URL check failed: ${error.message}`);
429
+ }
430
+ }
304
431
 
305
- // Final attempt to kill the process group with SIGKILL
306
- try {
307
- process.kill(pgid, 'SIGKILL');
308
- } catch (error) {
309
- if (error.code !== 'ESRCH') {
310
- this.logger.error(`- Failed to kill process group with SIGKILL: ${error.message}`);
311
- }
312
- }
313
- },
314
- );
315
-
316
- await Promise.all(killPromises);
317
- this.backgroundProcesses = [];
318
- this.backgroundProcessesDetails = [];
432
+ // Final attempt to kill the process group
433
+ try {
434
+ const isWindows = process.platform === 'win32';
435
+
436
+ if (isWindows) {
437
+ // Windows: force kill with taskkill
438
+ const killProcess = spawn('taskkill', ['/F', '/T', '/PID', pgid.toString()]);
439
+ await new Promise((resolve) => {
440
+ killProcess.on('close', resolve);
441
+ });
442
+ } else {
443
+ // Unix/Linux/macOS: use SIGKILL
444
+ process.kill(pgid, 'SIGKILL');
445
+ }
446
+ } catch (error) {
447
+ if (error.code !== 'ESRCH') {
448
+ this.logger.error(`- Failed to kill process group: ${error.message}`);
449
+ }
450
+ }
319
451
  }
320
452
  }
321
453
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scripts-orchestrator",
3
- "version": "2.0.0",
3
+ "version": "2.3.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",
@@ -1,83 +1,115 @@
1
- export default [
2
- {
3
- command: 'build',
4
- description: 'Build the project',
5
- status: 'enabled',
6
- attempts: 1,
7
- },
8
- {
9
- command: 'test-ci',
10
- description: 'Run unit tests',
11
- status: 'enabled',
12
- attempts: 2,
13
- should_retry: (output) => {
14
- // Check for actual test failures in the summary
15
- const testSummaryMatch = output.match(/Test Suites:.*?(\d+) failed/);
16
- const hasTestFailures =
17
- testSummaryMatch && parseInt(testSummaryMatch[1]) > 0;
1
+ export default {
2
+ phases: [
3
+ {
4
+ name: 'build',
5
+ parallel: [
6
+ {
7
+ command: 'build',
8
+ description: 'Build the project',
9
+ status: 'enabled',
10
+ attempts: 1,
11
+ },
12
+ {
13
+ command: 'stylelint',
14
+ description: 'Run stylelint checks',
15
+ status: 'enabled',
16
+ },
17
+ { command: 'lint', description: 'Run lint checks', status: 'enabled' },
18
+ {
19
+ command: 'jscpd',
20
+ description: 'Run code duplication checks',
21
+ status: 'enabled',
22
+ },
23
+ ],
24
+ },
25
+ {
26
+ name: 'storybook tests',
27
+ parallel: [
28
+ {
29
+ command: 'test-storybook',
30
+ description: 'Run Storybook tests',
31
+ status: 'enabled',
32
+ attempts: 2,
33
+ dependencies: [
34
+ {
35
+ command: 'storybook_silent',
36
+ background: true,
37
+ wait: 5000,
38
+ kill_command: 'kill_storybook',
39
+ dependencies: [],
40
+ // Add process tracking
41
+ process_tracking: true,
42
+ // Add health check
43
+ health_check: {
44
+ url: 'http://localhost:6006',
45
+ max_attempts: 20,
46
+ interval: 2000,
47
+ },
48
+ },
49
+ ],
50
+ },
51
+ ],
52
+ },
53
+ {
54
+ name: 'unit tests',
55
+ parallel: [
56
+ {
57
+ command: 'test-ci',
58
+ description: 'Run unit tests',
59
+ status: 'enabled',
60
+ attempts: 2,
61
+ should_retry: (output) => {
62
+ // Check for test failures in both formats
63
+ const testSuiteFailureMatch = output.match(
64
+ /Test Suites:.*?\(\d+\) failed/,
65
+ );
66
+ const individualTestFailureMatch =
67
+ output.match(/✘\s*(\d+)\s*failing/);
68
+
69
+ const hasTestSuiteFailures =
70
+ testSuiteFailureMatch && parseInt(testSuiteFailureMatch[1]) > 0;
71
+ const hasIndividualTestFailures =
72
+ individualTestFailureMatch &&
73
+ parseInt(individualTestFailureMatch[1]) > 0;
74
+
75
+ const hasTestFailures =
76
+ hasTestSuiteFailures || hasIndividualTestFailures;
18
77
 
19
- // Check for coverage failures
20
- const coverageSummaryMatch = output.match(
21
- /Jest: "global" coverage threshold/,
22
- );
23
- const hasCoverageFailures = coverageSummaryMatch !== null;
78
+ // Check for "Test suite failed to run" in logs
79
+ if (output.includes('Test suite failed to run')) {
80
+ console.error('Certain tests could not be run');
81
+ return false; // Don't retry if certain tests could not be run
82
+ }
24
83
 
25
- if (!hasTestFailures && hasCoverageFailures) {
26
- console.log(
27
- 'Tests have passed but coverage thresholds have not been met',
28
- );
29
- return false; // Don't retry if only coverage failed
30
- }
84
+ if (!hasTestFailures) {
85
+ console.log(
86
+ 'Tests have passed but coverage thresholds have not been met',
87
+ );
88
+ return false; // Don't retry if only coverage failed
89
+ }
31
90
 
32
- return hasTestFailures; // Only retry if there are actual test failures
91
+ return hasTestFailures; // Only retry if there are actual test failures
92
+ },
93
+ },
94
+ ],
33
95
  },
34
- },
35
- {
36
- command: 'test-storybook',
37
- description: 'Run Storybook tests',
38
- status: 'enabled',
39
- attempts: 2,
40
- dependencies: [
41
- {
42
- command: 'storybook_silent',
43
- background: true,
44
- wait: 5000,
45
- kill_script: 'kill_storybook',
46
- dependencies: [],
47
- // Add process tracking
48
- process_tracking: true,
49
- // Add health check
50
- health_check: {
51
- url: 'http://localhost:6006',
52
- max_attempts: 20,
53
- interval: 2000,
96
+ {
97
+ name: 'playwright',
98
+ parallel: [
99
+ {
100
+ command: 'playwright_ci',
101
+ description: 'Run Playwright tests',
102
+ status: 'enabled',
103
+ attempts: 1, //Playwright internally retries in CI mode
104
+ dependencies: [
105
+ {
106
+ command: 'dev',
107
+ background: true,
108
+ url: 'http://localhost:5173',
109
+ },
110
+ ],
54
111
  },
55
- },
56
- ],
57
- },
58
- {
59
- command: 'stylelint',
60
- description: 'Run stylelint checks',
61
- status: 'enabled',
62
- },
63
- { command: 'lint', description: 'Run lint checks', status: 'enabled' },
64
- {
65
- command: 'jscpd',
66
- description: 'Run code duplication checks',
67
- status: 'enabled',
68
- },
69
- {
70
- command: 'playwright_ci',
71
- description: 'Run Playwright tests',
72
- status: 'enabled',
73
- attempts: 1, //Playwright internally retries in CI mode
74
- dependencies: [
75
- {
76
- command: 'dev',
77
- background: true,
78
- url: 'http://localhost:5173',
79
- kill: 'application_end',
80
- },
81
- ],
82
- },
83
- ];
112
+ ],
113
+ },
114
+ ],
115
+ };