qunitx-cli 0.9.1 → 0.9.3

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.
Files changed (43) hide show
  1. package/{cli.js → cli.ts} +16 -8
  2. package/deno.lock +20 -14
  3. package/lib/commands/{generate.js → generate.ts} +5 -5
  4. package/lib/commands/{help.js → help.ts} +1 -1
  5. package/lib/commands/{init.js → init.ts} +26 -27
  6. package/lib/commands/run/{tests-in-browser.js → tests-in-browser.ts} +51 -22
  7. package/lib/commands/{run.js → run.ts} +100 -39
  8. package/lib/servers/{http.js → http.ts} +88 -35
  9. package/lib/setup/bind-server-to-port.ts +14 -0
  10. package/lib/setup/{browser.js → browser.ts} +14 -18
  11. package/lib/setup/config.ts +55 -0
  12. package/lib/setup/{file-watcher.js → file-watcher.ts} +21 -8
  13. package/lib/setup/{fs-tree.js → fs-tree.ts} +6 -4
  14. package/lib/setup/{keyboard-events.js → keyboard-events.ts} +10 -5
  15. package/lib/setup/{test-file-paths.js → test-file-paths.ts} +10 -4
  16. package/lib/setup/{web-server.js → web-server.ts} +22 -21
  17. package/lib/setup/{write-output-static-files.js → write-output-static-files.ts} +6 -2
  18. package/lib/tap/{display-final-result.js → display-final-result.ts} +6 -3
  19. package/lib/tap/{display-test-result.js → display-test-result.ts} +25 -11
  20. package/lib/tap/{dump-yaml.js → dump-yaml.ts} +19 -5
  21. package/lib/types.ts +61 -0
  22. package/lib/utils/{chromium-args.js → chromium-args.ts} +1 -1
  23. package/lib/utils/{color.js → color.ts} +24 -11
  24. package/lib/utils/{early-chrome.js → early-chrome.ts} +5 -5
  25. package/lib/utils/{find-chrome.js → find-chrome.ts} +2 -2
  26. package/lib/utils/{find-internal-assets-from-html.js → find-internal-assets-from-html.ts} +1 -1
  27. package/lib/utils/{find-project-root.js → find-project-root.ts} +4 -4
  28. package/lib/utils/{indent-string.js → indent-string.ts} +6 -2
  29. package/lib/utils/{listen-to-keyboard-key.js → listen-to-keyboard-key.ts} +12 -7
  30. package/lib/utils/{parse-cli-flags.js → parse-cli-flags.ts} +21 -8
  31. package/lib/utils/{path-exists.js → path-exists.ts} +2 -2
  32. package/lib/utils/{perf-logger.js → perf-logger.ts} +1 -1
  33. package/lib/utils/pre-launch-chrome.ts +45 -0
  34. package/lib/utils/{read-boilerplate.js → read-boilerplate.ts} +1 -1
  35. package/lib/utils/{resolve-port-number-for.js → resolve-port-number-for.ts} +2 -2
  36. package/lib/utils/{run-user-module.js → run-user-module.ts} +6 -2
  37. package/lib/utils/{search-in-parent-directories.js → search-in-parent-directories.ts} +5 -2
  38. package/lib/utils/{time-counter.js → time-counter.ts} +2 -2
  39. package/package.json +16 -15
  40. package/lib/setup/bind-server-to-port.js +0 -9
  41. package/lib/setup/config.js +0 -48
  42. package/lib/utils/pre-launch-chrome.js +0 -32
  43. /package/lib/setup/{default-project-config-values.js → default-project-config-values.ts} +0 -0
@@ -1,10 +1,10 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env -S node --experimental-strip-types
2
2
  import process from 'node:process';
3
- import './lib/utils/early-chrome.js';
4
- import displayHelpOutput from './lib/commands/help.js';
5
- import initializeProject from './lib/commands/init.js';
6
- import generateTestFiles from './lib/commands/generate.js';
7
- import setupConfig from './lib/setup/config.js';
3
+ import './lib/utils/early-chrome.ts';
4
+ import displayHelpOutput from './lib/commands/help.ts';
5
+ import initializeProject from './lib/commands/init.ts';
6
+ import generateTestFiles from './lib/commands/generate.ts';
7
+ import setupConfig from './lib/setup/config.ts';
8
8
 
