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.
@@ -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 Puppeteer-controlled browser page and streams TAP output.
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
  ]);
@@ -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 Puppeteer page inside one shared browser instance.
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 Chrome is starting up.
84
+ // Build all group bundles and write static files while the browser is starting up.
87
85
  const [browser] = await Promise.all([
88
- findChrome().then((chromePath) =>
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([
@@ -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 Puppeteer browser (or reuses an existing one), starts the web server, and returns the page/server/browser connection object.
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 [server, browser] = await Promise.all([
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.on('console', async (msg) => {
51
- if (config.debug) {
52
- const args = await Promise.all(msg.args().map((arg) => turnToObjects(arg)));
86
+ await page.addInitScript(() => {
87
+ window.IS_PLAYWRIGHT = true;
88
+ });
53
89
 
54
- console.log(...args);
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
- }
@@ -5,4 +5,5 @@ export default {
5
5
  failFast: false,
6
6
  port: 1234,
7
7
  extensions: ['js', 'ts'],
8
+ browser: 'chromium',
8
9
  };
@@ -1,20 +1,42 @@
1
- import chokidar from 'chokidar';
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 chokidar watchers for each lookup path and calls `onEventFunc` on JS/TS file changes, debounced via a global flag.
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((watcher, watchPath) => {
11
- return Object.assign(watcher, {
12
- [watchPath]: chokidar
13
- .watch(watchPath, { ignoreInitial: true })
14
- .on('all', (event, filePath) =>
15
- handleWatchEvent(config, extensions, event, filePath, onEventFunc, onFinishFunc),
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 chokidar event to fsTree mutation and optional rebuild trigger.
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
  */
@@ -171,9 +171,9 @@ function testRuntimeToInject(port, config) {
171
171
  retryOrFail();
172
172
  });
173
173
  window.socket.addEventListener('message', function(messageEvent) {
174
- if (!window.IS_PUPPETEER && messageEvent.data === 'refresh') {
174
+ if (!window.IS_PLAYWRIGHT && messageEvent.data === 'refresh') {
175
175
  window.location.reload(true);
176
- } else if (window.IS_PUPPETEER && messageEvent.data === 'abort') {
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.IS_PUPPETEER) {
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.IS_PUPPETEER) {
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.IS_PUPPETEER) {
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.IS_PUPPETEER) {
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);
@@ -1,20 +1,38 @@
1
- import { exec } from 'node:child_process';
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
- * Resolves the Chrome/Chromium executable path from `CHROME_BIN` or by probing common binary names.
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
- if (process.env.CHROME_BIN) return Promise.resolve(process.env.CHROME_BIN);
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.7.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:puppeteer || true",
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
- "puppeteer": "^24.38.0",
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
- "qunit": "^2.25.0",
57
- "qunitx": "^1.0.0"
55
+ "qunitx": "^1.0.3"
58
56
  },
59
57
  "volta": {
60
58
  "node": "24.14.0"
@@ -1 +0,0 @@
1
- {"name":"qunitx-vendor","version":"0.0.1"}