qunitx-cli 0.1.2 → 0.5.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.
Files changed (44) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/.env +1 -0
  3. package/Makefile +35 -0
  4. package/README.md +120 -49
  5. package/cli.js +1 -1
  6. package/cliff.toml +23 -0
  7. package/demo/demo.gif +0 -0
  8. package/demo/demo.tape +59 -0
  9. package/demo/example-test.js +53 -0
  10. package/demo/failing-test.js +22 -0
  11. package/flake.lock +4 -4
  12. package/flake.nix +33 -4
  13. package/lib/boilerplates/default-project-config-values.js +2 -2
  14. package/lib/boilerplates/test.js +5 -4
  15. package/lib/commands/generate.js +6 -8
  16. package/lib/commands/help.js +8 -8
  17. package/lib/commands/init.js +35 -25
  18. package/lib/commands/run/tests-in-browser.js +97 -67
  19. package/lib/commands/run.js +165 -55
  20. package/lib/servers/http.js +53 -42
  21. package/lib/setup/bind-server-to-port.js +3 -12
  22. package/lib/setup/browser.js +26 -18
  23. package/lib/setup/config.js +8 -10
  24. package/lib/setup/file-watcher.js +23 -6
  25. package/lib/setup/fs-tree.js +29 -27
  26. package/lib/setup/keyboard-events.js +7 -4
  27. package/lib/setup/test-file-paths.js +25 -23
  28. package/lib/setup/web-server.js +87 -61
  29. package/lib/setup/write-output-static-files.js +4 -1
  30. package/lib/tap/display-final-result.js +2 -2
  31. package/lib/tap/display-test-result.js +32 -14
  32. package/lib/utils/find-chrome.js +16 -0
  33. package/lib/utils/find-internal-assets-from-html.js +7 -5
  34. package/lib/utils/find-project-root.js +1 -2
  35. package/lib/utils/indent-string.js +6 -6
  36. package/lib/utils/listen-to-keyboard-key.js +6 -2
  37. package/lib/utils/parse-cli-flags.js +34 -31
  38. package/lib/utils/resolve-port-number-for.js +3 -3
  39. package/lib/utils/run-user-module.js +5 -3
  40. package/lib/utils/search-in-parent-directories.js +4 -1
  41. package/lib/utils/time-counter.js +2 -2
  42. package/package.json +21 -35
  43. package/vendor/qunit.css +7 -7
  44. package/vendor/qunit.js +3772 -3324
@@ -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
- }
@@ -1,103 +1,200 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import { normalize, dirname } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { availableParallelism } from 'node:os';
5
+ import Puppeteer from 'puppeteer';
4
6
  import kleur from 'kleur';
5
- import runTestsInBrowser from './run/tests-in-browser.js';
7
+ import runTestsInBrowser, { buildTestBundle } from './run/tests-in-browser.js';
6
8
  import setupBrowser from '../setup/browser.js';
7
9
  import fileWatcher from '../setup/file-watcher.js';
8
10
  import findInternalAssetsFromHTML from '../utils/find-internal-assets-from-html.js';
9
11
  import runUserModule from '../utils/run-user-module.js';
10
12
  import setupKeyboardEvents from '../setup/keyboard-events.js';
11
13
  import writeOutputStaticFiles from '../setup/write-output-static-files.js';
14
+ import timeCounter from '../utils/time-counter.js';
15
+ import TAPDisplayFinalResult from '../tap/display-final-result.js';
16
+ import findChrome from '../utils/find-chrome.js';
12
17
 
13
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
19
 
