qunitx-cli 0.1.1 → 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.
- package/.claude/settings.local.json +7 -0
- package/.env +1 -0
- package/Makefile +35 -0
- package/README.md +120 -49
- package/cli.js +1 -1
- package/cliff.toml +23 -0
- package/demo/demo.gif +0 -0
- package/demo/demo.tape +59 -0
- package/demo/example-test.js +53 -0
- package/demo/failing-test.js +22 -0
- package/flake.lock +4 -4
- package/flake.nix +33 -4
- package/lib/boilerplates/default-project-config-values.js +2 -2
- package/lib/boilerplates/test.js +5 -4
- package/lib/commands/generate.js +6 -8
- package/lib/commands/help.js +8 -8
- package/lib/commands/init.js +35 -25
- package/lib/commands/run/tests-in-browser.js +97 -67
- package/lib/commands/run.js +165 -55
- package/lib/servers/http.js +53 -42
- package/lib/setup/bind-server-to-port.js +3 -12
- package/lib/setup/browser.js +26 -18
- package/lib/setup/config.js +8 -10
- package/lib/setup/file-watcher.js +23 -6
- package/lib/setup/fs-tree.js +29 -27
- package/lib/setup/keyboard-events.js +7 -4
- package/lib/setup/test-file-paths.js +25 -23
- package/lib/setup/web-server.js +87 -61
- package/lib/setup/write-output-static-files.js +4 -1
- package/lib/tap/display-final-result.js +2 -2
- package/lib/tap/display-test-result.js +32 -14
- package/lib/utils/find-chrome.js +16 -0
- package/lib/utils/find-internal-assets-from-html.js +7 -5
- package/lib/utils/find-project-root.js +1 -2
- package/lib/utils/indent-string.js +6 -6
- package/lib/utils/listen-to-keyboard-key.js +6 -2
- package/lib/utils/parse-cli-flags.js +34 -31
- package/lib/utils/resolve-port-number-for.js +3 -3
- package/lib/utils/run-user-module.js +5 -3
- package/lib/utils/search-in-parent-directories.js +4 -1
- package/lib/utils/time-counter.js +2 -2
- package/package.json +21 -36
- package/vendor/qunit.css +7 -7
- package/vendor/qunit.js +3772 -3324
package/lib/commands/init.js
CHANGED
|
@@ -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(
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
54
|
+
await fs.mkdir(targetDirectory, { recursive: true });
|
|
55
|
+
await fs.writeFile(targetPath, testHTMLTemplate);
|
|
49
56
|
|
|
50
|
-
|
|
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(
|
|
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,
|
|
57
|
+
const { projectRoot, output } = config;
|
|
27
58
|
const allTestFilePaths = Object.keys(config.fsTree);
|
|
28
59
|
const runHasFilter = !!targetTestFilesToFilter;
|
|
29
60
|
|
|
30
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
71
|
-
|
|
72
|
-
|
|
84
|
+
await Promise.all(
|
|
85
|
+
cachedContent.htmlPathsToRunTests.map((htmlPath) =>
|
|
86
|
+
runTestInsideHTMLFile(htmlPath, connections, config),
|
|
87
|
+
),
|
|
88
|
+
);
|
|
73
89
|
}
|
|
74
90
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
TAPDisplayFinalResult(config.COUNTER, TIME_TAKEN);
|
|
91
|
+
const TIME_TAKEN = TIME_COUNTER.stop();
|
|
78
92
|
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
110
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
}
|
package/lib/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
if (config.before) {
|
|
35
|
+
await runUserModule(`${process.cwd()}/${config.before}`, config, 'before');
|
|
36
|
+
}
|
|
30
37
|
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
result.dynamicContentHTMLs[filePath] = html;
|
|
134
|
+
await browser.close();
|
|
61
135
|
|
|
62
|
-
|
|
136
|
+
TAPDisplayFinalResult(config.COUNTER, TIME_COUNTER.stop());
|
|
63
137
|
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
142
|
+
process.exit(config.COUNTER.failCount > 0 || hasFatalError ? 1 : 0);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
73
145
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
186
|
+
return addCachedContentMainHTML(config.projectRoot, cachedContent);
|
|
89
187
|
}
|
|
90
188
|
|
|
91
189
|
async function addCachedContentMainHTML(projectRoot, cachedContent) {
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
114
|
-
|
|
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}`);
|