qunitx-cli 0.7.0 → 0.9.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/README.md +33 -9
- package/cli.js +8 -2
- package/deno.json +0 -6
- package/deno.lock +14 -711
- package/lib/commands/help.js +1 -0
- package/lib/commands/run/tests-in-browser.js +2 -5
- package/lib/commands/run.js +4 -29
- package/lib/setup/browser.js +74 -40
- package/lib/setup/default-project-config-values.js +1 -0
- package/lib/setup/file-watcher.js +32 -10
- package/lib/setup/web-server.js +12 -7
- package/lib/utils/chromium-args.js +18 -0
- package/lib/utils/early-chrome.js +39 -0
- package/lib/utils/find-chrome.js +30 -12
- package/lib/utils/parse-cli-flags.js +11 -0
- package/lib/utils/perf-logger.js +25 -0
- package/lib/utils/pre-launch-chrome.js +32 -0
- package/package.json +6 -8
- package/vendor/package.json +0 -1
- package/vendor/qunit.css +0 -525
- package/vendor/qunit.js +0 -7485
package/lib/commands/help.js
CHANGED
|
@@ -24,6 +24,7 @@ ${color('--output')} : folder to distribute built qunitx html and js that a webs
|
|
|
24
24
|
${color('--failFast')} : run the target file or folders with immediate abort if a single test fails
|
|
25
25
|
${color('--port')} : HTTP server port (auto-selects a free port if the given port is taken)[default: 1234]
|
|
26
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]
|
|
27
28
|
${color('--before')} : run a script before the tests(i.e start a new web server before tests)
|
|
28
29
|
${color('--after')} : run a script after the tests(i.e save test results to a file)
|
|
29
30
|
|
|
@@ -48,7 +48,7 @@ export async function buildTestBundle(config, cachedContent) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
* Runs the esbuild-bundled tests inside a
|
|
51
|
+
* Runs the esbuild-bundled tests inside a Playwright-controlled browser page and streams TAP output.
|
|
52
52
|
* @returns {Promise<object>}
|
|
53
53
|
*/
|
|
54
54
|
export default async function runTestsInBrowser(
|
|
@@ -147,15 +147,12 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
|
|
|
147
147
|
config._testRunDone = resolve;
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
-
await page.evaluateOnNewDocument(() => {
|
|
151
|
-
window.IS_PUPPETEER = true;
|
|
152
|
-
});
|
|
153
150
|
await page.goto(`http://localhost:${config.port}${filePath}`, {
|
|
154
151
|
timeout: config.timeout + 10000,
|
|
155
152
|
});
|
|
156
153
|
await Promise.race([
|
|
157
154
|
testsDone,
|
|
158
|
-
page.waitForFunction(`window.testTimeout >= ${config.timeout}`, {
|
|
155
|
+
page.waitForFunction(`window.testTimeout >= ${config.timeout}`, null, {
|
|
159
156
|
timeout: config.timeout + 10000,
|
|
160
157
|
}),
|
|
161
158
|
]);
|
package/lib/commands/run.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
+
import setupBrowser, { launchBrowser } from '../setup/browser.js';
|
|
1
2
|
import fs from 'node:fs/promises';
|
|
2
3
|
import { normalize } from 'node:path';
|
|
3
4
|
import { availableParallelism } from 'node:os';
|
|
4
|
-
import Puppeteer from 'puppeteer';
|
|
5
5
|
import { blue, yellow } from '../utils/color.js';
|
|
6
6
|
import runTestsInBrowser, { buildTestBundle } from './run/tests-in-browser.js';
|
|
7
|
-
import setupBrowser from '../setup/browser.js';
|
|
8
7
|
import fileWatcher from '../setup/file-watcher.js';
|
|
9
8
|
import findInternalAssetsFromHTML from '../utils/find-internal-assets-from-html.js';
|
|
10
9
|
import runUserModule from '../utils/run-user-module.js';
|
|
@@ -12,7 +11,6 @@ import setupKeyboardEvents from '../setup/keyboard-events.js';
|
|
|
12
11
|
import writeOutputStaticFiles from '../setup/write-output-static-files.js';
|
|
13
12
|
import timeCounter from '../utils/time-counter.js';
|
|
14
13
|
import TAPDisplayFinalResult from '../tap/display-final-result.js';
|
|
15
|
-
import findChrome from '../utils/find-chrome.js';
|
|
16
14
|
import readBoilerplate from '../utils/read-boilerplate.js';
|
|
17
15
|
|
|
18
16
|
/**
|
|
@@ -65,7 +63,7 @@ export default async function run(config) {
|
|
|
65
63
|
// CONCURRENT MODE: split test files across N groups = availableParallelism().
|
|
66
64
|
// All group bundles are built while Chrome is starting up, so esbuild time
|
|
67
65
|
// is hidden behind the ~1.2s Chrome launch. Each group then gets its own
|
|
68
|
-
// HTTP server and
|
|
66
|
+
// HTTP server and Playwright page inside one shared browser instance.
|
|
69
67
|
const allFiles = Object.keys(config.fsTree);
|
|
70
68
|
const groupCount = Math.min(allFiles.length, availableParallelism());
|
|
71
69
|
const groups = splitIntoGroups(allFiles, groupCount);
|
|
@@ -83,32 +81,9 @@ export default async function run(config) {
|
|
|
83
81
|
}));
|
|
84
82
|
const groupCachedContents = groups.map(() => ({ ...cachedContent }));
|
|
85
83
|
|
|
86
|
-
// Build all group bundles and write static files while
|
|
84
|
+
// Build all group bundles and write static files while the browser is starting up.
|
|
87
85
|
const [browser] = await Promise.all([
|
|
88
|
-
|
|
89
|
-
Puppeteer.launch({
|
|
90
|
-
args: [
|
|
91
|
-
'--no-sandbox',
|
|
92
|
-
'--disable-gpu',
|
|
93
|
-
'--remote-debugging-port=0',
|
|
94
|
-
'--window-size=1440,900',
|
|
95
|
-
'--disable-extensions',
|
|
96
|
-
'--disable-sync',
|
|
97
|
-
'--no-first-run',
|
|
98
|
-
'--disable-default-apps',
|
|
99
|
-
'--mute-audio',
|
|
100
|
-
'--disable-background-networking',
|
|
101
|
-
'--disable-background-timer-throttling',
|
|
102
|
-
'--disable-renderer-backgrounding',
|
|
103
|
-
'--disable-dev-shm-usage',
|
|
104
|
-
'--disable-translate',
|
|
105
|
-
'--metrics-recording-only',
|
|
106
|
-
'--disable-hang-monitor',
|
|
107
|
-
],
|
|
108
|
-
executablePath: chromePath,
|
|
109
|
-
headless: true,
|
|
110
|
-
}),
|
|
111
|
-
),
|
|
86
|
+
launchBrowser(config),
|
|
112
87
|
Promise.all(
|
|
113
88
|
groupConfigs.map((groupConfig, i) =>
|
|
114
89
|
Promise.all([
|
package/lib/setup/browser.js
CHANGED
|
@@ -1,10 +1,62 @@
|
|
|
1
|
-
import Puppeteer from 'puppeteer';
|
|
2
1
|
import setupWebServer from './web-server.js';
|
|
3
2
|
import bindServerToPort from './bind-server-to-port.js';
|
|
4
3
|
import findChrome from '../utils/find-chrome.js';
|
|
4
|
+
import CHROMIUM_ARGS from '../utils/chromium-args.js';
|
|
5
|
+
import { earlyBrowserPromise } from '../utils/early-chrome.js';
|
|
6
|
+
import { perfLog } from '../utils/perf-logger.js';
|
|
7
|
+
|
|
8
|
+
// Playwright-core starts loading the moment run.js imports this module.
|
|
9
|
+
// browser.js is intentionally the first import in run.js so playwright-core
|
|
10
|
+
// starts loading before heavier deps (esbuild, chokidar) queue up I/O reads
|
|
11
|
+
// and saturate libuv's thread pool, which would delay the dynamic import resolution.
|
|
12
|
+
// early-chrome.js (statically imported by cli.js) already started Chrome pre-launch,
|
|
13
|
+
// so both race in parallel — Chrome is typically ready when playwright-core finishes.
|
|
14
|
+
const playwrightCorePromise = import('playwright-core');
|
|
15
|
+
perfLog('browser.js: playwright-core import started');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Launches a browser for the given config.browser type.
|
|
19
|
+
* For chromium: connects via CDP to the pre-launched Chrome (fast path) or falls
|
|
20
|
+
* back to chromium.launch() if pre-launch failed.
|
|
21
|
+
* For firefox/webkit: uses playwright's standard launch (requires `npx playwright install [browser]`).
|
|
22
|
+
* @returns {Promise<object>}
|
|
23
|
+
*/
|
|
24
|
+
export async function launchBrowser(config) {
|
|
25
|
+
const browserName = config.browser || 'chromium';
|
|
26
|
+
|
|
27
|
+
if (browserName === 'chromium') {
|
|
28
|
+
const waitStart = Date.now();
|
|
29
|
+
const [playwrightCore, earlyChrome] = await Promise.all([
|
|
30
|
+
playwrightCorePromise,
|
|
31
|
+
earlyBrowserPromise,
|
|
32
|
+
]);
|
|
33
|
+
perfLog(
|
|
34
|
+
`browser.js: playwright-core + earlyChrome resolved in ${Date.now() - waitStart}ms, earlyChrome:`,
|
|
35
|
+
earlyChrome?.cdpEndpoint ?? null,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
if (earlyChrome) {
|
|
39
|
+
const connectStart = Date.now();
|
|
40
|
+
const browser = await playwrightCore.chromium.connectOverCDP({
|
|
41
|
+
endpointURL: earlyChrome.cdpEndpoint,
|
|
42
|
+
});
|
|
43
|
+
perfLog(`browser.js: connectOverCDP took ${Date.now() - connectStart}ms`);
|
|
44
|
+
return browser;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Pre-launch failed (Chrome not found, wrong version, etc.) — fall back to normal launch.
|
|
48
|
+
const executablePath = await findChrome();
|
|
49
|
+
const launchOptions = { args: CHROMIUM_ARGS, headless: true };
|
|
50
|
+
if (executablePath) launchOptions.executablePath = executablePath;
|
|
51
|
+
return playwrightCore.chromium.launch(launchOptions);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const playwrightCore = await playwrightCorePromise;
|
|
55
|
+
return playwrightCore[browserName].launch({ headless: true });
|
|
56
|
+
}
|
|
5
57
|
|
|
6
58
|
/**
|
|
7
|
-
* Launches a
|
|
59
|
+
* Launches a Playwright browser (or reuses an existing one), starts the web server, and returns the page/server/browser connection object.
|
|
8
60
|
* @returns {Promise<{server: object, browser: object, page: object}>}
|
|
9
61
|
*/
|
|
10
62
|
export default async function setupBrowser(
|
|
@@ -13,51 +65,37 @@ export default async function setupBrowser(
|
|
|
13
65
|
debug: false,
|
|
14
66
|
watch: false,
|
|
15
67
|
timeout: 10000,
|
|
68
|
+
browser: 'chromium',
|
|
16
69
|
},
|
|
17
70
|
cachedContent,
|
|
18
71
|
existingBrowser = null,
|
|
19
72
|
) {
|
|
20
|
-
const
|
|
73
|
+
const setupStart = Date.now();
|
|
74
|
+
const [server, resolvedExistingBrowser] = await Promise.all([
|
|
21
75
|
setupWebServer(config, cachedContent),
|
|
22
|
-
existingBrowser
|
|
23
|
-
? Promise.resolve(existingBrowser)
|
|
24
|
-
: Puppeteer.launch({
|
|
25
|
-
debugger: config.debug || false,
|
|
26
|
-
args: [
|
|
27
|
-
'--no-sandbox',
|
|
28
|
-
'--disable-gpu',
|
|
29
|
-
'--remote-debugging-port=0',
|
|
30
|
-
'--window-size=1440,900',
|
|
31
|
-
'--disable-extensions',
|
|
32
|
-
'--disable-sync',
|
|
33
|
-
'--no-first-run',
|
|
34
|
-
'--disable-default-apps',
|
|
35
|
-
'--mute-audio',
|
|
36
|
-
'--disable-background-networking',
|
|
37
|
-
'--disable-background-timer-throttling',
|
|
38
|
-
'--disable-renderer-backgrounding',
|
|
39
|
-
'--disable-dev-shm-usage',
|
|
40
|
-
'--disable-translate',
|
|
41
|
-
'--metrics-recording-only',
|
|
42
|
-
'--disable-hang-monitor',
|
|
43
|
-
],
|
|
44
|
-
executablePath: await findChrome(),
|
|
45
|
-
headless: true,
|
|
46
|
-
}),
|
|
76
|
+
Promise.resolve(existingBrowser),
|
|
47
77
|
]);
|
|
78
|
+
perfLog(`browser.js: setupWebServer took ${Date.now() - setupStart}ms`);
|
|
79
|
+
|
|
80
|
+
const browser = resolvedExistingBrowser || (await launchBrowser(config));
|
|
81
|
+
|
|
82
|
+
const pageStart = Date.now();
|
|
48
83
|
const [page] = await Promise.all([browser.newPage(), bindServerToPort(server, config)]);
|
|
84
|
+
perfLog(`browser.js: newPage + bindServerToPort took ${Date.now() - pageStart}ms`);
|
|
49
85
|
|
|
50
|
-
page.
|
|
51
|
-
|
|
52
|
-
|
|
86
|
+
await page.addInitScript(() => {
|
|
87
|
+
window.IS_PLAYWRIGHT = true;
|
|
88
|
+
});
|
|
53
89
|
|
|
54
|
-
|
|
90
|
+
page.on('console', async (msg) => {
|
|
91
|
+
if (!config.debug) return;
|
|
92
|
+
try {
|
|
93
|
+
const values = await Promise.all(msg.args().map((arg) => arg.jsonValue()));
|
|
94
|
+
console.log(...values);
|
|
95
|
+
} catch {
|
|
96
|
+
console.log(msg.text());
|
|
55
97
|
}
|
|
56
98
|
});
|
|
57
|
-
page.on('error', (msg) => {
|
|
58
|
-
console.error(msg, msg.stack);
|
|
59
|
-
console.log(msg, msg.stack);
|
|
60
|
-
});
|
|
61
99
|
page.on('pageerror', (error) => {
|
|
62
100
|
console.log(error.toString());
|
|
63
101
|
console.error(error.toString());
|
|
@@ -65,7 +103,3 @@ export default async function setupBrowser(
|
|
|
65
103
|
|
|
66
104
|
return { server, browser, page };
|
|
67
105
|
}
|
|
68
|
-
|
|
69
|
-
function turnToObjects(jsHandle) {
|
|
70
|
-
return jsHandle.jsonValue();
|
|
71
|
-
}
|
|
@@ -1,20 +1,42 @@
|
|
|
1
|
-
import
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
2
4
|
import { green, magenta, red, yellow } from '../utils/color.js';
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
|
-
* Starts
|
|
7
|
+
* Starts `fs.watch` watchers for each lookup path and calls `onEventFunc` on JS/TS file changes, debounced via a flag.
|
|
8
|
+
* Uses `config.fsTree` to distinguish `unlink` (tracked file) from `unlinkDir` (directory) on deletion.
|
|
6
9
|
* @returns {object}
|
|
7
10
|
*/
|
|
8
11
|
export default function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFunc) {
|
|
9
12
|
const extensions = config.extensions || ['js', 'ts'];
|
|
10
|
-
const fileWatchers = testFileLookupPaths.reduce((
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
)
|
|
13
|
+
const fileWatchers = testFileLookupPaths.reduce((watchers, watchPath) => {
|
|
14
|
+
let ready = false;
|
|
15
|
+
const watcher = fs.watch(watchPath, { recursive: true }, async (eventType, filename) => {
|
|
16
|
+
if (!ready || !filename) return;
|
|
17
|
+
const fullPath = path.join(watchPath, filename);
|
|
18
|
+
if (eventType === 'change') {
|
|
19
|
+
return handleWatchEvent(config, extensions, 'change', fullPath, onEventFunc, onFinishFunc);
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const s = await stat(fullPath);
|
|
23
|
+
handleWatchEvent(
|
|
24
|
+
config,
|
|
25
|
+
extensions,
|
|
26
|
+
s.isDirectory() ? 'addDir' : 'add',
|
|
27
|
+
fullPath,
|
|
28
|
+
onEventFunc,
|
|
29
|
+
onFinishFunc,
|
|
30
|
+
);
|
|
31
|
+
} catch {
|
|
32
|
+
const event = config.fsTree && fullPath in config.fsTree ? 'unlink' : 'unlinkDir';
|
|
33
|
+
handleWatchEvent(config, extensions, event, fullPath, onEventFunc, onFinishFunc);
|
|
34
|
+
}
|
|
17
35
|
});
|
|
36
|
+
setImmediate(() => {
|
|
37
|
+
ready = true;
|
|
38
|
+
});
|
|
39
|
+
return Object.assign(watchers, { [watchPath]: watcher });
|
|
18
40
|
}, {});
|
|
19
41
|
|
|
20
42
|
return {
|
|
@@ -28,7 +50,7 @@ export default function setupFileWatchers(testFileLookupPaths, config, onEventFu
|
|
|
28
50
|
}
|
|
29
51
|
|
|
30
52
|
/**
|
|
31
|
-
* Routes a
|
|
53
|
+
* Routes a file-system event to fsTree mutation and optional rebuild trigger.
|
|
32
54
|
* `unlinkDir` bypasses the extension filter so deleted directories always clean up fsTree.
|
|
33
55
|
* @returns {void}
|
|
34
56
|
*/
|
package/lib/setup/web-server.js
CHANGED
|
@@ -171,9 +171,9 @@ function testRuntimeToInject(port, config) {
|
|
|
171
171
|
retryOrFail();
|
|
172
172
|
});
|
|
173
173
|
window.socket.addEventListener('message', function(messageEvent) {
|
|
174
|
-
if (!window.
|
|
174
|
+
if (!window.IS_PLAYWRIGHT && messageEvent.data === 'refresh') {
|
|
175
175
|
window.location.reload(true);
|
|
176
|
-
} else if (window.
|
|
176
|
+
} else if (window.IS_PLAYWRIGHT && messageEvent.data === 'abort') {
|
|
177
177
|
window.abortQUnit = true;
|
|
178
178
|
window.QUnit.config.queue.length = 0;
|
|
179
179
|
window.socket.send(JSON.stringify({ event: 'abort' }));
|
|
@@ -223,12 +223,12 @@ function testRuntimeToInject(port, config) {
|
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
window.QUnit.begin(() => { // NOTE: might be useful in future for hanged module tracking
|
|
226
|
-
if (window.
|
|
226
|
+
if (window.IS_PLAYWRIGHT) {
|
|
227
227
|
window.socket.send(JSON.stringify({ event: 'connection' }));
|
|
228
228
|
}
|
|
229
229
|
});
|
|
230
230
|
window.QUnit.moduleStart((details) => { // NOTE: might be useful in future for hanged module tracking
|
|
231
|
-
if (window.
|
|
231
|
+
if (window.IS_PLAYWRIGHT) {
|
|
232
232
|
window.socket.send(JSON.stringify({ event: 'moduleStart', details: details }, getCircularReplacer()));
|
|
233
233
|
}
|
|
234
234
|
});
|
|
@@ -240,7 +240,7 @@ function testRuntimeToInject(port, config) {
|
|
|
240
240
|
window.testTimeout = 0;
|
|
241
241
|
window.QUNIT_RESULT.finishedTests++;
|
|
242
242
|
window.QUNIT_RESULT.currentTest = null;
|
|
243
|
-
if (window.
|
|
243
|
+
if (window.IS_PLAYWRIGHT) {
|
|
244
244
|
window.socket.send(JSON.stringify({ event: 'testEnd', details: details, abort: window.abortQUnit }, getCircularReplacer()));
|
|
245
245
|
|
|
246
246
|
if (${config.failFast} && details.status === 'failed') {
|
|
@@ -249,10 +249,15 @@ function testRuntimeToInject(port, config) {
|
|
|
249
249
|
}
|
|
250
250
|
});
|
|
251
251
|
window.QUnit.done((details) => {
|
|
252
|
-
if (window.
|
|
252
|
+
if (window.IS_PLAYWRIGHT) {
|
|
253
253
|
window.socket.send(JSON.stringify({ event: 'done', details: details, abort: window.abortQUnit }, getCircularReplacer()));
|
|
254
|
+
// Delay the testTimeout fallback so the WS done event can arrive at Node.js first.
|
|
255
|
+
// Without this delay, waitForFunction resolves before Node.js processes the done WS
|
|
256
|
+
// message, causing the shared COUNTER to miss testEnd results under concurrent load.
|
|
257
|
+
window.setTimeout(() => { window.testTimeout = ${config.timeout}; }, 500);
|
|
258
|
+
} else {
|
|
259
|
+
window.testTimeout = ${config.timeout};
|
|
254
260
|
}
|
|
255
|
-
window.testTimeout = ${config.timeout};
|
|
256
261
|
});
|
|
257
262
|
|
|
258
263
|
window.QUnit.start();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Launch args passed to Chromium for both the CDP pre-launch spawn and the playwright fallback launch. */
|
|
2
|
+
export default [
|
|
3
|
+
'--no-sandbox',
|
|
4
|
+
'--disable-gpu',
|
|
5
|
+
'--window-size=1440,900',
|
|
6
|
+
'--disable-extensions',
|
|
7
|
+
'--disable-sync',
|
|
8
|
+
'--no-first-run',
|
|
9
|
+
'--disable-default-apps',
|
|
10
|
+
'--mute-audio',
|
|
11
|
+
'--disable-background-networking',
|
|
12
|
+
'--disable-background-timer-throttling',
|
|
13
|
+
'--disable-renderer-backgrounding',
|
|
14
|
+
'--disable-dev-shm-usage',
|
|
15
|
+
'--disable-translate',
|
|
16
|
+
'--metrics-recording-only',
|
|
17
|
+
'--disable-hang-monitor',
|
|
18
|
+
];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import findChrome from './find-chrome.js';
|
|
2
|
+
import preLaunchChrome from './pre-launch-chrome.js';
|
|
3
|
+
import CHROMIUM_ARGS from './chromium-args.js';
|
|
4
|
+
import { perfLog } from './perf-logger.js';
|
|
5
|
+
|
|
6
|
+
// This module is statically imported by cli.js so its module-level code runs
|
|
7
|
+
// at the very start of the process — before the IIFE, before playwright-core loads.
|
|
8
|
+
// For run commands only, Chrome is spawned immediately via CDP so it is ready
|
|
9
|
+
// (or nearly ready) by the time playwright-core finishes loading (~500ms later).
|
|
10
|
+
// For help/init/generate, nothing is spawned and this module costs ~0ms.
|
|
11
|
+
|
|
12
|
+
const NON_RUN_COMMANDS = new Set(['help', 'h', 'p', 'print', 'new', 'n', 'g', 'generate', 'init']);
|
|
13
|
+
const isRunCommand = Boolean(process.argv[2]) && !NON_RUN_COMMANDS.has(process.argv[2]);
|
|
14
|
+
const browserFromArgv =
|
|
15
|
+
process.argv.find((arg) => arg.startsWith('--browser='))?.split('=')[1] || 'chromium';
|
|
16
|
+
|
|
17
|
+
let earlyChromeProcRef = null;
|
|
18
|
+
process.on('exit', () => earlyChromeProcRef?.kill());
|
|
19
|
+
|
|
20
|
+
perfLog('early-chrome.js: module evaluated');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolves to `{ proc, cdpEndpoint }` when Chrome is pre-launched and ready,
|
|
24
|
+
* or `null` if pre-launch was skipped (non-run command or non-chromium browser).
|
|
25
|
+
* @type {Promise<{proc: import('node:child_process').ChildProcess, cdpEndpoint: string} | null>}
|
|
26
|
+
*/
|
|
27
|
+
export const earlyBrowserPromise =
|
|
28
|
+
isRunCommand && browserFromArgv === 'chromium'
|
|
29
|
+
? findChrome()
|
|
30
|
+
.then((chromePath) => {
|
|
31
|
+
perfLog('early-chrome.js: findChrome resolved', chromePath);
|
|
32
|
+
return preLaunchChrome(chromePath, CHROMIUM_ARGS);
|
|
33
|
+
})
|
|
34
|
+
.then((info) => {
|
|
35
|
+
perfLog('early-chrome.js: Chrome CDP ready', info?.cdpEndpoint ?? null);
|
|
36
|
+
if (info) earlyChromeProcRef = info.proc;
|
|
37
|
+
return info;
|
|
38
|
+
})
|
|
39
|
+
: Promise.resolve(null);
|
package/lib/utils/find-chrome.js
CHANGED
|
@@ -1,20 +1,38 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { accessSync, constants } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
2
3
|
|
|
3
4
|
const CANDIDATES = ['google-chrome-stable', 'google-chrome', 'chromium', 'chromium-browser'];
|
|
5
|
+
const PATH_DIRS = (process.env.PATH || '').split(':').filter(Boolean);
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
|
-
*
|
|
8
|
+
* Synchronously resolves the Chrome/Chromium executable path from `CHROME_BIN` or by probing
|
|
9
|
+
* common binary names in PATH directories using accessSync.
|
|
10
|
+
* Synchronous to avoid I/O saturation during the module-loading phase, which would cause async
|
|
11
|
+
* fs.access/exec-based approaches to be delayed by hundreds of milliseconds.
|
|
12
|
+
* @returns {string|null}
|
|
13
|
+
*/
|
|
14
|
+
function findChromeSync() {
|
|
15
|
+
if (process.env.CHROME_BIN) return process.env.CHROME_BIN;
|
|
16
|
+
|
|
17
|
+
for (const dir of PATH_DIRS) {
|
|
18
|
+
for (const name of CANDIDATES) {
|
|
19
|
+
const fullPath = join(dir, name);
|
|
20
|
+
try {
|
|
21
|
+
accessSync(fullPath, constants.X_OK);
|
|
22
|
+
return fullPath;
|
|
23
|
+
} catch {
|
|
24
|
+
// not found or not executable, try next
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolves the Chrome/Chromium executable path. Returns a Promise for API compatibility
|
|
33
|
+
* with callers, but the resolution is synchronous.
|
|
7
34
|
* @returns {Promise<string|null>}
|
|
8
35
|
*/
|
|
9
36
|
export default function findChrome() {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return Promise.any(
|
|
13
|
-
CANDIDATES.map(
|
|
14
|
-
(name) =>
|
|
15
|
-
new Promise((resolve, reject) =>
|
|
16
|
-
exec(`which ${name}`, (err, stdout) => (err ? reject() : resolve(stdout.trim()))),
|
|
17
|
-
),
|
|
18
|
-
),
|
|
19
|
-
).catch(() => null);
|
|
37
|
+
return Promise.resolve(findChromeSync());
|
|
20
38
|
}
|
|
@@ -33,10 +33,21 @@ export default function parseCliFlags(projectRoot) {
|
|
|
33
33
|
.split(',')
|
|
34
34
|
.map((e) => e.trim()),
|
|
35
35
|
});
|
|
36
|
+
} else if (arg.startsWith('--browser')) {
|
|
37
|
+
const value = arg.split('=')[1];
|
|
38
|
+
if (!['chromium', 'firefox', 'webkit'].includes(value)) {
|
|
39
|
+
console.error(
|
|
40
|
+
`Invalid --browser value: "${value}". Must be one of: chromium, firefox, webkit`,
|
|
41
|
+
);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
return Object.assign(result, { browser: value });
|
|
36
45
|
} else if (arg.startsWith('--before')) {
|
|
37
46
|
return Object.assign(result, { before: parseModule(arg.split('=')[1]) });
|
|
38
47
|
} else if (arg.startsWith('--after')) {
|
|
39
48
|
return Object.assign(result, { after: parseModule(arg.split('=')[1]) });
|
|
49
|
+
} else if (arg === '--trace-perf') {
|
|
50
|
+
return result; // consumed by perf-logger.js at module load time, not stored in config
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
// maybe set watch depth via micromatch(so incl metadata)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performance tracing logger for qunitx internals.
|
|
3
|
+
*
|
|
4
|
+
* Enable by passing `--trace-perf` to any run command:
|
|
5
|
+
* qunitx test/foo.js --trace-perf
|
|
6
|
+
*
|
|
7
|
+
* All perfLog() calls are zero-overhead no-ops when tracing is disabled —
|
|
8
|
+
* the flag is read once at module load time, no per-call argument evaluation.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const isPerfTracing = process.argv.includes('--trace-perf');
|
|
12
|
+
|
|
13
|
+
const processStart = isPerfTracing ? Date.now() : 0;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Writes a timestamped perf trace line to stderr when --trace-perf is active.
|
|
17
|
+
* @param {string} label
|
|
18
|
+
* @param {...*} details
|
|
19
|
+
*/
|
|
20
|
+
export function perfLog(label, ...details) {
|
|
21
|
+
if (!isPerfTracing) return;
|
|
22
|
+
const elapsed = Date.now() - processStart;
|
|
23
|
+
const suffix = details.length ? ' ' + details.join(' ') : '';
|
|
24
|
+
process.stderr.write(`[perf +${elapsed}ms] ${label}${suffix}\n`);
|
|
25
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const CDP_URL_REGEX = /DevTools listening on (ws:\/\/[^\s]+)/;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Spawns a headless Chrome process with remote-debugging-port=0 and resolves once the
|
|
7
|
+
* CDP WebSocket endpoint is printed to stderr. Returns null if Chrome is unavailable or
|
|
8
|
+
* fails to start, so callers can fall back to playwright's normal launch.
|
|
9
|
+
* @returns {Promise<{proc: ChildProcess, cdpEndpoint: string} | null>}
|
|
10
|
+
*/
|
|
11
|
+
export default function preLaunchChrome(chromePath, args) {
|
|
12
|
+
if (!chromePath) return Promise.resolve(null);
|
|
13
|
+
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const proc = spawn(chromePath, ['--remote-debugging-port=0', '--headless=new', ...args], {
|
|
16
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
let buffer = '';
|
|
20
|
+
proc.stderr.on('data', (chunk) => {
|
|
21
|
+
buffer += chunk.toString();
|
|
22
|
+
const match = buffer.match(CDP_URL_REGEX);
|
|
23
|
+
if (match) resolve({ proc, cdpEndpoint: match[1] });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Resolve null on any startup failure so launchBrowser falls back to chromium.launch()
|
|
27
|
+
proc.on('error', () => resolve(null));
|
|
28
|
+
proc.on('close', (code) => {
|
|
29
|
+
if (code !== null && code !== 0) resolve(null);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qunitx-cli",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.9.0",
|
|
5
5
|
"description": "Browser runner for QUnitx: run your qunitx tests in google-chrome",
|
|
6
6
|
"main": "cli.js",
|
|
7
7
|
"author": "Izel Nakri",
|
|
@@ -21,13 +21,12 @@
|
|
|
21
21
|
"lint": "deno lint lib/ cli.js",
|
|
22
22
|
"lint:docs": "node scripts/lint-docs.js",
|
|
23
23
|
"docs": "deno doc --html --name=\"qunitx-cli\" --output=docs/lib 'lib/**/*.js' README.md",
|
|
24
|
-
"build": "node build.js",
|
|
25
24
|
"changelog:unreleased": "git-cliff --unreleased --strip all",
|
|
26
25
|
"changelog:preview": "git-cliff",
|
|
27
26
|
"changelog:update": "git-cliff --output CHANGELOG.md",
|
|
28
|
-
"postinstall": "deno install --allow-scripts=npm:
|
|
29
|
-
"prepack": "npm run build",
|
|
27
|
+
"postinstall": "deno install --allow-scripts=npm:playwright-core || true",
|
|
30
28
|
"test": "node test/setup.js && FORCE_COLOR=0 node --test test/**/*-test.js",
|
|
29
|
+
"test:browser": "node test/setup.js && FORCE_COLOR=0 node --test test/flags/*-test.js test/inputs/*-test.js",
|
|
31
30
|
"test:sanity-first": "./cli.js test/helpers/failing-tests.js test/helpers/failing-tests.ts",
|
|
32
31
|
"test:sanity-second": "./cli.js test/helpers/passing-tests.js test/helpers/passing-tests.ts"
|
|
33
32
|
},
|
|
@@ -43,18 +42,17 @@
|
|
|
43
42
|
"url": "https://github.com/izelnakri/qunitx-cli.git"
|
|
44
43
|
},
|
|
45
44
|
"dependencies": {
|
|
46
|
-
"chokidar": "^5.0.0",
|
|
47
45
|
"esbuild": "^0.27.3",
|
|
48
46
|
"picomatch": "^4.0.3",
|
|
49
|
-
"
|
|
47
|
+
"playwright-core": "^1.58.2",
|
|
50
48
|
"ws": "^8.19.0"
|
|
51
49
|
},
|
|
52
50
|
"devDependencies": {
|
|
53
51
|
"cors": "^2.8.6",
|
|
54
52
|
"express": "^5.2.1",
|
|
53
|
+
"js-yaml": "^4.1.1",
|
|
55
54
|
"prettier": "^3.8.1",
|
|
56
|
-
"
|
|
57
|
-
"qunitx": "^1.0.0"
|
|
55
|
+
"qunitx": "^1.0.3"
|
|
58
56
|
},
|
|
59
57
|
"volta": {
|
|
60
58
|
"node": "24.14.0"
|
package/vendor/package.json
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"name":"qunitx-vendor","version":"0.0.1"}
|