qunitx-cli 0.1.2 → 0.5.1

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 (36) hide show
  1. package/README.md +120 -49
  2. package/cli.js +1 -1
  3. package/lib/boilerplates/default-project-config-values.js +2 -2
  4. package/lib/boilerplates/test.js +5 -4
  5. package/lib/commands/generate.js +6 -8
  6. package/lib/commands/help.js +8 -8
  7. package/lib/commands/init.js +35 -25
  8. package/lib/commands/run/tests-in-browser.js +97 -67
  9. package/lib/commands/run.js +165 -55
  10. package/lib/servers/http.js +59 -44
  11. package/lib/setup/bind-server-to-port.js +3 -12
  12. package/lib/setup/browser.js +26 -18
  13. package/lib/setup/config.js +8 -10
  14. package/lib/setup/file-watcher.js +23 -6
  15. package/lib/setup/fs-tree.js +29 -27
  16. package/lib/setup/keyboard-events.js +7 -4
  17. package/lib/setup/test-file-paths.js +25 -23
  18. package/lib/setup/web-server.js +87 -61
  19. package/lib/setup/write-output-static-files.js +4 -1
  20. package/lib/tap/display-final-result.js +2 -2
  21. package/lib/tap/display-test-result.js +32 -14
  22. package/lib/utils/find-chrome.js +16 -0
  23. package/lib/utils/find-internal-assets-from-html.js +7 -5
  24. package/lib/utils/find-project-root.js +1 -2
  25. package/lib/utils/indent-string.js +6 -6
  26. package/lib/utils/listen-to-keyboard-key.js +6 -2
  27. package/lib/utils/parse-cli-flags.js +34 -31
  28. package/lib/utils/resolve-port-number-for.js +3 -3
  29. package/lib/utils/run-user-module.js +5 -3
  30. package/lib/utils/search-in-parent-directories.js +4 -1
  31. package/lib/utils/time-counter.js +2 -2
  32. package/package.json +21 -35
  33. package/vendor/qunit.css +7 -7
  34. package/vendor/qunit.js +3772 -3324
  35. package/flake.lock +0 -64
  36. package/flake.nix +0 -26
package/README.md CHANGED
@@ -1,92 +1,163 @@
1
- # QUnitX CLI
1
+ # qunitx-cli
2
2
 
