qunitx-cli 0.9.1 → 0.9.2

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/cli.js CHANGED
@@ -27,5 +27,13 @@ process.title = 'qunitx';
27
27
  import('./lib/commands/run.js'),
28
28
  ]);
29
29
 
30
- return await run(config);
30
+ try {
31
+ return await run(config);
32
+ } catch (error) {
33
+ console.error(error);
34
+ // Flush stdout before exit so any queued console.log writes (e.g. from WS testEnd
35
+ // handlers that fired before the exception) are not lost when process.exit() runs.
36
+ process.exitCode = 1;
37
+ process.stdout.write('\n', () => process.exit(1));
38
+ }
31
39
  })();
package/deno.lock CHANGED
@@ -10,13 +10,13 @@
10
10
  "npm:esbuild@~0.27.3": "0.27.4",
11
11
  "npm:express@^5.2.1": "5.2.1",
12
12
  "npm:js-yaml@^4.1.1": "4.1.1",
13
- "npm:picomatch@*": "4.0.3",
14
- "npm:picomatch@^4.0.3": "4.0.3",
13
+ "npm:picomatch@*": "4.0.4",
14
+ "npm:picomatch@^4.0.4": "4.0.4",
15
15
  "npm:playwright-core@^1.58.2": "1.58.2",
16
16
  "npm:prettier@^3.8.1": "3.8.1",
17
- "npm:qunitx@^1.0.3": "1.0.3",
18
- "npm:ws@*": "8.19.0",
19
- "npm:ws@^8.19.0": "8.19.0"
17
+ "npm:qunitx@^1.0.4": "1.0.4",
18
+ "npm:ws@*": "8.20.0",
19
+ "npm:ws@^8.20.0": "8.20.0"
20
20
  },
