qunitx-cli 0.9.4 → 0.9.7

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/bin/qunitx.js +71 -0
  2. package/dist/cli.js +2009 -0
  3. package/package.json +16 -7
  4. package/cli.ts +0 -39
  5. package/deno.json +0 -18
  6. package/deno.lock +0 -646
  7. package/lib/commands/generate.ts +0 -33
  8. package/lib/commands/help.ts +0 -37
  9. package/lib/commands/init.ts +0 -77
  10. package/lib/commands/run/tests-in-browser.ts +0 -221
  11. package/lib/commands/run.ts +0 -279
  12. package/lib/servers/http.ts +0 -321
  13. package/lib/setup/bind-server-to-port.ts +0 -14
  14. package/lib/setup/browser.ts +0 -101
  15. package/lib/setup/config.ts +0 -55
  16. package/lib/setup/default-project-config-values.ts +0 -9
  17. package/lib/setup/file-watcher.ts +0 -134
  18. package/lib/setup/fs-tree.ts +0 -60
  19. package/lib/setup/keyboard-events.ts +0 -38
  20. package/lib/setup/test-file-paths.ts +0 -74
  21. package/lib/setup/web-server.ts +0 -274
  22. package/lib/setup/write-output-static-files.ts +0 -33
  23. package/lib/tap/display-final-result.ts +0 -25
  24. package/lib/tap/display-test-result.ts +0 -109
  25. package/lib/tap/dump-yaml.ts +0 -84
  26. package/lib/types.ts +0 -61
  27. package/lib/utils/chromium-args.ts +0 -18
  28. package/lib/utils/color.ts +0 -66
  29. package/lib/utils/early-chrome.ts +0 -39
  30. package/lib/utils/find-chrome.ts +0 -38
  31. package/lib/utils/find-internal-assets-from-html.ts +0 -18
  32. package/lib/utils/find-project-root.ts +0 -20
  33. package/lib/utils/indent-string.ts +0 -24
  34. package/lib/utils/listen-to-keyboard-key.ts +0 -57
  35. package/lib/utils/parse-cli-flags.ts +0 -95
  36. package/lib/utils/path-exists.ts +0 -21
  37. package/lib/utils/perf-logger.ts +0 -25
  38. package/lib/utils/pre-launch-chrome.ts +0 -45
  39. package/lib/utils/read-boilerplate.ts +0 -15
  40. package/lib/utils/resolve-port-number-for.ts +0 -29
  41. package/lib/utils/run-user-module.ts +0 -28
  42. package/lib/utils/search-in-parent-directories.ts +0 -25
  43. package/lib/utils/time-counter.ts +0 -19