15
- export default async function(config) {
16
- let cachedContent = await buildCachedContent(config, config.htmlPaths);
17
- let [connections, _] = await Promise.all([
18
- setupBrowser(config, cachedContent),
19
- writeOutputStaticFiles(config, cachedContent)
20
- ]);
21
- config.expressApp = connections.server;
20
+ export default async function (config) {
21
+ const cachedContent = await buildCachedContent(config, config.htmlPaths);
22
22
 
23
23
  if (config.watch) {
24
+ // WATCH MODE: single browser, all test files bundled together.
25
+ // The HTTP server stays alive so the user can browse http://localhost:PORT
26
+ // and see all tests running in a single QUnit view.
27
+ const [connections] = await Promise.all([
28
+ setupBrowser(config, cachedContent),
29
+ writeOutputStaticFiles(config, cachedContent),
30
+ ]);
31
+ config.expressApp = connections.server;
24
32
  setupKeyboardEvents(config, cachedContent, connections);
25
- }
26
33
 
27
- if (config.before) {
28
- await runUserModule(`${process.cwd()}/${config.before}`, config, 'before');
29
- }
34
+ if (config.before) {
35
+ await runUserModule(`${process.cwd()}/${config.before}`, config, 'before');
36
+ }
30
37
 
31
- await runTestsInBrowser(config, cachedContent, connections);
38
+ try {
39
+ await runTestsInBrowser(config, cachedContent, connections);
40
+ } catch (error) {
41
+ await Promise.all([
42
+ connections.server && connections.server.close(),
43
+ connections.browser && connections.browser.close(),
44
+ ]);
45
+ throw error;
46
+ }
32
47
 
33
- if (config.watch) {
34
48
  logWatcherAndKeyboardShortcutInfo(config, connections.server);
35
49
 
36
50
  await fileWatcher(
37
51
  config.testFileLookupPaths,
38
52
  config,
39
53
  async (event, file) => {
40
- if (event === 'addDir') {
41
- return;
42
- } else if (['unlink', 'unlinkDir'].includes(event)) {
54
+ if (event === 'addDir') return;
55
+ if (['unlink', 'unlinkDir'].includes(event)) {
43
56
  return await runTestsInBrowser(config, cachedContent, connections);
44
57
  }
45
-
46
58
  await runTestsInBrowser(config, cachedContent, connections, [file]);
47
59
  },
48
- (path, event) => connections.server.publish('refresh', 'refresh')
60
+ (_path, _event) => connections.server.publish('refresh', 'refresh'),
49
61
  );
50
- }
51
- }
62
+ } else {
63
+ // CONCURRENT MODE: split test files across N groups = availableParallelism().
64
+ // All group bundles are built while Chrome is starting up, so esbuild time
65
+ // is hidden behind the ~1.2s Chrome launch. Each group then gets its own
66
+ // HTTP server and Puppeteer page inside one shared browser instance.
67
+ const allFiles = Object.keys(config.fsTree);
68
+ const groupCount = Math.min(allFiles.length, availableParallelism());
69
+ const groups = splitIntoGroups(allFiles, groupCount);
70
+
71
+ // Shared COUNTER so TAP test numbers are globally sequential across all groups.
72
+ config.COUNTER = { testCount: 0, failCount: 0, skipCount: 0, passCount: 0 };
73
+ config.lastRanTestFiles = allFiles;
74
+
75
+ const groupConfigs = groups.map((groupFiles, i) => ({
76
+ ...config,
77
+ fsTree: Object.fromEntries(groupFiles.map((f) => [f, config.fsTree[f]])),
78
+ // Single group keeps the root output dir for backward-compatible file paths.
79
+ output: groupCount === 1 ? config.output : `${config.output}/group-${i}`,
80
+ _groupMode: true,
81
+ }));
82
+ const groupCachedContents = groups.map(() => ({ ...cachedContent }));
83
+
84
+ // Build all group bundles and write static files while Chrome is starting up.
85
+ const [browser] = await Promise.all([
86
+ findChrome().then((chromePath) =>
87
+ Puppeteer.launch({
88
+ args: [
89
+ '--no-sandbox',
90
+ '--disable-gpu',
91
+ '--remote-debugging-port=0',
92
+ '--window-size=1440,900',
93
+ ],
94
+ executablePath: chromePath,
95
+ headless: true,
96
+ }),
97
+ ),
98
+ Promise.all(
99
+ groupConfigs.map((groupConfig, i) =>
100
+ Promise.all([
101
+ buildTestBundle(groupConfig, groupCachedContents[i]),
102
+ writeOutputStaticFiles(groupConfig, groupCachedContents[i]),
103
+ ]),
104
+ ),
105
+ ),
106
+ ]);
107
+
108
+ console.log('TAP version 13');
109
+ const TIME_COUNTER = timeCounter();
110
+ let hasFatalError = false;
111
+
112
+ await Promise.allSettled(
113
+ groupConfigs.map(async (groupConfig, i) => {
114
+ const connections = await setupBrowser(groupConfig, groupCachedContents[i], browser);
115
+ groupConfig.expressApp = connections.server;
116
+
117
+ if (config.before) {
118
+ await runUserModule(`${process.cwd()}/${config.before}`, groupConfig, 'before');
119
+ }
52
120
 
53
- async function buildCachedContent(config, htmlPaths) {
54
- let htmlBuffers = await Promise.all(config.htmlPaths.map((htmlPath) => fs.readFile(htmlPath))); // TODO: remove this and read it from the fsTree, should be cached?
55
- let cachedContent = htmlPaths.reduce((result, htmlPath, index) => {
56
- let filePath = config.htmlPaths[index];
57
- let html = htmlBuffers[index].toString();
121
+ try {
122
+ await runTestsInBrowser(groupConfig, groupCachedContents[i], connections);
123
+ } catch {
124
+ hasFatalError = true;
125
+ } finally {
126
+ await Promise.all([
127
+ connections.server && connections.server.close(),
128
+ connections.page && connections.page.close(),
129
+ ]);
130
+ }
131
+ }),
132
+ );
58
133
 
59
- if (html.includes('{{content}}')) { // TODO: here I could do html analysis to see which static js certain html points to? Complex algorithm
60
- result.dynamicContentHTMLs[filePath] = html;
134
+ await browser.close();
61
135
 
62
- let relativePath = filePath.replace(config.projectRoot, '');
136
+ TAPDisplayFinalResult(config.COUNTER, TIME_COUNTER.stop());
63
137
 
64
- result.htmlPathsToRunTests.push(relativePath);
65
- } else {
66
- console.log('#', kleur.yellow(`WARNING: Static html file with no {{content}} detected. Therefore ignoring ${filePath}`));
67
- result.staticHTMLs[filePath] = html;
138
+ if (config.after) {
139
+ await runUserModule(`${process.cwd()}/${config.after}`, config.COUNTER, 'after');
68
140
  }
69
141
 
70
- findInternalAssetsFromHTML(html).forEach((key) => {
71
- result.assets.add(normalizeInternalAssetPathFromHTML(config.projectRoot, key, filePath))
72
- });
142
+ process.exit(config.COUNTER.failCount > 0 || hasFatalError ? 1 : 0);
143
+ }
144
+ }
73
145
 
74
- return result;
75
- }, {
76
- allTestCode: null,
77
- assets: new Set(),
78
- htmlPathsToRunTests: [],
79
- mainHTML: { filePath: null, html: null },
80
- staticHTMLs: {},
81
- dynamicContentHTMLs: {}
82
- });
146
+ async function buildCachedContent(config, htmlPaths) {
147
+ const htmlBuffers = await Promise.all(config.htmlPaths.map((htmlPath) => fs.readFile(htmlPath)));
148
+ const cachedContent = htmlPaths.reduce(
149
+ (result, htmlPath, index) => {
150
+ const filePath = config.htmlPaths[index];
151
+ const html = htmlBuffers[index].toString();
152
+
153
+ if (html.includes('{{content}}')) {
154
+ result.dynamicContentHTMLs[filePath] = html;
155
+ result.htmlPathsToRunTests.push(filePath.replace(config.projectRoot, ''));
156
+ } else {
157
+ console.log(
158
+ '#',
159
+ kleur.yellow(
160
+ `WARNING: Static html file with no {{content}} detected. Therefore ignoring ${filePath}`,
161
+ ),
162
+ );
163
+ result.staticHTMLs[filePath] = html;
164
+ }
165
+
166
+ findInternalAssetsFromHTML(html).forEach((key) => {
167
+ result.assets.add(normalizeInternalAssetPathFromHTML(config.projectRoot, key, filePath));
168
+ });
169
+
170
+ return result;
171
+ },
172
+ {
173
+ allTestCode: null,
174
+ assets: new Set(),
175
+ htmlPathsToRunTests: [],
176
+ mainHTML: { filePath: null, html: null },
177
+ staticHTMLs: {},
178
+ dynamicContentHTMLs: {},
179
+ },
180
+ );
83
181
 
84
182
  if (cachedContent.htmlPathsToRunTests.length === 0) {
85
183
  cachedContent.htmlPathsToRunTests = ['/'];
86
184
  }
87
185
 
88
- return await addCachedContentMainHTML(config.projectRoot, cachedContent);
186
+ return addCachedContentMainHTML(config.projectRoot, cachedContent);
89
187
  }
90
188
 
91
189
  async function addCachedContentMainHTML(projectRoot, cachedContent) {
92
- let mainHTMLPath = Object.keys(cachedContent.dynamicContentHTMLs)[0];
190
+ const mainHTMLPath = Object.keys(cachedContent.dynamicContentHTMLs)[0];
93
191
  if (mainHTMLPath) {
94
192
  cachedContent.mainHTML = {
95
193
  filePath: mainHTMLPath,
96
- html: cachedContent.dynamicContentHTMLs[mainHTMLPath]
194
+ html: cachedContent.dynamicContentHTMLs[mainHTMLPath],
97
195
  };
98
196
  } else {
99
- let html = (await fs.readFile(`${__dirname}/../boilerplates/setup/tests.hbs`)).toString();
100
-
197
+ const html = (await fs.readFile(`${__dirname}/../boilerplates/setup/tests.hbs`)).toString();
101
198
  cachedContent.mainHTML = { filePath: `${projectRoot}/test/tests.html`, html };
102
199
  cachedContent.assets.add(`${projectRoot}/node_modules/qunitx/vendor/qunit.css`);
103
200
  }
@@ -105,14 +202,27 @@ async function addCachedContentMainHTML(projectRoot, cachedContent) {
105
202
  return cachedContent;
106
203
  }
107
204
 
108
- function logWatcherAndKeyboardShortcutInfo(config, server) {
109
- console.log('#', kleur.blue(`Watching files... You can browse the tests on http://localhost:${config.port} ...`)); // NOTE: maybe add also qx to exit
110
- console.log('#', kleur.blue(`Shortcuts: Press "qq" to abort running tests, "qa" to run all the tests, "qf" to run last failing test, "ql" to repeat last test`)); // NOTE: maybe add also qx to test specific
205
+ function splitIntoGroups(files, groupCount) {
206
+ const groups = Array.from({ length: groupCount }, () => []);
207
+ files.forEach((file, i) => groups[i % groupCount].push(file));
208
+ return groups.filter((g) => g.length > 0);
111
209
  }
112
210
 
113
- function normalizeInternalAssetPathFromHTML(projectRoot, assetPath, htmlPath) { // NOTE: maybe normalize ..
114
- let currentDirectory = htmlPath ? htmlPath.split('/').slice(0, -1).join('/') : projectRoot;
211
+ function logWatcherAndKeyboardShortcutInfo(config, server) {
212
+ console.log(
213
+ '#',
214
+ kleur.blue(`Watching files... You can browse the tests on http://localhost:${config.port} ...`),
215
+ );
216
+ console.log(
217
+ '#',
218
+ kleur.blue(
219
+ `Shortcuts: Press "qq" to abort running tests, "qa" to run all the tests, "qf" to run last failing test, "ql" to repeat last test`,
220
+ ),
221
+ );
222
+ }
115
223
 
224
+ function normalizeInternalAssetPathFromHTML(projectRoot, assetPath, htmlPath) {
225
+ const currentDirectory = htmlPath ? htmlPath.split('/').slice(0, -1).join('/') : projectRoot;
116
226
  return assetPath.startsWith('./')
117
227
  ? normalize(`${currentDirectory}/${assetPath.slice(2)}`)
118
228
  : normalize(`${currentDirectory}/${assetPath}`);