21
21
  "jsr": {
22
22
  "@std/fmt@1.0.9": {
@@ -473,8 +473,8 @@
473
473
  "path-to-regexp@8.3.0": {
474
474
  "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="
475
475
  },
476
- "picomatch@4.0.3": {
477
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="
476
+ "picomatch@4.0.4": {
477
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="
478
478
  },
479
479
  "playwright-core@1.58.2": {
480
480
  "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
@@ -497,8 +497,8 @@
497
497
  "side-channel"
498
498
  ]
499
499
  },
500
- "qunitx@1.0.3": {
501
- "integrity": "sha512-LdCQ0Sh85IvLqcAIIbIlNNC3NGEMlkuDUQl1gk3AH2d3Dv7HbEYEG+RW786c8w5gMfPh8gx1+PJKbuN2jqQf+g=="
500
+ "qunitx@1.0.4": {
501
+ "integrity": "sha512-iu3enOfQo5Ul5As+KFiN1Zm1kd8MWeeNcHFUbZpKYQn+y6f6V4yiDeehVc5LFSUFBOkutjnqHzZiqInCf0MutA=="
502
502
  },
503
503
  "range-parser@1.2.1": {
504
504
  "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
@@ -615,8 +615,8 @@
615
615
  "wrappy@1.0.2": {
616
616
  "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
617
617
  },
618
- "ws@8.19.0": {
619
- "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="
618
+ "ws@8.20.0": {
619
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="
620
620
  }
621
621
  },
622
622
  "workspace": {
@@ -631,11 +631,11 @@
631
631
  "npm:esbuild@~0.27.3",
632
632
  "npm:express@^5.2.1",
633
633
  "npm:js-yaml@^4.1.1",
634
- "npm:picomatch@^4.0.3",
634
+ "npm:picomatch@^4.0.4",
635
635
  "npm:playwright-core@^1.58.2",
636
636
  "npm:prettier@^3.8.1",
637
- "npm:qunitx@^1.0.3",
638
- "npm:ws@^8.19.0"
637
+ "npm:qunitx@^1.0.4",
638
+ "npm:ws@^8.20.0"
639
639
  ]
640
640
  }
641
641
  }
@@ -10,7 +10,7 @@ import readBoilerplate from '../utils/read-boilerplate.js';
10
10
  */
11
11
  export default async function generateTestFiles() {
12
12
  const projectRoot = await findProjectRoot();
13
- const moduleName = process.argv[3]; // TODO: classify this maybe in future
13
+ const moduleName = process.argv[3];
14
14
  const path =
15
15
  process.argv[3].endsWith('.js') || process.argv[3].endsWith('.ts')
16
16
  ? `${projectRoot}/${process.argv[3]}`
@@ -9,34 +9,25 @@ import readBoilerplate from '../utils/read-boilerplate.js';
9
9
  export default async function initializeProject() {
10
10
  const projectRoot = await findProjectRoot();
11
11
  const oldPackageJSON = JSON.parse(await fs.readFile(`${projectRoot}/package.json`));
12
- const htmlPaths = process.argv.slice(2).reduce(
13
- (result, arg) => {
14
- if (arg.endsWith('.html')) {
15
- result.push(arg);
16
- }
17
-
18
- return result;
19
- },
20
- oldPackageJSON.qunitx && oldPackageJSON.qunitx.htmlPaths ? oldPackageJSON.qunitx.htmlPaths : [],
21
- );
22
- const newQunitxConfig = Object.assign(
23
- defaultProjectConfigValues,
24
- htmlPaths.length > 0 ? { htmlPaths } : { htmlPaths: ['test/tests.html'] },
25
- oldPackageJSON.qunitx,
26
- );
12
+ const existingQunitx = oldPackageJSON.qunitx || {};
13
+ const cliHtmlPaths = process.argv.slice(2).filter((arg) => arg.endsWith('.html'));
14
+ const config = Object.assign({}, defaultProjectConfigValues, existingQunitx, {
15
+ htmlPaths:
16
+ cliHtmlPaths.length > 0 ? cliHtmlPaths : existingQunitx.htmlPaths || ['test/tests.html'],
17
+ });
27
18
 
28
19
  await Promise.all([
29
- writeTestsHTML(projectRoot, newQunitxConfig, oldPackageJSON),
30
- rewritePackageJSON(projectRoot, newQunitxConfig, oldPackageJSON),
20
+ writeTestsHTML(projectRoot, config, oldPackageJSON),
21
+ rewritePackageJSON(projectRoot, config, oldPackageJSON),
31
22
  writeTSConfigIfNeeded(projectRoot),
32
23
  ]);
33
24
  }
34
25
 
35
- async function writeTestsHTML(projectRoot, newQunitxConfig, oldPackageJSON) {
26
+ async function writeTestsHTML(projectRoot, config, oldPackageJSON) {
36
27
  const testHTMLTemplateBuffer = await readBoilerplate('setup/tests.hbs');
37
28
 
38
29
  return await Promise.all(
39
- newQunitxConfig.htmlPaths.map(async (htmlPath) => {
30
+ config.htmlPaths.map(async (htmlPath) => {
40
31
  const targetPath = `${projectRoot}/${htmlPath}`;
41
32
  if (await pathExists(targetPath)) {
42
33
  return console.log(`${htmlPath} already exists`);
@@ -44,7 +35,7 @@ async function writeTestsHTML(projectRoot, newQunitxConfig, oldPackageJSON) {
44
35
  const targetDirectory = path.dirname(targetPath);
45
36
  const _targetOutputPath = path.relative(
46
37
  targetDirectory,
47
- `${projectRoot}/${newQunitxConfig.output}/tests.js`,
38
+ `${projectRoot}/${config.output}/tests.js`,
48
39
  );
49
40
  const testHTMLTemplate = testHTMLTemplateBuffer.replace(
50
41
  '{{applicationName}}',
@@ -60,8 +51,8 @@ async function writeTestsHTML(projectRoot, newQunitxConfig, oldPackageJSON) {
60
51
  );
61
52
  }
62
53
 
63
- async function rewritePackageJSON(projectRoot, newQunitxConfig, oldPackageJSON) {
64
- const newPackageJSON = Object.assign(oldPackageJSON, { qunitx: newQunitxConfig });
54
+ async function rewritePackageJSON(projectRoot, config, oldPackageJSON) {
55
+ const newPackageJSON = Object.assign(oldPackageJSON, { qunitx: config });
65
56
 
66
57
  await fs.writeFile(`${projectRoot}/package.json`, JSON.stringify(newPackageJSON, null, 2));
67
58
  }
@@ -140,28 +140,39 @@ function buildFilteredTests(filteredTests, outputPath, config) {
140
140
  async function runTestInsideHTMLFile(filePath, { page, server, browser }, config) {
141
141
  let QUNIT_RESULT;
142
142
  let targetError;
143
+ let timeoutHandle;
143
144
  try {
144
145
  console.log('#', blue(`QUnitX running: http://localhost:${config.port}${filePath}`));
145
146
 
146
- const testsDone = new Promise((resolve) => {
147
- config._testRunDone = resolve;
147
+ // Single promise driven by the WS handler:
148
+ // config._testRunDone() → tests finished normally
149
+ // config._resetTestTimeout() → reset idle timer; fires as timeout if silent for config.timeout ms
150
+ // This replaces waitForFunction (CDP polling), which raced against WS testEnd messages
151
+ // under load: CDP could win and trigger cleanup before Node.js processed the pending messages.
152
+ const testRaceResult = new Promise((resolve) => {
153
+ config._testRunDone = () => resolve(false);
154
+ config._resetTestTimeout = () => {
155
+ clearTimeout(timeoutHandle);
156
+ timeoutHandle = setTimeout(() => resolve(true), config.timeout);
157
+ };
148
158
  });
149
159
 
150
160
  await page.goto(`http://localhost:${config.port}${filePath}`, {
151
161
  timeout: config.timeout + 10000,
152
162
  });
153
- await Promise.race([
154
- testsDone,
155
- page.waitForFunction(`window.testTimeout >= ${config.timeout}`, null, {
156
- timeout: config.timeout + 10000,
157
- }),
158
- ]);
163
+
164
+ config._resetTestTimeout(); // start idle countdown once the page is loaded
165
+
166
+ await testRaceResult;
159
167
 
160
168
  QUNIT_RESULT = await page.evaluate(() => window.QUNIT_RESULT);
161
169
  } catch (error) {
162
170
  targetError = error;
163
171
  console.log(error);
164
172
  console.error(error);
173
+ } finally {
174
+ clearTimeout(timeoutHandle);
175
+ config._resetTestTimeout = null;
165
176
  }
166
177
 
167
178
  if (!QUNIT_RESULT || QUNIT_RESULT.totalTests === 0) {
@@ -174,6 +185,10 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
174
185
  console.log(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
175
186
  console.error(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
176
187
  await failOnNonWatchMode(config.watch, { server, browser }, config._groupMode);
188
+ } else if (QUNIT_RESULT.failedTests > config.COUNTER.failCount) {
189
+ // Safety net: browser tracked failures that WebSocket events never delivered to Node.js
190
+ // (e.g. WS connection dropped mid-run). Reconcile so the exit code is always correct.
191
+ config.COUNTER.failCount = QUNIT_RESULT.failedTests;
177
192
  }
178
193
  }
179
194
 
@@ -81,6 +81,8 @@ export default async function run(config) {
81
81
  }));
82
82
  const groupCachedContents = groups.map(() => ({ ...cachedContent }));
83
83
 
84
+ console.log('TAP version 13');
85
+
84
86
  // Build all group bundles and write static files while the browser is starting up.
85
87
  const [browser] = await Promise.all([
86
88
  launchBrowser(config),
@@ -93,34 +95,74 @@ export default async function run(config) {
93
95
  ),
94
96
  ),
95
97
  ]);
96
-
97
- console.log('TAP version 13');
98
98
  const TIME_COUNTER = timeCounter();
99
- let hasFatalError = false;
100
99
 
101
- await Promise.allSettled(
102
- groupConfigs.map(async (groupConfig, i) => {
103
- const connections = await setupBrowser(groupConfig, groupCachedContents[i], browser);
104
- groupConfig.expressApp = connections.server;
100
+ // 3-minute per-group deadline. Firefox/WebKit can hang indefinitely in any Playwright
101
+ // operation (browser.newPage, page.evaluate, page.close) when overwhelmed by concurrent
102
+ // pages. Without this outer timeout, one stuck group freezes Promise.allSettled forever.
103
+ // After all groups settle, browser.close() (below) terminates the browser and unblocks
104
+ // any still-pending Playwright calls in background async fns.
105
+ const GROUP_TIMEOUT_MS = 3 * 60 * 1000;
105
106
 
106
- if (config.before) {
107
- await runUserModule(`${process.cwd()}/${config.before}`, groupConfig, 'before');
108
- }
107
+ // Keep the event loop alive during Promise.allSettled. The Chrome child process and its
108
+ // stderr pipe are unref'd (pre-launch-chrome.js). If Chrome crashes during group cleanup,
109
+ // all active handles close and the event loop would drain — exiting silently before
110
+ // allSettled resolves or results are printed. This interval holds the loop open so that
111
+ // unref'd group/page-close timers can still fire normally.
112
+ const keepAlive = setInterval(() => {}, 1000);
109
113
 
110
- try {
111
- await runTestsInBrowser(groupConfig, groupCachedContents[i], connections);
112
- } catch {
113
- hasFatalError = true;
114
- } finally {
115
- await Promise.all([
116
- connections.server && connections.server.close(),
117
- connections.page && connections.page.close(),
118
- ]);
119
- }
114
+ const groupResults = await Promise.allSettled(
115
+ groupConfigs.map((groupConfig, i) => {
116
+ const groupTimeout = new Promise((_, reject) => {
117
+ const t = setTimeout(
118
+ () => reject(new Error(`Group ${i} timed out after ${GROUP_TIMEOUT_MS}ms`)),
119
+ GROUP_TIMEOUT_MS,
120
+ );
121
+ t.unref();
122
+ });
123
+
124
+ return Promise.race([
125
+ (async () => {
126
+ const connections = await setupBrowser(groupConfig, groupCachedContents[i], browser);
127
+ groupConfig.expressApp = connections.server;
128
+
129
+ if (config.before) {
130
+ await runUserModule(`${process.cwd()}/${config.before}`, groupConfig, 'before');
131
+ }
132
+
133
+ try {
134
+ await runTestsInBrowser(groupConfig, groupCachedContents[i], connections);
135
+ } finally {
136
+ await Promise.all([
137
+ connections.server && connections.server.close(),
138
+ connections.page &&
139
+ // Unref'd: the keepAlive interval above holds the event loop open, so this
140
+ // timer still fires if page.close() hangs, without preventing process exit later.
141
+ Promise.race([
142
+ connections.page.close(),
143
+ new Promise((resolve) => {
144
+ const t = setTimeout(resolve, 10000);
145
+ t.unref();
146
+ }),
147
+ ]).catch(() => {}),
148
+ ]);
149
+ }
150
+ })(),
151
+ groupTimeout,
152
+ ]);
120
153
  }),
121
154
  );
122
155
 
123
- await browser.close();
156
+ const exitCode = groupResults.reduce(
157
+ (code, { status, reason }) => {
158
+ if (status !== 'rejected') return code;
159
+ console.error(reason);
160
+ return 1;
161
+ },
162
+ config.COUNTER.failCount > 0 ? 1 : 0,
163
+ );
164
+
165
+ process.exitCode = exitCode;
124
166
 
125
167
  TAPDisplayFinalResult(config.COUNTER, TIME_COUNTER.stop());
126
168
 
@@ -128,7 +170,18 @@ export default async function run(config) {
128
170
  await runUserModule(`${process.cwd()}/${config.after}`, config.COUNTER, 'after');
129
171
  }
130
172
 
131
- process.exit(config.COUNTER.failCount > 0 || hasFatalError ? 1 : 0);
173
+ // Flush stdout then exit. keepAlive holds the event loop open until this callback fires,
174
+ // at which point process.exit() takes over — so clearInterval happens here, not earlier.
175
+ // If the write callback never fires (theoretical), the unref'd exitTimer is the fallback.
176
+ const exitTimer = setTimeout(() => process.exit(exitCode), 5000);
177
+ exitTimer.unref();
178
+ process.stdout.write('\n', () => {
179
+ clearTimeout(exitTimer);
180
+ clearInterval(keepAlive);
181
+ // Close browser after stdout is flushed; fire-and-forget since process.exit follows.
182
+ browser.close().catch(() => {});
183
+ process.exit(exitCode);
184
+ });
132
185
  }
133
186
  }
134
187
 
@@ -77,11 +77,13 @@ export default class HTTPServer {
77
77
  }
78
78
 
79
79
  /**
80
- * Closes the underlying HTTP server.
81
- * @returns {object}
80
+ * Closes the underlying HTTP server and all active connections, returning a
81
+ * Promise that resolves once the server is fully closed.
82
+ * @returns {Promise<void>}
82
83
  */
83
84
  close() {
84
- return this._server.close();
85
+ this._server.closeAllConnections?.();
86
+ return new Promise((resolve) => this._server.close(resolve));
85
87
  }
86
88
 
87
89
  /** Registers a GET route handler. */
@@ -86,9 +86,8 @@ export function handleWatchEvent(config, extensions, event, filePath, onEventFun
86
86
  .then(() => {
87
87
  onFinishFunc ? onFinishFunc(event, filePath) : null;
88
88
  })
89
- .catch(() => {
90
- // TODO: make an index.html to display the error
91
- // error type has to be derived from the error!
89
+ .catch((error) => {
90
+ console.error('#', red('Build error:'), error.message || error);
92
91
  })
93
92
  .finally(() => (config._building = false));
94
93
  }
@@ -22,8 +22,6 @@ export default async function buildFSTree(fileAbsolutePaths, config = {}) {
22
22
  fileAbsolutePaths.map(async (fileAbsolutePath) => {
23
23
  const glob = picomatch.scan(fileAbsolutePath);
24
24
 
25
- // TODO: maybe allow absolute path references
26
-
27
25
  try {
28
26
  if (glob.isGlob) {
29
27
  const fileNames = await readDirRecursive(glob.base, (name) => {
@@ -29,11 +29,13 @@ export default function setupWebServer(
29
29
 
30
30
  if (event === 'connection') {
31
31
  if (!config._groupMode) console.log('TAP version 13');
32
+ config._resetTestTimeout?.();
32
33
  } else if (event === 'testEnd' && !abort) {
33
34
  if (details.status === 'failed') {
34
35
  config.lastFailedTestFiles = config.lastRanTestFiles;
35
36
  }
36
37
 
38
+ config._resetTestTimeout?.();
37
39
  TAPDisplayTestResult(config.COUNTER, details);
38
40
  } else if (event === 'done') {
39
41
  // Signal test completion. TCP ordering guarantees all testEnd messages
@@ -214,7 +216,7 @@ function testRuntimeToInject(port, config) {
214
216
  }
215
217
 
216
218
  function setupQUnit() {
217
- window.QUNIT_RESULT = { totalTests: 0, finishedTests: 0, currentTest: '' };
219
+ window.QUNIT_RESULT = { totalTests: 0, finishedTests: 0, failedTests: 0, currentTest: '' };
218
220
 
219
221
  if (!window.QUnit) {
220
222
  console.log('QUnit not found after WebSocket connected');
@@ -239,6 +241,7 @@ function testRuntimeToInject(port, config) {
239
241
  window.QUnit.on('testEnd', (details) => { // NOTE: https://github.com/qunitjs/qunit/blob/master/src/html-reporter/diff.js
240
242
  window.testTimeout = 0;
241
243
  window.QUNIT_RESULT.finishedTests++;
244
+ if (details.status === 'failed') window.QUNIT_RESULT.failedTests++;
242
245
  window.QUNIT_RESULT.currentTest = null;
243
246
  if (window.IS_PLAYWRIGHT) {
244
247
  window.socket.send(JSON.stringify({ event: 'testEnd', details: details, abort: window.abortQUnit }, getCircularReplacer()));
@@ -251,10 +254,11 @@ function testRuntimeToInject(port, config) {
251
254
  window.QUnit.done((details) => {
252
255
  if (window.IS_PLAYWRIGHT) {
253
256
  window.socket.send(JSON.stringify({ event: 'done', details: details, abort: window.abortQUnit }, getCircularReplacer()));
254
- // Delay the testTimeout fallback so the WS done event can arrive at Node.js first.
255
- // Without this delay, waitForFunction resolves before Node.js processes the done WS
256
- // message, causing the shared COUNTER to miss testEnd results under concurrent load.
257
- window.setTimeout(() => { window.testTimeout = ${config.timeout}; }, 500);
257
+ // Do NOT set testTimeout here. The WS 'done' event (testsDone promise) is the
258
+ // canonical completion signal for Playwright runs. waitForFunction is reserved
259
+ // for true timeouts (test hangs) where testTimeout increments naturally via setInterval.
260
+ // Setting testTimeout after done caused a race: under CI load, waitForFunction could
261
+ // win before Node.js processed the WS done message, dropping all testEnd events.
258
262
  } else {
259
263
  window.testTimeout = ${config.timeout};
260
264
  }
@@ -23,7 +23,7 @@ export default function TAPDisplayTestResult(COUNTER, details) {
23
23
  details.fullName.join(' | '),
24
24
  `# (${details.runtime.toFixed(0)} ms)`,
25
25
  );
26
- details.assertions.reduce((errorCount, assertion, index) => {
26
+ details.assertions.forEach((assertion, index) => {
27
27
  if (!assertion.passed && assertion.todo === false) {
28
28
  COUNTER.errorCount = (COUNTER.errorCount ?? 0) + 1;
29
29
  const stack = assertion.stack?.match(/\(.+\)/g);
@@ -32,7 +32,7 @@ export default function TAPDisplayTestResult(COUNTER, details) {
32
32
  console.log(
33
33
  indentString(
34
34
  dumpYaml({
35
- name: `Assertion #${index + 1}`, // TODO: check what happens on runtime errors
35
+ name: `Assertion #${index + 1}`,
36
36
  actual: assertion.actual
37
37
  ? JSON.parse(JSON.stringify(assertion.actual, getCircularReplacer()))
38
38
  : assertion.actual,
@@ -48,9 +48,7 @@ export default function TAPDisplayTestResult(COUNTER, details) {
48
48
  );
49
49
  console.log(' ...');
50
50
  }
51
-
52
- return errorCount;
53
- }, 0);
51
+ });
54
52
  } else if (details.status === 'passed') {
55
53
  COUNTER.passCount++;
56
54
  console.log(
@@ -14,6 +14,7 @@ export default function listenToKeyboardKey(
14
14
  closure,
15
15
  options = { caseSensitive: false },
16
16
  ) {
17
+ if (!stdin.isTTY) return;
17
18
  stdin.setRawMode(true);
18
19
  stdin.resume();
19
20
  stdin.setEncoding('utf8');
@@ -20,13 +20,22 @@ export default function preLaunchChrome(chromePath, args) {
20
20
  proc.stderr.on('data', (chunk) => {
21
21
  buffer += chunk.toString();
22
22
  const match = buffer.match(CDP_URL_REGEX);
23
- if (match) resolve({ proc, cdpEndpoint: match[1] });
23
+ if (match) {
24
+ // Unref so Chrome's process + stderr pipe don't keep the Node.js event loop alive
25
+ // after all test work is done. Chrome is still killed via process.on('exit').
26
+ proc.unref();
27
+ proc.stderr.unref();
28
+ resolve({ proc, cdpEndpoint: match[1] });
29
+ }
24
30
  });
25
31
 
26
- // Resolve null on any startup failure so launchBrowser falls back to chromium.launch()
32
+ // Resolve null on any startup failure so launchBrowser falls back to chromium.launch().
33
+ // The close handler resolves unconditionally: if Chrome exits before printing its CDP URL
34
+ // (code=0 for a clean exit, code=null for a signal-killed process such as OOM on CI),
35
+ // the original condition `code !== null && code !== 0` would leave the promise pending
36
+ // forever, causing launchBrowser to hang and the event loop to drain silently (exit 0).
37
+ // If Chrome already printed its URL and the promise is resolved, this is a no-op.
27
38
  proc.on('error', () => resolve(null));
28
- proc.on('close', (code) => {
29
- if (code !== null && code !== 0) resolve(null);
30
- });
39
+ proc.on('close', () => resolve(null));
31
40
  });
32
41
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "qunitx-cli",
3
3
  "type": "module",
4
- "version": "0.9.1",
4
+ "version": "0.9.2",
5
5
  "description": "Browser runner for QUnitx: run your qunitx tests in google-chrome",
6
6
  "main": "cli.js",
7
7
  "author": "Izel Nakri",
@@ -25,8 +25,8 @@
25
25
  "changelog:preview": "git-cliff",
26
26
  "changelog:update": "git-cliff --output CHANGELOG.md",
27
27
  "postinstall": "deno install --allow-scripts=npm:playwright-core || true",
28
- "test": "node test/setup.js && FORCE_COLOR=0 node --test test/**/*-test.js",
29
- "test:browser": "node test/setup.js && FORCE_COLOR=0 node --test test/flags/*-test.js test/inputs/*-test.js",
28
+ "test": "node test/setup.js && FORCE_COLOR=0 node --test --test-concurrency=$(node -p 'require(\"os\").availableParallelism()') test/**/*-test.js",
29
+ "test:browser": "node test/setup.js && FORCE_COLOR=0 node --test --test-concurrency=$(node -p 'require(\"os\").availableParallelism()') test/flags/*-test.js test/inputs/*-test.js",
30
30
  "test:sanity-first": "./cli.js test/helpers/failing-tests.js test/helpers/failing-tests.ts",
31
31
  "test:sanity-second": "./cli.js test/helpers/passing-tests.js test/helpers/passing-tests.ts"
32
32
  },
@@ -43,16 +43,16 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "esbuild": "^0.27.3",
46
- "picomatch": "^4.0.3",
46
+ "picomatch": "^4.0.4",
47
47
  "playwright-core": "^1.58.2",
48
- "ws": "^8.19.0"
48
+ "ws": "^8.20.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "cors": "^2.8.6",
52
52
  "express": "^5.2.1",
53
53
  "js-yaml": "^4.1.1",
54
54
  "prettier": "^3.8.1",
55
- "qunitx": "^1.0.3"
55
+ "qunitx": "^1.0.4"
56
56
  },
57
57
  "volta": {
58
58
  "node": "24.14.0"