@@ -1,37 +0,0 @@
1
- import { blue, magenta } from '../utils/color.ts';
2
- import pkg from '../../package.json' with { type: 'json' };
3
-
4
- const highlight = (text) => magenta().bold(text);
5
- const color = (text) => blue(text);
6
-
7
- /** Prints qunitx-cli usage information to stdout. */
8
- export default function displayHelpOutput() {
9
- const config = pkg;
10
-
11
- console.log(`${highlight('[qunitx v' + config.version + '] Usage:')} qunitx ${color('[targets] --$flags')}
12
-
13
- ${highlight('Input options:')}
14
- - File: $ ${color('qunitx test/foo.js')}
15
- - Folder: $ ${color('qunitx test/login')}
16
- - Globs: $ ${color('qunitx test/**/*-test.js')}
17
- - Combination: $ ${color('qunitx test/foo.js test/bar.js test/*-test.js test/logout')}
18
-
19
- ${highlight('Optional flags:')}
20
- ${color('--debug')} : print console output when tests run in browser
21
- ${color('--watch')} : run the target file or folders, watch them for continuous run and expose http server under localhost
22
- ${color('--timeout')} : change default timeout per test case
23
- ${color('--output')} : folder to distribute built qunitx html and js that a webservers can run[default: tmp]
24
- ${color('--failFast')} : run the target file or folders with immediate abort if a single test fails
25
- ${color('--port')} : HTTP server port (auto-selects a free port if the given port is taken)[default: 1234]
26
- ${color('--extensions')} : comma-separated file extensions to track for discovery and watch-mode rebuilds[default: js,ts]
27
- ${color('--browser')} : browser engine to run tests in: chromium, firefox, webkit[default: chromium]
28
- ${color('--before')} : run a script before the tests(i.e start a new web server before tests)
29
- ${color('--after')} : run a script after the tests(i.e save test results to a file)
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')}
32
-
33
- ${highlight('Commands:')}
34
- ${color('$ qunitx init')} # Bootstraps qunitx base html and add qunitx config to package.json if needed
35
- ${color('$ qunitx new $testFileName')} # Creates a qunitx test file
36
- `);
37
- }
@@ -1,77 +0,0 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
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
-
8
- /** Bootstraps a new qunitx project: writes the test HTML template, updates package.json, and optionally writes tsconfig.json. */
9
- export default async function initializeProject() {
10
- const projectRoot = await findProjectRoot();
11
- const oldPackageJSON = JSON.parse(await fs.readFile(`${projectRoot}/package.json`));
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
- });
18
-
19
- await Promise.all([
20
- writeTestsHTML(projectRoot, config, oldPackageJSON),
21
- rewritePackageJSON(projectRoot, config, oldPackageJSON),
22
- writeTSConfigIfNeeded(projectRoot),
23
- ]);
24
- }
25
-
26
- async function writeTestsHTML(
27
- projectRoot: string,
28
- config: { htmlPaths: string[]; output: string },
29
- oldPackageJSON: Record<string, unknown>,
30
- ): Promise<unknown[]> {
31
- const testHTMLTemplateBuffer = await readBoilerplate('setup/tests.hbs');
32
-
33
- return await Promise.all(
34
- config.htmlPaths.map(async (htmlPath) => {
35
- const targetPath = `${projectRoot}/${htmlPath}`;
36
- if (await pathExists(targetPath)) {
37
- return console.log(`${htmlPath} already exists`);
38
- } else {
39
- const targetDirectory = path.dirname(targetPath);
40
- const _targetOutputPath = path.relative(
41
- targetDirectory,
42
- `${projectRoot}/${config.output}/tests.js`,
43
- );
44
- const testHTMLTemplate = testHTMLTemplateBuffer.replace(
45
- '{{applicationName}}',
46
- oldPackageJSON.name,
47
- );
48
-
49
- await fs.mkdir(targetDirectory, { recursive: true });
50
- await fs.writeFile(targetPath, testHTMLTemplate);
51
-
52
- console.log(`${targetPath} written`);
53
- }
54
- }),
55
- );
56
- }
57
-
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 });
64
-
65
- await fs.writeFile(`${projectRoot}/package.json`, JSON.stringify(newPackageJSON, null, 2));
66
- }
67
-
68
- async function writeTSConfigIfNeeded(projectRoot: string): Promise<void> {
69
- const targetPath = `${projectRoot}/tsconfig.json`;
70
- if (!(await pathExists(targetPath))) {
71
- const tsConfigTemplate = await readBoilerplate('setup/tsconfig.json');
72
-
73
- await fs.writeFile(targetPath, tsConfigTemplate);
74
-
75
- console.log(`${targetPath} written`);
76
- }
77
- }
@@ -1,221 +0,0 @@
1
- import fs from 'node:fs/promises';
2
- import { blue } from '../../utils/color.ts';
3
- import esbuild from 'esbuild';
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';
9
-
10
- class BundleError extends Error {
11
- constructor(message: unknown) {
12
- super(message);
13
- this.name = 'BundleError';
14
- this.message = `esbuild Bundle Error: ${message}`.split('\n').join('\n# ');
15
- }
16
- }
17
-
18
- /**
19
- * Pre-builds the esbuild bundle for all test files and caches the result in `cachedContent`.
20
- * @returns {Promise<void>}
21
- */
22
- export async function buildTestBundle(config: Config, cachedContent: CachedContent): Promise<void> {
23
- const { projectRoot, output } = config;
24
- const allTestFilePaths = Object.keys(config.fsTree);
25
-
26
- await Promise.all([
27
- esbuild.build({
28
- stdin: {
29
- contents: allTestFilePaths.map((f) => `import "${f}";`).join(''),
30
- resolveDir: process.cwd(),
31
- },
32
- bundle: true,
33
- logLevel: 'error',
34
- outfile: `${projectRoot}/${output}/tests.js`,
35
- keepNames: true,
36
- sourcemap: config.debug || config.watch ? 'inline' : false,
37
- }),
38
- Promise.all(
39
- cachedContent.htmlPathsToRunTests.map(async (htmlPath) => {
40
- const targetPath = `${config.projectRoot}/${config.output}${htmlPath}`;
41
- if (htmlPath !== '/') {
42
- await fs.rm(targetPath, { force: true, recursive: true });
43
- await fs.mkdir(targetPath.split('/').slice(0, -1).join('/'), { recursive: true });
44
- }
45
- }),
46
- ),
47
- ]);
48
-
49
- cachedContent.allTestCode = await fs.readFile(`${projectRoot}/${output}/tests.js`);
50
- }
51
-
52
- /**
53
- * Runs the esbuild-bundled tests inside a Playwright-controlled browser page and streams TAP output.
54
- * @returns {Promise<object>}
55
- */
56
- export default async function runTestsInBrowser(
57
- config: Config,
58
- cachedContent: CachedContent = {} as CachedContent,
59
- connections: Connections,
60
- targetTestFilesToFilter?: string[],
61
- ): Promise<Connections | undefined> {
62
- const { projectRoot, output } = config;
63
- const allTestFilePaths = Object.keys(config.fsTree);
64
- const runHasFilter = !!targetTestFilesToFilter;
65
-
66
- // In group mode the COUNTER is shared across all groups and managed by run.js.
67
- if (!config._groupMode) {
68
- config.COUNTER = { testCount: 0, failCount: 0, skipCount: 0, passCount: 0, errorCount: 0 };
69
- }
70
- config.lastRanTestFiles = targetTestFilesToFilter || allTestFilePaths;
71
-
72
- try {
73
- // Skip bundle build if run.js already pre-built it (group mode optimization).
74
- if (!cachedContent.allTestCode) {
75
- await buildTestBundle(config, cachedContent);
76
- }
77
-
78
- if (runHasFilter) {
79
- const outputPath = `${projectRoot}/${output}/filtered-tests.js`;
80
- await buildFilteredTests(targetTestFilesToFilter, outputPath, config);
81
- cachedContent.filteredTestCode = (await fs.readFile(outputPath)).toString();
82
- }
83
-
84
- const TIME_COUNTER = timeCounter();
85
-
86
- if (runHasFilter) {
87
- await runTestInsideHTMLFile('/qunitx.html', connections, config);
88
- } else {
89
- await Promise.all(
90
- cachedContent.htmlPathsToRunTests.map((htmlPath) =>
91
- runTestInsideHTMLFile(htmlPath, connections, config),
92
- ),
93
- );
94
- }
95
-
96
- const TIME_TAKEN = TIME_COUNTER.stop();
97
-
98
- // In group mode the parent orchestrator handles the final summary, after hook, and exit.
99
- if (!config._groupMode) {
100
- TAPDisplayFinalResult(config.COUNTER, TIME_TAKEN);
101
-
102
- if (config.after) {
103
- await runUserModule(`${process.cwd()}/${config.after}`, config.COUNTER, 'after');
104
- }
105
-
106
- if (!config.watch) {
107
- await Promise.all([
108
- connections.server && connections.server.close(),
109
- connections.browser && connections.browser.close(),
110
- ]);
111
- return process.exit(config.COUNTER.failCount > 0 ? 1 : 0);
112
- }
113
- }
114
- } catch (error) {
115
- config.lastFailedTestFiles = config.lastRanTestFiles;
116
- console.log(error);
117
- const exception = new BundleError(error);
118
-
119
- if (config.watch) {
120
- console.log(`# ${exception}`);
121
- } else {
122
- throw exception;
123
- }
124
- }
125
-
126
- return connections;
127
- }
128
-
129
- function buildFilteredTests(
130
- filteredTests: string[],
131
- outputPath: string,
132
- config: Config,
133
- ): Promise<esbuild.BuildResult> {
134
- return esbuild.build({
135
- stdin: {
136
- contents: filteredTests.map((f) => `import "${f}";`).join(''),
137
- resolveDir: process.cwd(),
138
- },
139
- bundle: true,
140
- logLevel: 'error',
141
- outfile: outputPath,
142
- sourcemap: config.debug || config.watch ? 'inline' : false,
143
- });
144
- }
145
-
146
- async function runTestInsideHTMLFile(
147
- filePath: string,
148
- { page, server, browser }: Connections,
149
- config: Config,
150
- ): Promise<void> {
151
- let QUNIT_RESULT;
152
- let targetError;
153
- let timeoutHandle;
154
- try {
155
- console.log('#', blue(`QUnitX running: http://localhost:${config.port}${filePath}`));
156
-
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
- };
168
- });
169
-
170
- await page.goto(`http://localhost:${config.port}${filePath}`, {
171
- timeout: config.timeout + 10000,
172
- });
173
-
174
- config._resetTestTimeout(); // start idle countdown once the page is loaded
175
-
176
- await testRaceResult;
177
-
178
- QUNIT_RESULT = await page.evaluate(() => window.QUNIT_RESULT);
179
- } catch (error) {
180
- targetError = error;
181
- console.log(error);
182
- console.error(error);
183
- } finally {
184
- clearTimeout(timeoutHandle);
185
- config._resetTestTimeout = null;
186
- }
187
-
188
- if (!QUNIT_RESULT || QUNIT_RESULT.totalTests === 0) {
189
- console.log(targetError);
190
- console.log('BROWSER: runtime error thrown during executing tests');
191
- console.error('BROWSER: runtime error thrown during executing tests');
192
- await failOnNonWatchMode(config.watch, { server, browser }, config._groupMode);
193
- } else if (QUNIT_RESULT.totalTests > QUNIT_RESULT.finishedTests) {
194
- console.log(targetError);
195
- console.log(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
196
- console.error(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
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;
202
- }
203
- }
204
-
205
- async function failOnNonWatchMode(
206
- watchMode: boolean = false,
207
- connections: { server?: HTTPServer; browser?: { close(): Promise<void> } } = {},
208
- groupMode: boolean = false,
209
- ): Promise<void> {
210
- if (!watchMode) {
211
- if (groupMode) {
212
- // Parent orchestrator handles cleanup and exit; signal failure via throw.
213
- throw new Error('Browser test run failed');
214
- }
215
- await Promise.all([
216
- connections.server && connections.server.close(),
217
- connections.browser && connections.browser.close(),
218
- ]);
219
- process.exit(1);
220
- }
221
- }
@@ -1,279 +0,0 @@
1
- import setupBrowser, { launchBrowser } from '../setup/browser.ts';
2
- import fs from 'node:fs/promises';
3
- import { normalize } from 'node:path';
4
- import { availableParallelism } from 'node:os';
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';
16
-
17
- /**
18
- * Runs qunitx tests in headless Chrome, either in watch mode or concurrent batch mode.
19
- * @returns {Promise<void>}
20
- */
21
- export default async function run(config: Config): Promise<void> {
22
- const cachedContent = await buildCachedContent(config, config.htmlPaths);
23
-
24
- if (config.watch) {
25
- // WATCH MODE: single browser, all test files bundled together.
26
- // The HTTP server stays alive so the user can browse http://localhost:PORT
27
- // and see all tests running in a single QUnit view.
28
- const [connections] = await Promise.all([
29
- setupBrowser(config, cachedContent),
30
- writeOutputStaticFiles(config, cachedContent),
31
- ]);
32
- config.expressApp = connections.server;
33
- setupKeyboardEvents(config, cachedContent, connections);
34
-
35
- if (config.before) {
36
- await runUserModule(`${process.cwd()}/${config.before}`, config, 'before');
37
- }
38
-
39
- try {
40
- await runTestsInBrowser(config, cachedContent, connections);
41
- } catch (error) {
42
- await Promise.all([
43
- connections.server && connections.server.close(),
44
- connections.browser && connections.browser.close(),
45
- ]);
46
- throw error;
47
- }
48
-
49
- logWatcherAndKeyboardShortcutInfo(config, connections.server);
50
-
51
- await fileWatcher(
52
- config.testFileLookupPaths,
53
- config,
54
- async (event, file) => {
55
- if (event === 'addDir') return;
56
- if (['unlink', 'unlinkDir'].includes(event)) {
57
- return await runTestsInBrowser(config, cachedContent, connections);
58
- }
59
- await runTestsInBrowser(config, cachedContent, connections, [file]);
60
- },
61
- (_path, _event) => connections.server.publish('refresh', 'refresh'),
62
- );
63
- } else {
64
- // CONCURRENT MODE: split test files across N groups = availableParallelism().
65
- // All group bundles are built while Chrome is starting up, so esbuild time
66
- // is hidden behind the ~1.2s Chrome launch. Each group then gets its own
67
- // HTTP server and Playwright page inside one shared browser instance.
68
- const allFiles = Object.keys(config.fsTree);
69
- const groupCount = Math.min(allFiles.length, availableParallelism());
70
- const groups = splitIntoGroups(allFiles, groupCount);
71
-
72
- // Shared COUNTER so TAP test numbers are globally sequential across all groups.
73
- config.COUNTER = { testCount: 0, failCount: 0, skipCount: 0, passCount: 0, errorCount: 0 };
74
- config.lastRanTestFiles = allFiles;
75
-
76
- const groupConfigs = groups.map((groupFiles, i) => ({
77
- ...config,
78
- fsTree: Object.fromEntries(groupFiles.map((f) => [f, config.fsTree[f]])),
79
- // Single group keeps the root output dir for backward-compatible file paths.
80
- output: groupCount === 1 ? config.output : `${config.output}/group-${i}`,
81
- _groupMode: true,
82
- }));
83
- const groupCachedContents = groups.map(() => ({ ...cachedContent }));
84
-
85
- console.log('TAP version 13');
86
-
87
- // Build all group bundles and write static files while the browser is starting up.
88
- const [browser] = await Promise.all([
89
- launchBrowser(config),
90
- Promise.all(
91
- groupConfigs.map((groupConfig, i) =>
92
- Promise.all([
93
- buildTestBundle(groupConfig, groupCachedContents[i]),
94
- writeOutputStaticFiles(groupConfig, groupCachedContents[i]),
95
- ]),
96
- ),
97
- ),
98
- ]);
99
- const TIME_COUNTER = timeCounter();
100
-
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;
107
-
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);
114
-
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
- ]);
154
- }),
155
- );
156
-
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;
167
-
168
- TAPDisplayFinalResult(config.COUNTER, TIME_COUNTER.stop());
169
-
170
- if (config.after) {
171
- await runUserModule(`${process.cwd()}/${config.after}`, config.COUNTER, 'after');
172
- }
173
-
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
- });
186
- }
187
- }
188
-
189
- async function buildCachedContent(config: Config, htmlPaths: string[]): Promise<CachedContent> {
190
- const htmlBuffers = await Promise.all(config.htmlPaths.map((htmlPath) => fs.readFile(htmlPath)));
191
- const cachedContent = htmlPaths.reduce(
192
- (result, _htmlPath, index) => {
193
- const filePath = config.htmlPaths[index];
194
- const html = htmlBuffers[index].toString();
195
-
196
- if (html.includes('{{content}}')) {
197
- result.dynamicContentHTMLs[filePath] = html;
198
- result.htmlPathsToRunTests.push(filePath.replace(config.projectRoot, ''));
199
- } else {
200
- console.log(
201
- '#',
202
- yellow(
203
- `WARNING: Static html file with no {{content}} detected. Therefore ignoring ${filePath}`,
204
- ),
205
- );
206
- result.staticHTMLs[filePath] = html;
207
- }
208
-
209
- findInternalAssetsFromHTML(html).forEach((key) => {
210
- result.assets.add(normalizeInternalAssetPathFromHTML(config.projectRoot, key, filePath));
211
- });
212
-
213
- return result;
214
- },
215
- {
216
- allTestCode: null,
217
- assets: new Set(),
218
- htmlPathsToRunTests: [],
219
- mainHTML: { filePath: null, html: null },
220
- staticHTMLs: {},
221
- dynamicContentHTMLs: {},
222
- },
223
- );
224
-
225
- if (cachedContent.htmlPathsToRunTests.length === 0) {
226
- cachedContent.htmlPathsToRunTests = ['/'];
227
- }
228
-
229
- return addCachedContentMainHTML(config.projectRoot, cachedContent);
230
- }
231
-
232
- async function addCachedContentMainHTML(
233
- projectRoot: string,
234
- cachedContent: CachedContent,
235
- ): Promise<CachedContent> {
236
- const mainHTMLPath = Object.keys(cachedContent.dynamicContentHTMLs)[0];
237
- if (mainHTMLPath) {
238
- cachedContent.mainHTML = {
239
- filePath: mainHTMLPath,
240
- html: cachedContent.dynamicContentHTMLs[mainHTMLPath],
241
- };
242
- } else {
243
- const html = await readBoilerplate('setup/tests.hbs');
244
- cachedContent.mainHTML = { filePath: `${projectRoot}/test/tests.html`, html };
245
- cachedContent.assets.add(`${projectRoot}/node_modules/qunitx/vendor/qunit.css`);
246
- }
247
-
248
- return cachedContent;
249
- }
250
-
251
- function splitIntoGroups(files: string[], groupCount: number): string[][] {
252
- const groups = Array.from({ length: groupCount }, () => []);
253
- files.forEach((file, i) => groups[i % groupCount].push(file));
254
- return groups.filter((g) => g.length > 0);
255
- }
256
-
257
- function logWatcherAndKeyboardShortcutInfo(config: Config, _server: unknown): void {
258
- console.log(
259
- '#',
260
- blue(`Watching files... You can browse the tests on http://localhost:${config.port} ...`),
261
- );
262
- console.log(
263
- '#',
264
- blue(
265
- `Shortcuts: Press "qq" to abort running tests, "qa" to run all the tests, "qf" to run last failing test, "ql" to repeat last test`,
266
- ),
267
- );
268
- }
269
-
270
- function normalizeInternalAssetPathFromHTML(
271
- projectRoot: string,
272
- assetPath: string,
273
- htmlPath: string,
274
- ): string {
275
- const currentDirectory = htmlPath ? htmlPath.split('/').slice(0, -1).join('/') : projectRoot;
276
- return assetPath.startsWith('./')
277
- ? normalize(`${currentDirectory}/${assetPath.slice(2)}`)
278
- : normalize(`${currentDirectory}/${assetPath}`);
279
- }