3
- CI browser runner for [qunitx](https://github.com/izelnakri/qunitx)
3
+ [![CI](https://github.com/izelnakri/qunitx-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/izelnakri/qunitx-cli/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/qunitx-cli)](https://www.npmjs.com/package/qunitx-cli)
5
+ [![npm downloads](https://img.shields.io/npm/dm/qunitx-cli)](https://www.npmjs.com/package/qunitx-cli)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
4
7
 
5
- ![QunitX terminal output](https://raw.githubusercontent.com/izelnakri/qunitx/main/docs/qunitx-help-stdout.png)
8
+ Browser-based test runner for [QUnitX](https://github.com/izelnakri/qunitx) — bundles your JS/TS tests
9
+ with esbuild, runs them in headless Chrome, and streams TAP output to the terminal.
6
10
 
7
- Default test output is TAP (_Test-Anything-Protocol_) thus you can use any tap reporter of your choice to display test
8
- output in anyway you like. Example:
11
+ ![qunitx-cli demo](demo/demo.gif)
9
12
 
10
- ```zsh
11
- # using it with tap-difflet TAP reporter:
12
- qunitx tests/attachments tests/user | npx tap-difflet
13
+ ## Features
14
+
15
+ - Runs `.js` and `.ts` test files in headless Chrome (Puppeteer + esbuild)
16
+ - TypeScript works with zero configuration — esbuild handles transpilation
17
+ - Inline source maps for accurate stack traces pointing to original source files
18
+ - Streams TAP-formatted output to the terminal in real time
19
+ - Concurrent mode (default) splits test files across all CPU cores for fast parallel runs
20
+ - `--watch` mode re-runs affected tests on file change
21
+ - `--failFast` stops the run after the first failing test
22
+ - `--debug` prints the local server URL and pipes browser console to stdout
23
+ - `--before` / `--after` hook scripts for server setup and teardown
24
+ - `--timeout` controls the maximum ms to wait for the full suite to finish
25
+ - Docker image for zero-install CI usage
26
+
27
+ ## Installation
28
+
29
+ Requires Node.js >= 24.
30
+
31
+ ```sh
32
+ npm install --save-dev qunitx-cli
13
33
  ```
14
34
 
15
- #### Installation:
35
+ Or run without installing:
36
+
37
+ ```sh
38
+ npx qunitx test/**/*.js
39
+ ```
40
+
41
+ With Docker — no install needed:
42
+
43
+ ```sh
44
+ docker run --rm -v "$(pwd):/code" -w /code ghcr.io/izelnakri/qunitx-cli:latest npx qunitx test/**/*.js
45
+ ```
16
46
 
17
- ```zsh
18
- npm install -g qunitx-cli
47
+ With Nix:
19
48
 
20
- qunitx
49
+ ```sh
50
+ nix profile install github:izelnakri/qunitx-cli
21
51
  ```
22
52
 
23
- In order to use qunitx to execute existing qunit tests please change:
53
+ ## Usage
54
+
55
+ ```sh
56
+ # Single file
57
+ qunitx test/my-test.js
58
+
59
+ # Multiple files / globs
60
+ qunitx test/**/*.js test/**/*.ts
61
+
62
+ # TypeScript — no tsconfig required
63
+ qunitx test/my-test.ts
64
+
65
+ # Watch mode: re-run on file changes
66
+ qunitx test/**/*.js --watch
67
+
68
+ # Stop on the first failure
69
+ qunitx test/**/*.js --failFast
70
+
71
+ # Print the server URL and pipe Chrome console to stdout
72
+ qunitx test/**/*.js --debug
73
+
74
+ # Custom timeout (ms)
75
+ qunitx test/**/*.js --timeout=30000
76
+
77
+ # Run a setup script before tests (can be async — awaited automatically)
78
+ qunitx test/**/*.js --before=scripts/start-server.js
79
+
80
+ # Run a teardown script after tests (can be async)
81
+ qunitx test/**/*.js --after=scripts/stop-server.js
82
+ ```
83
+
84
+ ## Writing Tests
85
+
86
+ qunitx-cli runs [QUnitX](https://github.com/izelnakri/qunitx) tests — a superset of QUnit with async
87
+ hooks, concurrency control, and test metadata.
88
+
89
+ Migrating from QUnit? Change a single import:
24
90
 
25
91
  ```js
92
+ // before
26
93
  import { module, test } from 'qunit';
27
-
28
- // to:
94
+ // after
29
95
  import { module, test } from 'qunitx';
30
96
  ```
31
97
 
32
- Example:
98
+ Example test file — ES modules, npm imports, and nested modules all work out of the box:
33
99
 
34
100
  ```js
35
- // in some-test.js: (typescript is also supported for --browser mode and node.js with --loader flag)
101
+ // some-test.js (TypeScript is also supported)
36
102
  import { module, test } from 'qunitx';
37
103
  import $ from 'jquery';
38
104
 
39
- module('Basic sanity check', function (hooks) {
40
- test('it works', function (assert) {
105
+ module('Basic sanity check', (hooks) => {
106
+ test('it works', (assert) => {
41
107
  assert.equal(true, true);
42
108
  });
43
109
 
44
- module('More advanced cases', function (hooks) {
45
- test('deepEqual works', function (assert) {
110
+ module('More advanced cases', (hooks) => {
111
+ test('deepEqual works', (assert) => {
46
112
  assert.deepEqual({ username: 'izelnakri' }, { username: 'izelnakri' });
47
113
  });
48
- test('can import ES & npm modules', function (assert) {
114
+
115
+ test('can import ES & npm modules', (assert) => {
49
116
  assert.ok(Object.keys($));
50
117
  });
51
118
  });
52
119
  });
53
120
  ```
54
121
 
55
- ```zsh
56
- # you can run the test in node with ES modules package.json{ "type": "module" }
57
- $ node --test some-test.js
122
+ Run it:
58
123
 
59
- # Suggested mode: if you want to run it in CI/google chrome:
124
+ ```sh
125
+ # Headless Chrome (recommended for CI)
126
+ qunitx some-test.js
60
127
 
61
- $ qunitx some-test.js
62
-
63
- # with browser output enabled:
64
-
65
- $ qunitx some-test.js --debug
66
-
67
- # TypeScript also works, make sure on node.js mode, tsconfig.json exists with compilerOptions.module & compilerOptions.moduleResolution set to "NodeNext":
68
-
69
- $ node --loader=ts-node/esm/transpile-only --test some-test.ts
70
-
71
- $ qunitx some-test.ts --debug
128
+ # With browser console output
129
+ qunitx some-test.js --debug
72
130
 
131
+ # TypeScript — no config needed
132
+ qunitx some-test.ts
73
133
  ```
74
134
 
75
- ### Code coverage
135
+ ## CLI Reference
76
136
 
77
- Since QUnitX proxies to default node.js test runner in when executed with node, you can use any code coverage tool you like. When running the tests in `qunit`(the browser mode) code coverage support is limited.
78
137
  ```
79
- c8 node test/attachments test/user
138
+ Usage: qunitx [files/folders...] [options]
139
+
140
+ Options:
141
+ --watch Re-run tests on file changes
142
+ --failFast Stop after the first failure
143
+ --debug Print the server URL; pipe browser console to stdout
144
+ --timeout=<ms> Max ms to wait for the suite to finish [default: 20000]
145
+ --output=<dir> Directory for compiled test assets [default: ./tmp]
146
+ --before=<file> Script to run (and optionally await) before tests start
147
+ --after=<file> Script to run (and optionally await) after tests finish
148
+ --port=<n> HTTP server port (auto-selects a free port if taken)
80
149
  ```
81
150
 
82
- You can browse [c8 documentation](https://github.com/bcoe/c8) for all configuration options.
151
+ ## Development
152
+
153
+ ```sh
154
+ npm install
155
+ make check # lint + test (run before every commit)
156
+ make test # run tests only
157
+ make demo # regenerate demo output
158
+ make release LEVEL=patch # bump version, update changelog, tag, push
159
+ ```
83
160
 
84
- Implementing code coverage for the browser mode is currently not possible because we use esbuild --bundle feature to
85
- create a JS bundles for testing in the browser, this could be instrumented with `puppeteer-to-istanbul` however
86
- instrumentation includes transpiled npm imports of `qunitx` and other potential npm imports developer
87
- includes in the code, this cannot be filtered since potential filtering can only occur after the `esbuild` bundling.
88
- When chrome browser and puppeteer fully supports ES asset maps we can remove esbuild from the browser mode, run
89
- everything in deno and make instrumentation for code coverage possible with the default v8 instrumentation.
161
+ ## License
90
162
 
91
- Esbuild plugin interface is an ongoing development, we might be able to figure out a way to generate this instrumentation
92
- with esbuild in the future, which could allow code coverage for --browser mode.
163
+ MIT
package/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env -S TS_NODE_COMPILER_OPTIONS='{"module":"ES2020"}' node --loader ts-node/esm/transpile-only
1
+ #!/usr/bin/env node
2
2
  import process from 'node:process';
3
3
  import displayHelpOutput from './lib/commands/help.js';
4
4
  import initializeProject from './lib/commands/init.js';
@@ -2,5 +2,5 @@ export default {
2
2
  output: 'tmp',
3
3
  timeout: 20000,
4
4
  failFast: false,
5
- port: 1234
6
- }
5
+ port: 1234,
6
+ };
@@ -1,6 +1,6 @@
1
1
  import { module, test } from 'qunitx';
2
2
 
3
- module('{{moduleName}}', function(hooks) {
3
+ module('{{moduleName}}', function (hooks) {
4
4
  test('assert true works', function (assert) {
5
5
  assert.expect(3);
6
6
  assert.ok(true);
@@ -11,9 +11,10 @@ module('{{moduleName}}', function(hooks) {
11
11
  test('async test finishes', async function (assert) {
12
12
  assert.expect(3);
13
13
 
14
- let wait = () => new Promise((resolve, reject) => {
15
- setTimeout(() => resolve(true), 50);
16
- });
14
+ let wait = () =>
15
+ new Promise((resolve, reject) => {
16
+ setTimeout(() => resolve(true), 50);
17
+ });
17
18
  let result = await wait();
18
19
 
19
20
  assert.ok(true);
@@ -7,12 +7,13 @@ import pathExists from '../utils/path-exists.js';
7
7
 
8
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
9
 
10
- export default async function() {
10
+ export default async function () {
11
11
  let projectRoot = await findProjectRoot();
12
12
  let moduleName = process.argv[3]; // TODO: classify this maybe in future
13
- let path = process.argv[3].endsWith('.js') || process.argv[3].endsWith('.ts')
14
- ? `${projectRoot}/${process.argv[3]}`
15
- : `${projectRoot}/${process.argv[3]}.js`;
13
+ let path =
14
+ process.argv[3].endsWith('.js') || process.argv[3].endsWith('.ts')
15
+ ? `${projectRoot}/${process.argv[3]}`
16
+ : `${projectRoot}/${process.argv[3]}.js`;
16
17
 
17
18
  if (await pathExists(path)) {
18
19
  return console.log(`${path} already exists!`);
@@ -24,10 +25,7 @@ export default async function() {
24
25
  targetFolderPaths.pop();
25
26
 
26
27
  await fs.mkdir(targetFolderPaths.join('/'), { recursive: true });
27
- await fs.writeFile(
28
- path,
29
- testJSContent.toString().replace('{{moduleName}}', moduleName)
30
- );
28
+ await fs.writeFile(path, testJSContent.toString().replace('{{moduleName}}', moduleName));
31
29
 
32
30
  console.log(kleur.green(`${path} written`));
33
31
  }
@@ -7,30 +7,30 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
7
7
  const highlight = (text) => kleur.magenta().bold(text);
8
8
  const color = (text) => kleur.blue(text);
9
9
 
10
+ export default async function () {
11
+ const config = JSON.parse(await fs.readFile(`${__dirname}/../../package.json`));
10
12
 
11
- export default async function() {
12
- const config = JSON.parse((await fs.readFile(`${__dirname}/../../package.json`)));
13
+ console.log(`${highlight('[qunitx v' + config.version + '] Usage:')} qunitx ${color('[targets] --$flags')}
13
14
 
14
- console.log(`${highlight("[qunitx v" + config.version + "] Usage:")} qunitx ${color('[targets] --$flags')}
15
-
16
- ${highlight("Input options:")}
15
+ ${highlight('Input options:')}
17
16
  - File: $ ${color('qunitx test/foo.js')}
18
17
  - Folder: $ ${color('qunitx test/login')}
19
18
  - Globs: $ ${color('qunitx test/**/*-test.js')}
20
19
  - Combination: $ ${color('qunitx test/foo.js test/bar.js test/*-test.js test/logout')}
21
20
 
22
- ${highlight("Optional flags:")}
21
+ ${highlight('Optional flags:')}
23
22
  ${color('--debug')} : print console output when tests run in browser
24
23
  ${color('--watch')} : run the target file or folders, watch them for continuous run and expose http server under localhost
25
24
  ${color('--timeout')} : change default timeout per test case
26
25
  ${color('--output')} : folder to distribute built qunitx html and js that a webservers can run[default: tmp]
27
26
  ${color('--failFast')} : run the target file or folders with immediate abort if a single test fails
27
+ ${color('--port')} : HTTP server port (auto-selects a free port if the given port is taken)[default: 1234]
28
28
  ${color('--before')} : run a script before the tests(i.e start a new web server before tests)
29
29
  ${color('--after')} : run a script after the tests(i.e save test results to a file)
30
30
 
31
- ${highlight("Example:")} $ ${color('qunitx test/foo.ts app/e2e --debug --watch --before=scripts/start-new-webserver.js --after=scripts/write-test-results.js')}
31
+ ${highlight('Example:')} $ ${color('qunitx test/foo.ts app/e2e --debug --watch --before=scripts/start-new-webserver.js --after=scripts/write-test-results.js')}
32
32
 
33
- ${highlight("Commands:")}
33
+ ${highlight('Commands:')}
34
34
  ${color('$ qunitx init')} # Bootstraps qunitx base html and add qunitx config to package.json if needed
35
35
  ${color('$ qunitx new $testFileName')} # Creates a qunitx test file
36
36
  `);
@@ -7,49 +7,57 @@ import defaultProjectConfigValues from '../boilerplates/default-project-config-v
7
7
 
8
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
9
 
10
- export default async function() {
10
+ export default async function () {
11
11
  let projectRoot = await findProjectRoot();
12
12
  let oldPackageJSON = JSON.parse(await fs.readFile(`${projectRoot}/package.json`));
13
- let htmlPaths = process.argv.slice(2).reduce((result, arg) => {
14
- if (arg.endsWith('.html')) {
15
- result.push(arg);
16
- }
13
+ let htmlPaths = process.argv.slice(2).reduce(
14
+ (result, arg) => {
15
+ if (arg.endsWith('.html')) {
16
+ result.push(arg);
17
+ }
17
18
 
18
- return result;
19
- }, oldPackageJSON.qunitx && oldPackageJSON.qunitx.htmlPaths ? oldPackageJSON.qunitx.htmlPaths : []);
19
+ return result;
20
+ },
21
+ oldPackageJSON.qunitx && oldPackageJSON.qunitx.htmlPaths ? oldPackageJSON.qunitx.htmlPaths : [],
22
+ );
20
23
  let newQunitxConfig = Object.assign(
21
24
  defaultProjectConfigValues,
22
25
  htmlPaths.length > 0 ? { htmlPaths } : { htmlPaths: ['test/tests.html'] },
23
- oldPackageJSON.qunitx
26
+ oldPackageJSON.qunitx,
24
27
  );
25
28
 
26
29
  await Promise.all([
27
30
  writeTestsHTML(projectRoot, newQunitxConfig, oldPackageJSON),
28
31
  rewritePackageJSON(projectRoot, newQunitxConfig, oldPackageJSON),
29
- writeTSConfigIfNeeded(projectRoot)
32
+ writeTSConfigIfNeeded(projectRoot),
30
33
  ]);
31
34
  }
32
35
 
33
36
  async function writeTestsHTML(projectRoot, newQunitxConfig, oldPackageJSON) {
34
37
  let testHTMLTemplateBuffer = await fs.readFile(`${__dirname}/../boilerplates/setup/tests.hbs`);
35
38
 
36
- return await Promise.all(newQunitxConfig.htmlPaths.map(async (htmlPath) => {
37
- let targetPath = `${projectRoot}/${htmlPath}`;
38
- if (await pathExists(targetPath)) {
39
- return console.log(`${htmlPath} already exists`);
40
- } else {
41
- let targetDirectory = path.dirname(targetPath);
42
- let targetOutputPath = path.relative(targetDirectory, `${projectRoot}/${newQunitxConfig.output}/tests.js`);
43
- let testHTMLTemplate = testHTMLTemplateBuffer
44
- .toString()
45
- .replace('{{applicationName}}', oldPackageJSON.name);
39
+ return await Promise.all(
40
+ newQunitxConfig.htmlPaths.map(async (htmlPath) => {
41
+ let targetPath = `${projectRoot}/${htmlPath}`;
42
+ if (await pathExists(targetPath)) {
43
+ return console.log(`${htmlPath} already exists`);
44
+ } else {
45
+ let targetDirectory = path.dirname(targetPath);
46
+ let targetOutputPath = path.relative(
47
+ targetDirectory,
48
+ `${projectRoot}/${newQunitxConfig.output}/tests.js`,
49
+ );
50
+ let testHTMLTemplate = testHTMLTemplateBuffer
51
+ .toString()
52
+ .replace('{{applicationName}}', oldPackageJSON.name);
46
53
 
47
- await fs.mkdir(targetDirectory, { recursive: true });
48
- await fs.writeFile(targetPath, testHTMLTemplate);
54
+ await fs.mkdir(targetDirectory, { recursive: true });
55
+ await fs.writeFile(targetPath, testHTMLTemplate);
49
56
 
50
- console.log(`${targetPath} written`);
51
- }
52
- }));
57
+ console.log(`${targetPath} written`);
58
+ }
59
+ }),
60
+ );
53
61
  }
54
62
 
55
63
  async function rewritePackageJSON(projectRoot, newQunitxConfig, oldPackageJSON) {
@@ -61,7 +69,9 @@ async function rewritePackageJSON(projectRoot, newQunitxConfig, oldPackageJSON)
61
69
  async function writeTSConfigIfNeeded(projectRoot) {
62
70
  let targetPath = `${projectRoot}/tsconfig.json`;
63
71
  if (!(await pathExists(targetPath))) {
64
- let tsConfigTemplateBuffer = await fs.readFile(`${__dirname}/../boilerplates/setup/tsconfig.json`);
72
+ let tsConfigTemplateBuffer = await fs.readFile(
73
+ `${__dirname}/../boilerplates/setup/tsconfig.json`,
74
+ );
65
75
 
66
76
  await fs.writeFile(targetPath, tsConfigTemplateBuffer);
67
77
 
@@ -17,81 +17,99 @@ class BundleError extends Error {
17
17
  }
18
18
  }
19
19
 
20
+ // Exported so run.js can pre-build all group bundles in parallel with Chrome startup.
21
+ export async function buildTestBundle(config, cachedContent) {
22
+ const { projectRoot, output } = config;
23
+ const allTestFilePaths = Object.keys(config.fsTree);
24
+
25
+ await Promise.all([
26
+ esbuild.build({
27
+ stdin: {
28
+ contents: allTestFilePaths.map((f) => `import "${f}";`).join(''),
29
+ resolveDir: process.cwd(),
30
+ },
31
+ bundle: true,
32
+ logLevel: 'error',
33
+ outfile: `${projectRoot}/${output}/tests.js`,
34
+ keepNames: true,
35
+ sourcemap: 'inline',
36
+ }),
37
+ Promise.all(
38
+ cachedContent.htmlPathsToRunTests.map(async (htmlPath) => {
39
+ const targetPath = `${config.projectRoot}/${config.output}${htmlPath}`;
40
+ if (htmlPath !== '/') {
41
+ await fs.rm(targetPath, { force: true, recursive: true });
42
+ await fs.mkdir(targetPath.split('/').slice(0, -1).join('/'), { recursive: true });
43
+ }
44
+ }),
45
+ ),
46
+ ]);
47
+
48
+ cachedContent.allTestCode = await fs.readFile(`${projectRoot}/${output}/tests.js`);
49
+ }
50
+
20
51
  export default async function runTestsInBrowser(
21
52
  config,
22
53
  cachedContent = {},
23
54
  connections,
24
- targetTestFilesToFilter
55
+ targetTestFilesToFilter,
25
56
  ) {
26
- const { projectRoot, timeout, output } = config;
57
+ const { projectRoot, output } = config;
27
58
  const allTestFilePaths = Object.keys(config.fsTree);
28
59
  const runHasFilter = !!targetTestFilesToFilter;
29
60
 
30
- config.COUNTER = { testCount: 0, failCount: 0, skipCount: 0, passCount: 0 };
61
+ // In group mode the COUNTER is shared across all groups and managed by run.js.
62
+ if (!config._groupMode) {
63
+ config.COUNTER = { testCount: 0, failCount: 0, skipCount: 0, passCount: 0 };
64
+ }
31
65
  config.lastRanTestFiles = targetTestFilesToFilter || allTestFilePaths;
32
66
 
33
67
  try {
34
- await Promise.all([
35
- esbuild.build({
36
- stdin: {
37
- contents: allTestFilePaths.reduce((result, fileAbsolutePath) => {
38
- return result + `import "${fileAbsolutePath}";`
39
- }, ''),
40
- resolveDir: process.cwd()
41
- },
42
- bundle: true,
43
- logLevel: 'error',
44
- outfile: `${projectRoot}/${output}/tests.js`,
45
- keepNames: true
46
- }), // NOTE: This prevents file cache most likely
47
- Promise.all(cachedContent.htmlPathsToRunTests.map(async (htmlPath) => {
48
- let targetPath = `${config.projectRoot}/${config.output}${htmlPath}`;
49
-
50
- if (htmlPath !== '/') {
51
- await fs.rm(targetPath, { force: true, recursive: true });
52
- await fs.mkdir(targetPath.split('/').slice(0, -1).join('/'), { recursive: true }); // NOTE: this can be done earlier
53
- }
54
- }))
55
- ]);
56
- cachedContent.allTestCode = await fs.readFile(`${projectRoot}/${output}/tests.js`);
68
+ // Skip bundle build if run.js already pre-built it (group mode optimization).
69
+ if (!cachedContent.allTestCode) {
70
+ await buildTestBundle(config, cachedContent);
71
+ }
57
72
 
58
73
  if (runHasFilter) {
59
- let outputPath = `${projectRoot}/${output}/filtered-tests.js`;
60
-
74
+ const outputPath = `${projectRoot}/${output}/filtered-tests.js`;
61
75
  await buildFilteredTests(targetTestFilesToFilter, outputPath);
62
76
  cachedContent.filteredTestCode = (await fs.readFile(outputPath)).toString();
63
77
  }
64
78
 
65
- let TIME_COUNTER = timeCounter();
79
+ const TIME_COUNTER = timeCounter();
66
80
 
67
81
  if (runHasFilter) {
68
82
  await runTestInsideHTMLFile('/qunitx.html', connections, config);
69
83
  } else {
70
- await Promise.all(cachedContent.htmlPathsToRunTests.map((htmlPath) => {
71
- return runTestInsideHTMLFile(htmlPath, connections, config); // NOTE: maybe make this blocking
72
- }));
84
+ await Promise.all(
85
+ cachedContent.htmlPathsToRunTests.map((htmlPath) =>
86
+ runTestInsideHTMLFile(htmlPath, connections, config),
87
+ ),
88
+ );
73
89
  }
74
90
 
75
- let TIME_TAKEN = TIME_COUNTER.stop()
76
-
77
- TAPDisplayFinalResult(config.COUNTER, TIME_TAKEN);
91
+ const TIME_TAKEN = TIME_COUNTER.stop();
78
92
 
79
- if (config.after) {
80
- await runUserModule(`${process.cwd()}/${config.after}`, config.COUNTER, 'after');
81
- }
93
+ // In group mode the parent orchestrator handles the final summary, after hook, and exit.
94
+ if (!config._groupMode) {
95
+ TAPDisplayFinalResult(config.COUNTER, TIME_TAKEN);
82
96
 
83
- if (!config.watch) {
84
- await Promise.all([
85
- connections.server && connections.server.close(),
86
- connections.browser && connections.browser.close()
87
- ]);
97
+ if (config.after) {
98
+ await runUserModule(`${process.cwd()}/${config.after}`, config.COUNTER, 'after');
99
+ }
88
100
 
89
- return process.exit(config.COUNTER.failCount > 0 ? 1 : 0);
101
+ if (!config.watch) {
102
+ await Promise.all([
103
+ connections.server && connections.server.close(),
104
+ connections.browser && connections.browser.close(),
105
+ ]);
106
+ return process.exit(config.COUNTER.failCount > 0 ? 1 : 0);
107
+ }
90
108
  }
91
- } catch(error) {
109
+ } catch (error) {
92
110
  config.lastFailedTestFiles = config.lastRanTestFiles;
93
111
  console.log(error);
94
- let exception = new BundleError(error);
112
+ const exception = new BundleError(error);
95
113
 
96
114
  if (config.watch) {
97
115
  console.log(`# ${exception}`);
@@ -106,31 +124,41 @@ export default async function runTestsInBrowser(
106
124
  function buildFilteredTests(filteredTests, outputPath) {
107
125
  return esbuild.build({
108
126
  stdin: {
109
- contents: filteredTests.reduce((result, fileAbsolutePath) => {
110
- return result + `import "${fileAbsolutePath}";`
111
- }, ''),
112
- resolveDir: process.cwd()
127
+ contents: filteredTests.map((f) => `import "${f}";`).join(''),
128
+ resolveDir: process.cwd(),
113
129
  },
114
130
  bundle: true,
115
131
  logLevel: 'error',
116
- outfile: outputPath
132
+ outfile: outputPath,
133
+ sourcemap: 'inline',
117
134
  });
118
135
  }
119
136
 
120
- async function runTestInsideHTMLFile(filePath, { page, server }, config) {
137
+ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config) {
121
138
  let QUNIT_RESULT;
122
139
  let targetError;
123
140
  try {
124
- await wait(350);
125
141
  console.log('#', kleur.blue(`QUnitX running: http://localhost:${config.port}${filePath}`));
126
- await page.goto(`http://localhost:${config.port}${filePath}`, { timeout: 0 });
127
- await page.evaluate(() => {
142
+
143
+ const testsDone = new Promise((resolve) => {
144
+ config._testRunDone = resolve;
145
+ });
146
+
147
+ await page.evaluateOnNewDocument(() => {
128
148
  window.IS_PUPPETEER = true;
129
149
  });
130
- await page.waitForFunction(`window.testTimeout >= ${config.timeout}`, { timeout: 0 });
150
+ await page.goto(`http://localhost:${config.port}${filePath}`, {
151
+ timeout: config.timeout + 10000,
152
+ });
153
+ await Promise.race([
154
+ testsDone,
155
+ page.waitForFunction(`window.testTimeout >= ${config.timeout}`, {
156
+ timeout: config.timeout + 10000,
157
+ }),
158
+ ]);
131
159
 
132
160
  QUNIT_RESULT = await page.evaluate(() => window.QUNIT_RESULT);
133
- } catch(error) {
161
+ } catch (error) {
134
162
  targetError = error;
135
163
  console.log(error);
136
164
  console.error(error);
@@ -140,23 +168,25 @@ async function runTestInsideHTMLFile(filePath, { page, server }, config) {
140
168
  console.log(targetError);
141
169
  console.log('BROWSER: runtime error thrown during executing tests');
142
170
  console.error('BROWSER: runtime error thrown during executing tests');
143
-
144
- await failOnNonWatchMode(config.watch);
171
+ await failOnNonWatchMode(config.watch, { server, browser }, config._groupMode);
145
172
  } else if (QUNIT_RESULT.totalTests > QUNIT_RESULT.finishedTests) {
146
173
  console.log(targetError);
147
174
  console.log(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
148
175
  console.error(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
149
-
150
- await failOnNonWatchMode(config.watch);
176
+ await failOnNonWatchMode(config.watch, { server, browser }, config._groupMode);
151
177
  }
152
178
  }
153
179
 
154
- async function failOnNonWatchMode(watchMode = false) {
180
+ async function failOnNonWatchMode(watchMode = false, connections = {}, groupMode = false) {
155
181
  if (!watchMode) {
156
- await new Promise((resolve, reject) => setTimeout(() => resolve(process.exit(1)), 100));
182
+ if (groupMode) {
183
+ // Parent orchestrator handles cleanup and exit; signal failure via throw.
184
+ throw new Error('Browser test run failed');
185
+ }
186
+ await Promise.all([
187
+ connections.server && connections.server.close(),
188
+ connections.browser && connections.browser.close(),
189
+ ]);
190
+ process.exit(1);
157
191
  }
158
192
  }
159
-
160
- function wait(duration) {
161
- return new Promise((resolve) => setTimeout(() => { resolve() }, duration));
162
- }