9
9
  process.title = 'qunitx';
10
10
 
@@ -24,8 +24,16 @@ process.title = 'qunitx';
24
24
  // lets playwright-core start loading while config is being assembled.
25
25
  const [config, { default: run }] = await Promise.all([
26
26
  setupConfig(),
27
- import('./lib/commands/run.js'),
27
+ import('./lib/commands/run.ts'),
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,14 @@
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:typescript@5": "5.9.3",
19
+ "npm:ws@*": "8.20.0",
20
+ "npm:ws@^8.20.0": "8.20.0"
20
21
  },
21
22
  "jsr": {
22
23
  "@std/fmt@1.0.9": {
@@ -473,8 +474,8 @@
473
474
  "path-to-regexp@8.3.0": {
474
475
  "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="
475
476
  },
476
- "picomatch@4.0.3": {
477
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="
477
+ "picomatch@4.0.4": {
478
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="
478
479
  },
479
480
  "playwright-core@1.58.2": {
480
481
  "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
@@ -497,8 +498,8 @@
497
498
  "side-channel"
498
499
  ]
499
500
  },
500
- "qunitx@1.0.3": {
501
- "integrity": "sha512-LdCQ0Sh85IvLqcAIIbIlNNC3NGEMlkuDUQl1gk3AH2d3Dv7HbEYEG+RW786c8w5gMfPh8gx1+PJKbuN2jqQf+g=="
501
+ "qunitx@1.0.4": {
502
+ "integrity": "sha512-iu3enOfQo5Ul5As+KFiN1Zm1kd8MWeeNcHFUbZpKYQn+y6f6V4yiDeehVc5LFSUFBOkutjnqHzZiqInCf0MutA=="
502
503
  },
503
504
  "range-parser@1.2.1": {
504
505
  "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
@@ -603,6 +604,10 @@
603
604
  "mime-types"
604
605
  ]
605
606
  },
607
+ "typescript@5.9.3": {
608
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
609
+ "bin": true
610
+ },
606
611
  "undici-types@7.18.2": {
607
612
  "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="
608
613
  },
@@ -615,8 +620,8 @@
615
620
  "wrappy@1.0.2": {
616
621
  "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
617
622
  },
618
- "ws@8.19.0": {
619
- "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="
623
+ "ws@8.20.0": {
624
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="
620
625
  }
621
626
  },
622
627
  "workspace": {
@@ -631,11 +636,12 @@
631
636
  "npm:esbuild@~0.27.3",
632
637
  "npm:express@^5.2.1",
633
638
  "npm:js-yaml@^4.1.1",
634
- "npm:picomatch@^4.0.3",
639
+ "npm:picomatch@^4.0.4",
635
640
  "npm:playwright-core@^1.58.2",
636
641
  "npm:prettier@^3.8.1",
637
- "npm:qunitx@^1.0.3",
638
- "npm:ws@^8.19.0"
642
+ "npm:qunitx@^1.0.4",
643
+ "npm:typescript@5",
644
+ "npm:ws@^8.20.0"
639
645
  ]
640
646
  }
641
647
  }
@@ -1,8 +1,8 @@
1
1
  import fs from 'node:fs/promises';
2
- import { green } from '../utils/color.js';
3
- import findProjectRoot from '../utils/find-project-root.js';
4
- import pathExists from '../utils/path-exists.js';
5
- import readBoilerplate from '../utils/read-boilerplate.js';
2
+ import { green } from '../utils/color.ts';
3
+ import findProjectRoot from '../utils/find-project-root.ts';
4
+ import pathExists from '../utils/path-exists.ts';
5
+ import readBoilerplate from '../utils/read-boilerplate.ts';
6
6
 
7
7
  /**
8
8
  * Generates a new test file from the boilerplate template.
@@ -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]}`
@@ -1,4 +1,4 @@
1
- import { blue, magenta } from '../utils/color.js';
1
+ import { blue, magenta } from '../utils/color.ts';
2
2
  import pkg from '../../package.json' with { type: 'json' };
3
3
 
4
4
  const highlight = (text) => magenta().bold(text);
@@ -1,42 +1,37 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import findProjectRoot from '../utils/find-project-root.js';
4
- import pathExists from '../utils/path-exists.js';
5
- import defaultProjectConfigValues from '../setup/default-project-config-values.js';
6
- import readBoilerplate from '../utils/read-boilerplate.js';
3
+ import findProjectRoot from '../utils/find-project-root.ts';
4
+ import pathExists from '../utils/path-exists.ts';
5
+ import defaultProjectConfigValues from '../setup/default-project-config-values.ts';
6
+ import readBoilerplate from '../utils/read-boilerplate.ts';
7
7
 
8
8
  /** Bootstraps a new qunitx project: writes the test HTML template, updates package.json, and optionally writes tsconfig.json. */
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(
27
+ projectRoot: string,
28
+ config: { htmlPaths: string[]; output: string },
29
+ oldPackageJSON: Record<string, unknown>,
30
+ ): Promise<unknown[]> {
36
31
  const testHTMLTemplateBuffer = await readBoilerplate('setup/tests.hbs');
37
32
 
38
33
  return await Promise.all(
39
- newQunitxConfig.htmlPaths.map(async (htmlPath) => {
34
+ config.htmlPaths.map(async (htmlPath) => {
40
35
  const targetPath = `${projectRoot}/${htmlPath}`;
41
36
  if (await pathExists(targetPath)) {
42
37
  return console.log(`${htmlPath} already exists`);
@@ -44,7 +39,7 @@ async function writeTestsHTML(projectRoot, newQunitxConfig, oldPackageJSON) {
44
39
  const targetDirectory = path.dirname(targetPath);
45
40
  const _targetOutputPath = path.relative(
46
41
  targetDirectory,
47
- `${projectRoot}/${newQunitxConfig.output}/tests.js`,
42
+ `${projectRoot}/${config.output}/tests.js`,
48
43
  );
49
44
  const testHTMLTemplate = testHTMLTemplateBuffer.replace(
50
45
  '{{applicationName}}',
@@ -60,13 +55,17 @@ async function writeTestsHTML(projectRoot, newQunitxConfig, oldPackageJSON) {
60
55
  );
61
56
  }
62
57
 
63
- async function rewritePackageJSON(projectRoot, newQunitxConfig, oldPackageJSON) {
64
- const newPackageJSON = Object.assign(oldPackageJSON, { qunitx: newQunitxConfig });
58
+ async function rewritePackageJSON(
59
+ projectRoot: string,
60
+ config: unknown,
61
+ oldPackageJSON: Record<string, unknown>,
62
+ ): Promise<void> {
63
+ const newPackageJSON = Object.assign(oldPackageJSON, { qunitx: config });
65
64
 
66
65
  await fs.writeFile(`${projectRoot}/package.json`, JSON.stringify(newPackageJSON, null, 2));
67
66
  }
68
67
 
69
- async function writeTSConfigIfNeeded(projectRoot) {
68
+ async function writeTSConfigIfNeeded(projectRoot: string): Promise<void> {
70
69
  const targetPath = `${projectRoot}/tsconfig.json`;
71
70
  if (!(await pathExists(targetPath))) {
72
71
  const tsConfigTemplate = await readBoilerplate('setup/tsconfig.json');
@@ -1,12 +1,14 @@
1
1
  import fs from 'node:fs/promises';
2
- import { blue } from '../../utils/color.js';
2
+ import { blue } from '../../utils/color.ts';
3
3
  import esbuild from 'esbuild';
4
- import timeCounter from '../../utils/time-counter.js';
5
- import runUserModule from '../../utils/run-user-module.js';
6
- import TAPDisplayFinalResult from '../../tap/display-final-result.js';
4
+ import timeCounter from '../../utils/time-counter.ts';
5
+ import runUserModule from '../../utils/run-user-module.ts';
6
+ import TAPDisplayFinalResult from '../../tap/display-final-result.ts';
7
+ import type { Config, CachedContent, Connections } from '../../types.ts';
8
+ import type HTTPServer from '../../servers/http.ts';
7
9
 
8
10
  class BundleError extends Error {
9
- constructor(message) {
11
+ constructor(message: unknown) {
10
12
  super(message);
11
13
  this.name = 'BundleError';
12
14
  this.message = `esbuild Bundle Error: ${message}`.split('\n').join('\n# ');
@@ -17,7 +19,7 @@ class BundleError extends Error {
17
19
  * Pre-builds the esbuild bundle for all test files and caches the result in `cachedContent`.
18
20
  * @returns {Promise<void>}
19
21
  */
20
- export async function buildTestBundle(config, cachedContent) {
22
+ export async function buildTestBundle(config: Config, cachedContent: CachedContent): Promise<void> {
21
23
  const { projectRoot, output } = config;
22
24
  const allTestFilePaths = Object.keys(config.fsTree);
23
25
 
@@ -52,11 +54,11 @@ export async function buildTestBundle(config, cachedContent) {
52
54
  * @returns {Promise<object>}
53
55
  */
54
56
  export default async function runTestsInBrowser(
55
- config,
56
- cachedContent = {},
57
- connections,
58
- targetTestFilesToFilter,
59
- ) {
57
+ config: Config,
58
+ cachedContent: CachedContent = {} as CachedContent,
59
+ connections: Connections,
60
+ targetTestFilesToFilter?: string[],
61
+ ): Promise<Connections | undefined> {
60
62
  const { projectRoot, output } = config;
61
63
  const allTestFilePaths = Object.keys(config.fsTree);
62
64
  const runHasFilter = !!targetTestFilesToFilter;
@@ -124,7 +126,11 @@ export default async function runTestsInBrowser(
124
126
  return connections;
125
127
  }
126
128
 
127
- function buildFilteredTests(filteredTests, outputPath, config) {
129
+ function buildFilteredTests(
130
+ filteredTests: string[],
131
+ outputPath: string,
132
+ config: Config,
133
+ ): Promise<esbuild.BuildResult> {
128
134
  return esbuild.build({
129
135
  stdin: {
130
136
  contents: filteredTests.map((f) => `import "${f}";`).join(''),
@@ -137,31 +143,46 @@ function buildFilteredTests(filteredTests, outputPath, config) {
137
143
  });
138
144
  }
139
145
 
140
- async function runTestInsideHTMLFile(filePath, { page, server, browser }, config) {
146
+ async function runTestInsideHTMLFile(
147
+ filePath: string,
148
+ { page, server, browser }: Connections,
149
+ config: Config,
150
+ ): Promise<void> {
141
151
  let QUNIT_RESULT;
142
152
  let targetError;
153
+ let timeoutHandle;
143
154
  try {
144
155
  console.log('#', blue(`QUnitX running: http://localhost:${config.port}${filePath}`));
145
156
 
146
- const testsDone = new Promise((resolve) => {
147
- config._testRunDone = resolve;
157
+ // Single promise driven by the WS handler:
158
+ // config._testRunDone() → tests finished normally
159
+ // config._resetTestTimeout() → reset idle timer; fires as timeout if silent for config.timeout ms
160
+ // This replaces waitForFunction (CDP polling), which raced against WS testEnd messages
161
+ // under load: CDP could win and trigger cleanup before Node.js processed the pending messages.
162
+ const testRaceResult = new Promise((resolve) => {
163
+ config._testRunDone = () => resolve(false);
164
+ config._resetTestTimeout = () => {
165
+ clearTimeout(timeoutHandle);
166
+ timeoutHandle = setTimeout(() => resolve(true), config.timeout);
167
+ };
148
168
  });
149
169
 
150
170
  await page.goto(`http://localhost:${config.port}${filePath}`, {
151
171
  timeout: config.timeout + 10000,
152
172
  });
153
- await Promise.race([
154
- testsDone,
155
- page.waitForFunction(`window.testTimeout >= ${config.timeout}`, null, {
156
- timeout: config.timeout + 10000,
157
- }),
158
- ]);
173
+
174
+ config._resetTestTimeout(); // start idle countdown once the page is loaded
175
+
176
+ await testRaceResult;
159
177
 
160
178
  QUNIT_RESULT = await page.evaluate(() => window.QUNIT_RESULT);
161
179
  } catch (error) {
162
180
  targetError = error;
163
181
  console.log(error);
164
182
  console.error(error);
183
+ } finally {
184
+ clearTimeout(timeoutHandle);
185
+ config._resetTestTimeout = null;
165
186
  }
166
187
 
167
188
  if (!QUNIT_RESULT || QUNIT_RESULT.totalTests === 0) {
@@ -174,10 +195,18 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
174
195
  console.log(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
175
196
  console.error(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
176
197
  await failOnNonWatchMode(config.watch, { server, browser }, config._groupMode);
198
+ } else if (QUNIT_RESULT.failedTests > config.COUNTER.failCount) {
199
+ // Safety net: browser tracked failures that WebSocket events never delivered to Node.js
200
+ // (e.g. WS connection dropped mid-run). Reconcile so the exit code is always correct.
201
+ config.COUNTER.failCount = QUNIT_RESULT.failedTests;
177
202
  }
178
203
  }
179
204
 
180
- async function failOnNonWatchMode(watchMode = false, connections = {}, groupMode = false) {
205
+ async function failOnNonWatchMode(
206
+ watchMode: boolean = false,
207
+ connections: { server?: HTTPServer; browser?: { close(): Promise<void> } } = {},
208
+ groupMode: boolean = false,
209
+ ): Promise<void> {
181
210
  if (!watchMode) {
182
211
  if (groupMode) {
183
212
  // Parent orchestrator handles cleanup and exit; signal failure via throw.
@@ -1,23 +1,24 @@
1
- import setupBrowser, { launchBrowser } from '../setup/browser.js';
1
+ import setupBrowser, { launchBrowser } from '../setup/browser.ts';
2
2
  import fs from 'node:fs/promises';
3
3
  import { normalize } from 'node:path';
4
4
  import { availableParallelism } from 'node:os';
5
- import { blue, yellow } from '../utils/color.js';
6
- import runTestsInBrowser, { buildTestBundle } from './run/tests-in-browser.js';
7
- import fileWatcher from '../setup/file-watcher.js';
8
- import findInternalAssetsFromHTML from '../utils/find-internal-assets-from-html.js';
9
- import runUserModule from '../utils/run-user-module.js';
10
- import setupKeyboardEvents from '../setup/keyboard-events.js';
11
- import writeOutputStaticFiles from '../setup/write-output-static-files.js';
12
- import timeCounter from '../utils/time-counter.js';
13
- import TAPDisplayFinalResult from '../tap/display-final-result.js';
14
- import readBoilerplate from '../utils/read-boilerplate.js';
5
+ import { blue, yellow } from '../utils/color.ts';
6
+ import runTestsInBrowser, { buildTestBundle } from './run/tests-in-browser.ts';
7
+ import fileWatcher from '../setup/file-watcher.ts';
8
+ import findInternalAssetsFromHTML from '../utils/find-internal-assets-from-html.ts';
9
+ import runUserModule from '../utils/run-user-module.ts';
10
+ import setupKeyboardEvents from '../setup/keyboard-events.ts';
11
+ import writeOutputStaticFiles from '../setup/write-output-static-files.ts';
12
+ import timeCounter from '../utils/time-counter.ts';
13
+ import TAPDisplayFinalResult from '../tap/display-final-result.ts';
14
+ import readBoilerplate from '../utils/read-boilerplate.ts';
15
+ import type { Config, CachedContent } from '../types.ts';
15
16
 
16
17
  /**
17
18
  * Runs qunitx tests in headless Chrome, either in watch mode or concurrent batch mode.
18
19
  * @returns {Promise<void>}
19
20
  */
20
- export default async function run(config) {
21
+ export default async function run(config: Config): Promise<void> {
21
22
  const cachedContent = await buildCachedContent(config, config.htmlPaths);
22
23
 
23
24
  if (config.watch) {
@@ -81,6 +82,8 @@ export default async function run(config) {
81
82
  }));
82
83
  const groupCachedContents = groups.map(() => ({ ...cachedContent }));
83
84
 
85
+ console.log('TAP version 13');
86
+
84
87
  // Build all group bundles and write static files while the browser is starting up.
85
88
  const [browser] = await Promise.all([
86
89
  launchBrowser(config),
@@ -93,34 +96,74 @@ export default async function run(config) {
93
96
  ),
94
97
  ),
95
98
  ]);
96
-
97
- console.log('TAP version 13');
98
99
  const TIME_COUNTER = timeCounter();
99
- let hasFatalError = false;
100
100
 
101
- await Promise.allSettled(
102
- groupConfigs.map(async (groupConfig, i) => {
103
- const connections = await setupBrowser(groupConfig, groupCachedContents[i], browser);
104
- groupConfig.expressApp = connections.server;
101
+ // 3-minute per-group deadline. Firefox/WebKit can hang indefinitely in any Playwright
102
+ // operation (browser.newPage, page.evaluate, page.close) when overwhelmed by concurrent
103
+ // pages. Without this outer timeout, one stuck group freezes Promise.allSettled forever.
104
+ // After all groups settle, browser.close() (below) terminates the browser and unblocks
105
+ // any still-pending Playwright calls in background async fns.
106
+ const GROUP_TIMEOUT_MS = 3 * 60 * 1000;
105
107
 
106
- if (config.before) {
107
- await runUserModule(`${process.cwd()}/${config.before}`, groupConfig, 'before');
108
- }
108
+ // Keep the event loop alive during Promise.allSettled. The Chrome child process and its
109
+ // stderr pipe are unref'd (pre-launch-chrome.js). If Chrome crashes during group cleanup,
110
+ // all active handles close and the event loop would drain — exiting silently before
111
+ // allSettled resolves or results are printed. This interval holds the loop open so that
112
+ // unref'd group/page-close timers can still fire normally.
113
+ const keepAlive = setInterval(() => {}, 1000);
109
114
 
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
- }
115
+ const groupResults = await Promise.allSettled(
116
+ groupConfigs.map((groupConfig, i) => {
117
+ const groupTimeout = new Promise((_, reject) => {
118
+ const t = setTimeout(
119
+ () => reject(new Error(`Group ${i} timed out after ${GROUP_TIMEOUT_MS}ms`)),
120
+ GROUP_TIMEOUT_MS,
121
+ );
122
+ t.unref();
123
+ });
124
+
125
+ return Promise.race([
126
+ (async () => {
127
+ const connections = await setupBrowser(groupConfig, groupCachedContents[i], browser);
128
+ groupConfig.expressApp = connections.server;
129
+
130
+ if (config.before) {
131
+ await runUserModule(`${process.cwd()}/${config.before}`, groupConfig, 'before');
132
+ }
133
+
134
+ try {
135
+ await runTestsInBrowser(groupConfig, groupCachedContents[i], connections);
136
+ } finally {
137
+ await Promise.all([
138
+ connections.server && connections.server.close(),
139
+ connections.page &&
140
+ // Unref'd: the keepAlive interval above holds the event loop open, so this
141
+ // timer still fires if page.close() hangs, without preventing process exit later.
142
+ Promise.race([
143
+ connections.page.close(),
144
+ new Promise((resolve) => {
145
+ const t = setTimeout(resolve, 10000);
146
+ t.unref();
147
+ }),
148
+ ]).catch(() => {}),
149
+ ]);
150
+ }
151
+ })(),
152
+ groupTimeout,
153
+ ]);
120
154
  }),
121
155
  );
122
156
 
123
- await browser.close();
157
+ const exitCode = groupResults.reduce(
158
+ (code, { status, reason }) => {
159
+ if (status !== 'rejected') return code;
160
+ console.error(reason);
161
+ return 1;
162
+ },
163
+ config.COUNTER.failCount > 0 ? 1 : 0,
164
+ );
165
+
166
+ process.exitCode = exitCode;
124
167
 
125
168
  TAPDisplayFinalResult(config.COUNTER, TIME_COUNTER.stop());
126
169
 
@@ -128,11 +171,22 @@ export default async function run(config) {
128
171
  await runUserModule(`${process.cwd()}/${config.after}`, config.COUNTER, 'after');
129
172
  }
130
173
 
131
- process.exit(config.COUNTER.failCount > 0 || hasFatalError ? 1 : 0);
174
+ // Flush stdout then exit. keepAlive holds the event loop open until this callback fires,
175
+ // at which point process.exit() takes over — so clearInterval happens here, not earlier.
176
+ // If the write callback never fires (theoretical), the unref'd exitTimer is the fallback.
177
+ const exitTimer = setTimeout(() => process.exit(exitCode), 5000);
178
+ exitTimer.unref();
179
+ process.stdout.write('\n', () => {
180
+ clearTimeout(exitTimer);
181
+ clearInterval(keepAlive);
182
+ // Close browser after stdout is flushed; fire-and-forget since process.exit follows.
183
+ browser.close().catch(() => {});
184
+ process.exit(exitCode);
185
+ });
132
186
  }
133
187
  }
134
188
 
135
- async function buildCachedContent(config, htmlPaths) {
189
+ async function buildCachedContent(config: Config, htmlPaths: string[]): Promise<CachedContent> {
136
190
  const htmlBuffers = await Promise.all(config.htmlPaths.map((htmlPath) => fs.readFile(htmlPath)));
137
191
  const cachedContent = htmlPaths.reduce(
138
192
  (result, _htmlPath, index) => {
@@ -175,7 +229,10 @@ async function buildCachedContent(config, htmlPaths) {
175
229
  return addCachedContentMainHTML(config.projectRoot, cachedContent);
176
230
  }
177
231
 
178
- async function addCachedContentMainHTML(projectRoot, cachedContent) {
232
+ async function addCachedContentMainHTML(
233
+ projectRoot: string,
234
+ cachedContent: CachedContent,
235
+ ): Promise<CachedContent> {
179
236
  const mainHTMLPath = Object.keys(cachedContent.dynamicContentHTMLs)[0];
180
237
  if (mainHTMLPath) {
181
238
  cachedContent.mainHTML = {
@@ -191,13 +248,13 @@ async function addCachedContentMainHTML(projectRoot, cachedContent) {
191
248
  return cachedContent;
192
249
  }
193
250
 
194
- function splitIntoGroups(files, groupCount) {
251
+ function splitIntoGroups(files: string[], groupCount: number): string[][] {
195
252
  const groups = Array.from({ length: groupCount }, () => []);
196
253
  files.forEach((file, i) => groups[i % groupCount].push(file));
197
254
  return groups.filter((g) => g.length > 0);
198
255
  }
199
256
 
200
- function logWatcherAndKeyboardShortcutInfo(config, _server) {
257
+ function logWatcherAndKeyboardShortcutInfo(config: Config, _server: unknown): void {
201
258
  console.log(
202
259
  '#',
203
260
  blue(`Watching files... You can browse the tests on http://localhost:${config.port} ...`),
@@ -210,7 +267,11 @@ function logWatcherAndKeyboardShortcutInfo(config, _server) {
210
267
  );
211
268
  }
212
269
 
213
- function normalizeInternalAssetPathFromHTML(projectRoot, assetPath, htmlPath) {
270
+ function normalizeInternalAssetPathFromHTML(
271
+ projectRoot: string,
272
+ assetPath: string,
273
+ htmlPath: string,
274
+ ): string {
214
275
  const currentDirectory = htmlPath ? htmlPath.split('/').slice(0, -1).join('/') : projectRoot;
215
276
  return assetPath.startsWith('./')
216
277
  ? normalize(`${currentDirectory}/${assetPath.slice(2)}`)