qunitx-cli 0.9.10 → 0.10.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 CHANGED
@@ -22,9 +22,12 @@ output to the terminal.
22
22
  - `--watch` mode re-runs affected tests on file change
23
23
  - `--failFast` stops the run after the first failing test
24
24
  - `--debug` prints the local server URL and pipes browser console to stdout
25
+ - `--open` / `-o` opens the test output in the same browser the tests run in as soon as the bundle is ready; `--open=brave` opens in a specific binary instead
25
26
  - `--before` / `--after` hook scripts for server setup and teardown
26
27
  - `--timeout` controls the maximum ms to wait for the full suite to finish
28
+ - `--port` defaults to 1234 and auto-increments if taken; fails fast if an explicit port is unavailable
27
29
  - `--browser` flag to run tests in Chromium, Firefox, or WebKit
30
+ - `--version` / `-v` prints the installed version
28
31
  - Docker image for zero-install CI usage
29
32
 
30
33
  ## Installation
@@ -74,6 +77,13 @@ qunitx test/**/*.js --failFast
74
77
  # Print the server URL and pipe browser console to stdout
75
78
  qunitx test/**/*.js --debug
76
79
 
80
+ # Open output in the test browser as soon as the bundle is ready
81
+ qunitx test/**/*.js --open
82
+
83
+ # Open output in a specific browser binary instead
84
+ qunitx test/**/*.js --open=brave
85
+ qunitx test/**/*.js --open=google-chrome-lts
86
+
77
87
  # Custom timeout (ms)
78
88
  qunitx test/**/*.js --timeout=30000
79
89
 
@@ -89,6 +99,7 @@ qunitx test/**/*.js --browser=webkit
89
99
  ```
90
100
 
91
101
  > **Prerequisite for Firefox / WebKit:** install the Playwright browser binaries once:
102
+ >
92
103
  > ```sh
93
104
  > npx playwright install firefox
94
105
  > npx playwright install webkit
@@ -153,6 +164,7 @@ All CLI flags can also be set in `package.json` under the `qunitx` key, so you d
153
164
  {
154
165
  "qunitx": {
155
166
  "inputs": ["test/**/*-test.js", "test/**/*-test.ts"],
167
+ "htmlPaths": ["test/tests.html"],
156
168
  "extensions": ["js", "ts"],
157
169
  "output": "tmp",
158
170
  "timeout": 20000,
@@ -163,18 +175,31 @@ All CLI flags can also be set in `package.json` under the `qunitx` key, so you d
163
175
  }
164
176
  ```
165
177
 
166
- | Key | Default | Description |
167
- |-----|---------|-------------|
168
- | `inputs` | `[]` | Glob patterns, file paths, or directories to use as test entry points. Merged with any paths given on the CLI. |
169
- | `extensions` | `["js", "ts"]` | File extensions tracked for test discovery (directory scans) and watch-mode rebuild triggers. Add `"mjs"`, `"cjs"`, or any other extension your project uses. |
170
- | `output` | `"tmp"` | Directory where compiled test bundles are written. |
171
- | `timeout` | `20000` | Maximum milliseconds to wait for the full test suite before timing out. |
172
- | `failFast` | `false` | Stop the run after the first failing test. |
173
- | `port` | `1234` | Preferred HTTP server port. qunitx auto-selects a free port if this one is taken. |
174
- | `browser` | `"chromium"` | Browser engine to use: `"chromium"`, `"firefox"`, or `"webkit"`. Overridden by `--browser` on the CLI. |
178
+ | Key | Default | Description |
179
+ | ------------ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
180
+ | `inputs` | `[]` | Glob patterns, file paths, or directories to use as test entry points. Merged with any paths given on the CLI. |
181
+ | `htmlPaths` | `[]` | Optional HTML templates to run tests inside. Any listed `.html` file that contains `{{qunitxScript}}` or other handlebars-style tokens is treated as a test runner template. |
182
+ | `extensions` | `["js", "ts"]` | File extensions tracked for test discovery (directory scans) and watch-mode rebuild triggers. Add `"mjs"`, `"cjs"`, or any other extension your project uses. |
183
+ | `output` | `"tmp"` | Directory where compiled test bundles are written. |
184
+ | `timeout` | `20000` | Maximum milliseconds to wait for the full test suite before timing out. |
185
+ | `failFast` | `false` | Stop the run after the first failing test. |
186
+ | `port` | `1234` | Preferred HTTP server port. qunitx auto-selects a free port if this one is taken. |
187
+ | `browser` | `"chromium"` | Browser engine to use: `"chromium"`, `"firefox"`, or `"webkit"`. Overridden by `--browser` on the CLI. |
175
188
 
176
189
  CLI flags always override `package.json` values when both are present.
177
190
 
191
+ If you do not provide any HTML template, qunitx falls back to its built-in `test/tests.html` boilerplate internally, so `qunitx init` is optional.
192
+
193
+ You can also pass a custom HTML file on the CLI:
194
+
195
+ ```sh
196
+ qunitx test/**/*.js custom.html
197
+ ```
198
+
199
+ If that file contains `{{qunitxScript}}`, qunitx injects the runner script block at that exact spot. If it contains other handlebars-style tokens (e.g. `{{applicationName}}`), qunitx still treats it as a custom runner template and injects the runner before `</body>`.
200
+
201
+ The `{{qunitxScript}}` placeholder is replaced with a `<script>` tag containing the WebSocket runtime, QUnit event hooks, and the bundled test code.
202
+
178
203
  ## CLI Reference
179
204
 
180
205
  ```
@@ -189,6 +214,8 @@ Options:
189
214
  --extensions=<...> Comma-separated file extensions to track [default: js,ts]
190
215
  --before=<file> Script to run (and optionally await) before tests start
191
216
  --after=<file> Script to run (and optionally await) after tests finish
217
+ --open, -o Open output in the test browser as soon as the bundle is ready
218
+ --open=<binary> Open output in a specific browser binary (e.g. brave, google-chrome-lts)
192
219
  --port=<n> HTTP server port (auto-selects a free port if taken)
193
220
  --browser=<name> Browser engine: chromium (default), firefox, or webkit
194
221
  ```
package/dist/cli.js CHANGED
@@ -39,10 +39,11 @@ var init_find_chrome = __esm({
39
39
 
40
40
  // lib/utils/pre-launch-chrome.ts
41
41
  import { spawn } from "node:child_process";
42
- function preLaunchChrome(chromePath, args) {
42
+ function preLaunchChrome(chromePath, args, headless = true) {
43
43
  if (!chromePath) return Promise.resolve(null);
44
+ const headlessArgs = headless ? ["--headless=new"] : [];
44
45
  return new Promise((resolve) => {
45
- const proc = spawn(chromePath, ["--remote-debugging-port=0", "--headless=new", ...args], {
46
+ const proc = spawn(chromePath, ["--remote-debugging-port=0", ...headlessArgs, ...args], {
46
47
  stdio: ["ignore", "ignore", "pipe"]
47
48
  });
48
49
  let buffer = "";
@@ -107,7 +108,7 @@ var init_perf_logger = __esm({
107
108
  });
108
109
 
109
110
  // lib/utils/early-chrome.ts
110
- var NON_RUN_COMMANDS, isRunCommand, browserFromArgv, earlyChromeProcRef, earlyBrowserPromise;
111
+ var NON_RUN_COMMANDS, isRunCommand, browserFromArgv, openFromArgv, watchFromArgv, openWatchMode, earlyChromeProcRef, earlyBrowserPromise;
111
112
  var init_early_chrome = __esm({
112
113
  "lib/utils/early-chrome.ts"() {
113
114
  init_find_chrome();
@@ -116,13 +117,24 @@ var init_early_chrome = __esm({
116
117
  init_perf_logger();
117
118
  NON_RUN_COMMANDS = /* @__PURE__ */ new Set(["help", "h", "p", "print", "new", "n", "g", "generate", "init"]);
118
119
  isRunCommand = Boolean(process.argv[2]) && !NON_RUN_COMMANDS.has(process.argv[2]);
119
- browserFromArgv = process.argv.find((arg) => arg.startsWith("--browser="))?.split("=")[1] || "chromium";
120
+ ({ browserFromArgv, openFromArgv, watchFromArgv } = process.argv.reduce(
121
+ (flags, arg) => {
122
+ if (arg.startsWith("--browser=")) flags.browserFromArgv = arg.slice(10);
123
+ else if (arg === "--open" || arg === "-o") flags.openFromArgv = true;
124
+ else if (arg === "--watch" || arg === "-w") flags.watchFromArgv = true;
125
+ return flags;
126
+ },
127
+ { browserFromArgv: "chromium", openFromArgv: false, watchFromArgv: false }
128
+ ));
129
+ openWatchMode = openFromArgv && watchFromArgv;
120
130
  earlyChromeProcRef = null;
121
- process.on("exit", () => earlyChromeProcRef?.kill());
131
+ if (!openWatchMode) {
132
+ process.on("exit", () => earlyChromeProcRef?.kill());
133
+ }
122
134
  perfLog("early-chrome.js: module evaluated");
123
135
  earlyBrowserPromise = isRunCommand && browserFromArgv === "chromium" ? findChrome().then((chromePath) => {
124
136
  perfLog("early-chrome.js: findChrome resolved", chromePath);
125
- return preLaunchChrome(chromePath, chromium_args_default);
137
+ return preLaunchChrome(chromePath, chromium_args_default, !openWatchMode);
126
138
  }).then((info) => {
127
139
  perfLog("early-chrome.js: Chrome CDP ready", info?.cdpEndpoint ?? null);
128
140
  if (info) earlyChromeProcRef = info.proc;
@@ -220,6 +232,37 @@ var init_find_internal_assets_from_html = __esm({
220
232
  }
221
233
  });
222
234
 
235
+ // lib/utils/html-content-marker.ts
236
+ function findHTMLContentMarker(html) {
237
+ return html.includes(HTML_CONTENT_MARKER) ? HTML_CONTENT_MARKER : void 0;
238
+ }
239
+ function htmlHasDynamicContentMarker(html) {
240
+ return !!findHTMLContentMarker(html) || HANDLEBARS_TOKEN_REGEX.test(html);
241
+ }
242
+ function replaceHTMLContentMarker(html, content) {
243
+ const marker = findHTMLContentMarker(html);
244
+ if (marker) {
245
+ return html.replace(marker, content);
246
+ }
247
+ if (htmlHasDynamicContentMarker(html)) {
248
+ if (html.includes("</body>")) {
249
+ return html.replace("</body>", `${content}</body>`);
250
+ }
251
+ if (html.includes("</html>")) {
252
+ return html.replace("</html>", `${content}</html>`);
253
+ }
254
+ return `${html}${content}`;
255
+ }
256
+ return html;
257
+ }
258
+ var HTML_CONTENT_MARKER, HANDLEBARS_TOKEN_REGEX;
259
+ var init_html_content_marker = __esm({
260
+ "lib/utils/html-content-marker.ts"() {
261
+ HTML_CONTENT_MARKER = "{{qunitxScript}}";
262
+ HANDLEBARS_TOKEN_REGEX = /{{\s*[^}]+\s*}}/;
263
+ }
264
+ });
265
+
223
266
  // lib/tap/dump-yaml.ts
224
267
  function needsQuoting(str) {
225
268
  return NEEDS_QUOTING.test(str);
@@ -354,7 +397,19 @@ var init_display_test_result = __esm({
354
397
 
355
398
  // lib/setup/bind-server-to-port.ts
356
399
  async function bindServerToPort(server, config) {
357
- await server.listen(0);
400
+ let port = config.port;
401
+ while (true) {
402
+ try {
403
+ await server.listen(port);
404
+ break;
405
+ } catch (err) {
406
+ if (err.code === "EADDRINUSE" && !config.portExplicit) {
407
+ port++;
408
+ continue;
409
+ }
410
+ throw err;
411
+ }
412
+ }
358
413
  config.port = server._server.address().port;
359
414
  return server;
360
415
  }
@@ -440,6 +495,7 @@ var init_http = __esm({
440
495
  });
441
496
  this.wss = new WebSocketServer({ server: this._server });
442
497
  this.wss.on("error", (error) => {
498
+ if (error.code === "EADDRINUSE") return;
443
499
  console.log("# [WebSocketServer] Error:");
444
500
  console.log(error);
445
501
  });
@@ -611,10 +667,17 @@ import path3 from "node:path";
611
667
  function setupWebServer(config, cachedContent) {
612
668
  const STATIC_FILES_PATH = path3.join(config.projectRoot, config.output);
613
669
  const server = new HTTPServer();
670
+ const mainHTMLWithReplacedAssets = replaceAssetPaths(
671
+ cachedContent.mainHTML.html,
672
+ cachedContent.mainHTML.filePath,
673
+ config.projectRoot
674
+ );
614
675
  server.wss.on("connection", function connection(socket) {
615
676
  socket.on("message", function message(data) {
616
677
  const { event, details, abort } = JSON.parse(data);
617
- if (event === "connection") {
678
+ if (event === "wsOpen") {
679
+ config._onWsOpen?.();
680
+ } else if (event === "connection") {
618
681
  if (!config._groupMode) console.log("TAP version 13");
619
682
  config._resetTestTimeout?.();
620
683
  } else if (event === "testEnd" && !abort) {
@@ -631,17 +694,22 @@ function setupWebServer(config, cachedContent) {
631
694
  }
632
695
  });
633
696
  });
697
+ server.get("/tests.js", (_req, res) => {
698
+ res.writeHead(200, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
699
+ res.end(cachedContent.allTestCode);
700
+ });
701
+ server.get("/filtered-tests.js", (_req, res) => {
702
+ res.writeHead(200, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
703
+ res.end(cachedContent.filteredTestCode);
704
+ });
634
705
  server.get("/", async (_req, res) => {
635
706
  const TEST_RUNTIME_TO_INJECT = testRuntimeToInject(config.port, config);
636
707
  const htmlContent = escapeAndInjectTestsToHTML(
637
- replaceAssetPaths(
638
- cachedContent.mainHTML.html,
639
- cachedContent.mainHTML.filePath,
640
- config.projectRoot
641
- ),
708
+ mainHTMLWithReplacedAssets,
642
709
  TEST_RUNTIME_TO_INJECT,
643
- cachedContent.allTestCode
710
+ "./tests.js"
644
711
  );
712
+ res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
645
713
  res.write(htmlContent);
646
714
  res.end();
647
715
  return await fsPromise.writeFile(
@@ -652,14 +720,11 @@ function setupWebServer(config, cachedContent) {
652
720
  server.get("/qunitx.html", async (_req, res) => {
653
721
  const TEST_RUNTIME_TO_INJECT = testRuntimeToInject(config.port, config);
654
722
  const htmlContent = escapeAndInjectTestsToHTML(
655
- replaceAssetPaths(
656
- cachedContent.mainHTML.html,
657
- cachedContent.mainHTML.filePath,
658
- config.projectRoot
659
- ),
723
+ mainHTMLWithReplacedAssets,
660
724
  TEST_RUNTIME_TO_INJECT,
661
- cachedContent.filteredTestCode
725
+ "./filtered-tests.js"
662
726
  );
727
+ res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
663
728
  res.write(htmlContent);
664
729
  res.end();
665
730
  return await fsPromise.writeFile(
@@ -674,8 +739,9 @@ function setupWebServer(config, cachedContent) {
674
739
  const htmlContent = escapeAndInjectTestsToHTML(
675
740
  possibleDynamicHTML,
676
741
  TEST_RUNTIME_TO_INJECT,
677
- cachedContent.allTestCode
742
+ "/tests.js"
678
743
  );
744
+ res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
679
745
  res.write(htmlContent);
680
746
  res.end();
681
747
  return await fsPromise.writeFile(
@@ -715,8 +781,31 @@ function testRuntimeToInject(port, config) {
715
781
  }, 1000);
716
782
 
717
783
  (function() {
784
+ // wsOpenStatus: true once the WebSocket 'open' event fires (or immediately for static files).
785
+ // testsLoaded: true once tests.js has executed and dispatched 'qunitx:tests-ready'.
786
+ // Both must be true before setupQUnit() is called, ensuring QUnit.start() only runs
787
+ // after all test modules are registered.
788
+ let wsOpenStatus = window.location.protocol === 'file:';
789
+ let testsLoaded = false;
790
+
791
+ function maybeStart() {
792
+ if (wsOpenStatus && testsLoaded) setupQUnit();
793
+ }
794
+
795
+ // tests.js (loaded as an async external script) dispatches this event after registering
796
+ // all test modules. Decoupled from WS so Chrome can compile tests.js in a background
797
+ // thread while the main thread handles the WebSocket handshake.
798
+ window.addEventListener('qunitx:tests-ready', function() {
799
+ testsLoaded = true;
800
+ maybeStart();
801
+ });
802
+
803
+ // For static files (file:// protocol) there is no WebSocket server.
804
+ // wsOpenStatus is already true above; setupQUnit fires when tests load.
805
+ if (window.location.protocol === 'file:') return;
806
+
718
807
  let wsRetryCount = 0;
719
- const WS_MAX_RETRIES = 50; // 500ms total before giving up
808
+ const WS_MAX_RETRIES = Math.ceil(${config.timeout} / 10); // retry for the full test timeout window
720
809
 
721
810
  function setupWebSocket() {
722
811
  try {
@@ -728,7 +817,14 @@ function testRuntimeToInject(port, config) {
728
817
  }
729
818
 
730
819
  window.socket.addEventListener('open', function() {
731
- setupQUnit();
820
+ wsOpenStatus = true;
821
+ // Notify Node.js that the WS socket is open. This fires immediately (< 1 s) because
822
+ // this runtime script is tiny \u2014 tests.js background compilation hasn't finished yet.
823
+ // Node.js uses this to distinguish "WS never connected" from "WS connected but bundle slow".
824
+ if (window.IS_PLAYWRIGHT) {
825
+ window.socket.send(JSON.stringify({ event: 'wsOpen' }));
826
+ }
827
+ maybeStart();
732
828
  });
733
829
  window.socket.addEventListener('error', function() {
734
830
  retryOrFail();
@@ -757,8 +853,6 @@ function testRuntimeToInject(port, config) {
757
853
  setupWebSocket();
758
854
  })();
759
855
 
760
- {{allTestCode}}
761
-
762
856
  function getCircularReplacer() {
763
857
  const ancestors = [];
764
858
  return function (key, value) {
@@ -829,17 +923,18 @@ function testRuntimeToInject(port, config) {
829
923
  }
830
924
  </script>`;
831
925
  }
832
- function escapeAndInjectTestsToHTML(html, testRuntimeCode, testContentCode) {
833
- return html.replace(
834
- "{{content}}",
835
- testRuntimeCode.replace("{{allTestCode}}", testContentCode).replace("</script>", "</script>")
836
- // NOTE: remove this when simple-html-tokenizer PR gets merged
926
+ function escapeAndInjectTestsToHTML(html, testRuntimeCode, testBundleUrl) {
927
+ return replaceHTMLContentMarker(
928
+ html,
929
+ `${testRuntimeCode}
930
+ <script src="${testBundleUrl}" async></script>`
837
931
  );
838
932
  }
839
933
  var fsPromise;
840
934
  var init_web_server = __esm({
841
935
  "lib/setup/web-server.ts"() {
842
936
  init_find_internal_assets_from_html();
937
+ init_html_content_marker();
843
938
  init_display_test_result();
844
939
  init_path_exists();
845
940
  init_http();
@@ -869,12 +964,28 @@ async function launchBrowser(config) {
869
964
  return browser;
870
965
  }
871
966
  const executablePath = await findChrome();
872
- const launchOptions = { args: chromium_args_default, headless: true };
967
+ const launchOptions = {
968
+ args: chromium_args_default,
969
+ headless: true,
970
+ // Disable Playwright's async SIGTERM/SIGHUP handlers. When the CLI is killed by an
971
+ // external signal (e.g. exec() timeout in tests), those handlers start an async browser
972
+ // graceful-close that can hang indefinitely on CI, preventing the process from exiting
973
+ // and blocking the test runner. With these disabled, Node.js's default signal behaviour
974
+ // (synchronous process.exit) runs instead, and Playwright's synchronous exitHandler
975
+ // (registered via process.on('exit')) still kills the browser correctly.
976
+ handleSIGTERM: false,
977
+ handleSIGHUP: false
978
+ };
873
979
  if (executablePath) launchOptions.executablePath = executablePath;
874
980
  return playwrightCore2.chromium.launch(launchOptions);
875
981
  }
876
982
  const playwrightCore = await playwrightCorePromise;
877
- return playwrightCore[browserName].launch({ headless: true });
983
+ return playwrightCore[browserName].launch({
984
+ headless: !(config.open && config.watch),
985
+ // See comment in the chromium fallback path above for why these are disabled.
986
+ handleSIGTERM: false,
987
+ handleSIGHUP: false
988
+ });
878
989
  }
879
990
  async function setupBrowser(config, cachedContent, existingBrowser = null) {
880
991
  const setupStart = Date.now();
@@ -919,6 +1030,42 @@ var init_browser = __esm({
919
1030
  }
920
1031
  });
921
1032
 
1033
+ // lib/utils/open-output-in-browser.ts
1034
+ import { spawn as spawn2 } from "node:child_process";
1035
+ async function openOutputInBrowser(config) {
1036
+ try {
1037
+ const outputFile = config.watch ? `http://localhost:${config.port}` : `file://${config.projectRoot}/${config.output}/index.html`;
1038
+ if (typeof config.open === "string") {
1039
+ spawnDetached(config.open, [outputFile]);
1040
+ return;
1041
+ }
1042
+ const browserName = config.browser || "chromium";
1043
+ if (browserName === "firefox") {
1044
+ spawnDetached("firefox", [outputFile]);
1045
+ return;
1046
+ }
1047
+ if (browserName === "webkit") {
1048
+ if (process.platform === "darwin") spawnDetached("open", ["-a", "Safari", outputFile]);
1049
+ return;
1050
+ }
1051
+ const chromePath = await findChrome() ?? (await import("playwright-core")).chromium.executablePath();
1052
+ if (chromePath) spawnDetached(chromePath, [outputFile]);
1053
+ } catch (err) {
1054
+ console.error("# Warning: --open could not launch browser:", err);
1055
+ }
1056
+ }
1057
+ function spawnDetached(cmd, args) {
1058
+ const child = spawn2(cmd, args, { detached: true, stdio: "ignore" });
1059
+ child.on("error", () => {
1060
+ });
1061
+ child.unref();
1062
+ }
1063
+ var init_open_output_in_browser = __esm({
1064
+ "lib/utils/open-output-in-browser.ts"() {
1065
+ init_find_chrome();
1066
+ }
1067
+ });
1068
+
922
1069
  // lib/utils/time-counter.ts
923
1070
  function timeCounter() {
924
1071
  const startTime = /* @__PURE__ */ new Date();
@@ -984,7 +1131,12 @@ async function buildTestBundle(config, cachedContent) {
984
1131
  logLevel: "error",
985
1132
  outfile: `${projectRoot}/${output}/tests.js`,
986
1133
  keepNames: true,
987
- sourcemap: config.debug || config.watch ? "inline" : false
1134
+ sourcemap: config.debug ? "inline" : config.watch ? "linked" : false,
1135
+ // Signal the runtime that all test modules are registered. The runtime's maybeStart()
1136
+ // waits for both this event and the WebSocket 'open' event before calling QUnit.start().
1137
+ // Dispatching from the bundle (rather than from a script onload attr) is reliable across
1138
+ // all browsers and does not require changes to user test code.
1139
+ footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
988
1140
  }),
989
1141
  Promise.all(
990
1142
  cachedContent.htmlPathsToRunTests.map(async (htmlPath) => {
@@ -1060,26 +1212,41 @@ function buildFilteredTests(filteredTests, outputPath, config) {
1060
1212
  bundle: true,
1061
1213
  logLevel: "error",
1062
1214
  outfile: outputPath,
1063
- sourcemap: config.debug || config.watch ? "inline" : false
1215
+ sourcemap: config.debug ? "inline" : config.watch ? "linked" : false,
1216
+ footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
1064
1217
  });
1065
1218
  }
1066
1219
  async function runTestInsideHTMLFile(filePath, { page, server, browser }, config) {
1067
1220
  let QUNIT_RESULT;
1068
1221
  let targetError;
1069
1222
  let timeoutHandle;
1223
+ let wsConnected = false;
1070
1224
  try {
1071
1225
  console.log("#", blue(`QUnitX running: http://localhost:${config.port}${filePath}`));
1226
+ let resolveTestRace;
1072
1227
  const testRaceResult = new Promise((resolve) => {
1073
- config._testRunDone = () => resolve(false);
1074
- config._resetTestTimeout = () => {
1075
- clearTimeout(timeoutHandle);
1076
- timeoutHandle = setTimeout(() => resolve(true), config.timeout);
1077
- };
1228
+ resolveTestRace = resolve;
1078
1229
  });
1079
- await page.goto(`http://localhost:${config.port}${filePath}`, {
1080
- timeout: config.timeout + 1e4
1081
- });
1082
- config._resetTestTimeout();
1230
+ config._testRunDone = resolveTestRace;
1231
+ config._onWsOpen = () => {
1232
+ wsConnected = true;
1233
+ clearTimeout(timeoutHandle);
1234
+ timeoutHandle = setTimeout(resolveTestRace, config.timeout * 3);
1235
+ };
1236
+ config._resetTestTimeout = () => {
1237
+ wsConnected = true;
1238
+ clearTimeout(timeoutHandle);
1239
+ timeoutHandle = setTimeout(resolveTestRace, config.timeout);
1240
+ };
1241
+ const targetUrl = `http://localhost:${config.port}${filePath}`;
1242
+ const navOptions = { timeout: config.timeout + 1e4, waitUntil: "commit" };
1243
+ if (page.url().split("?")[0] === targetUrl) {
1244
+ await page.reload(navOptions);
1245
+ } else {
1246
+ await page.goto(targetUrl, navOptions);
1247
+ }
1248
+ clearTimeout(timeoutHandle);
1249
+ timeoutHandle = setTimeout(resolveTestRace, config.timeout * 3);
1083
1250
  await testRaceResult;
1084
1251
  QUNIT_RESULT = await page.evaluate(() => window.QUNIT_RESULT);
1085
1252
  } catch (error) {
@@ -1088,15 +1255,22 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
1088
1255
  console.error(error);
1089
1256
  } finally {
1090
1257
  clearTimeout(timeoutHandle);
1258
+ config._onWsOpen = null;
1091
1259
  config._resetTestTimeout = null;
1260
+ config._testRunDone = null;
1092
1261
  }
1093
1262
  if (!QUNIT_RESULT || QUNIT_RESULT.totalTests === 0) {
1094
- console.log(targetError);
1263
+ if (targetError) console.log(targetError);
1264
+ const wsReason = !wsConnected ? "WebSocket connection never received \u2014 Chrome may be CPU-starved or the page failed to load" : "WebSocket connected but no tests ran \u2014 QUnit may have failed to start";
1265
+ console.log(`# TIMEOUT: ${wsReason}`);
1095
1266
  console.log("BROWSER: runtime error thrown during executing tests");
1096
1267
  console.error("BROWSER: runtime error thrown during executing tests");
1097
1268
  await failOnNonWatchMode(config.watch, { server, browser }, config._groupMode);
1098
1269
  } else if (QUNIT_RESULT.totalTests > QUNIT_RESULT.finishedTests) {
1099
- console.log(targetError);
1270
+ if (targetError) console.log(targetError);
1271
+ console.log(
1272
+ `# TIMEOUT: test stalled after ${QUNIT_RESULT.finishedTests}/${QUNIT_RESULT.totalTests} finished \u2014 last active: ${QUNIT_RESULT.currentTest}`
1273
+ );
1100
1274
  console.log(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
1101
1275
  console.error(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
1102
1276
  await failOnNonWatchMode(config.watch, { server, browser }, config._groupMode);
@@ -1139,12 +1313,21 @@ import { stat } from "node:fs/promises";
1139
1313
  import path4 from "node:path";
1140
1314
  function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFunc) {
1141
1315
  const extensions = config.extensions || ["js", "ts"];
1316
+ const readyPromises = [];
1317
+ const parentWatchers = [];
1142
1318
  const fileWatchers = testFileLookupPaths.reduce((watchers, watchPath) => {
1143
1319
  let ready = false;
1144
- const watcher = fs9.watch(watchPath, { recursive: true }, async (eventType, filename) => {
1320
+ const lastChangeMs = {};
1321
+ const CHANGE_DEDUPE_MS = 30;
1322
+ const childWatcher = fs9.watch(watchPath, { recursive: true }, async (eventType, filename) => {
1145
1323
  if (!ready || !filename) return;
1146
1324
  const fullPath = path4.join(watchPath, filename);
1147
1325
  if (eventType === "change") {
1326
+ if (!config._building) {
1327
+ const now = Date.now();
1328
+ if (now - (lastChangeMs[fullPath] ?? 0) < CHANGE_DEDUPE_MS) return;
1329
+ lastChangeMs[fullPath] = now;
1330
+ }
1148
1331
  return handleWatchEvent(config, extensions, "change", fullPath, onEventFunc, onFinishFunc);
1149
1332
  }
1150
1333
  try {
@@ -1158,19 +1341,41 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
1158
1341
  onFinishFunc
1159
1342
  );
1160
1343
  } catch {
1161
- const event = config.fsTree && fullPath in config.fsTree ? "unlink" : "unlinkDir";
1162
- handleWatchEvent(config, extensions, event, fullPath, onEventFunc, onFinishFunc);
1344
+ if (!(config.fsTree && fullPath in config.fsTree)) return;
1345
+ handleWatchEvent(config, extensions, "unlink", fullPath, onEventFunc, onFinishFunc);
1163
1346
  }
1164
1347
  });
1165
- setImmediate(() => {
1166
- ready = true;
1348
+ const parentDir = path4.dirname(watchPath);
1349
+ const watchedBasename = path4.basename(watchPath);
1350
+ const parentWatcher = fs9.watch(parentDir, async (eventType, filename) => {
1351
+ if (!ready || filename !== watchedBasename || eventType !== "rename") return;
1352
+ try {
1353
+ await stat(watchPath);
1354
+ } catch {
1355
+ handleWatchEvent(config, extensions, "unlinkDir", watchPath, onEventFunc, onFinishFunc);
1356
+ childWatcher.close();
1357
+ parentWatcher.close();
1358
+ delete watchers[watchPath];
1359
+ }
1167
1360
  });
1168
- return Object.assign(watchers, { [watchPath]: watcher });
1361
+ parentWatchers.push(parentWatcher);
1362
+ readyPromises.push(
1363
+ new Promise(
1364
+ (resolve) => setImmediate(() => {
1365
+ ready = true;
1366
+ resolve();
1367
+ })
1368
+ )
1369
+ );
1370
+ return Object.assign(watchers, { [watchPath]: childWatcher });
1169
1371
  }, {});
1170
1372
  return {
1171
1373
  fileWatchers,
1374
+ ready: Promise.all(readyPromises).then(() => {
1375
+ }),
1172
1376
  killFileWatchers() {
1173
- Object.keys(fileWatchers).forEach((watcherKey) => fileWatchers[watcherKey].close());
1377
+ Object.keys(fileWatchers).forEach((key) => fileWatchers[key].close());
1378
+ parentWatchers.forEach((pw) => pw.close());
1174
1379
  return fileWatchers;
1175
1380
  }
1176
1381
  };
@@ -1199,7 +1404,16 @@ function handleWatchEvent(config, extensions, event, filePath, onEventFunc, onFi
1199
1404
  onFinishFunc ? onFinishFunc(event, filePath) : null;
1200
1405
  }).catch((error) => {
1201
1406
  console.error("#", red("Build error:"), error.message || error);
1202
- }).finally(() => config._building = false);
1407
+ }).finally(() => {
1408
+ config._building = false;
1409
+ if (config._pendingBuildTrigger) {
1410
+ const trigger = config._pendingBuildTrigger;
1411
+ config._pendingBuildTrigger = null;
1412
+ trigger();
1413
+ }
1414
+ });
1415
+ } else {
1416
+ config._pendingBuildTrigger = () => handleWatchEvent(config, extensions, event, filePath, onEventFunc, onFinishFunc);
1203
1417
  }
1204
1418
  }
1205
1419
  function mutateFSTree(fsTree, event, path5) {
@@ -1346,6 +1560,9 @@ async function run(config) {
1346
1560
  ]);
1347
1561
  config.expressApp = connections.server;
1348
1562
  setupKeyboardEvents(config, cachedContent, connections);
1563
+ if (config.open) {
1564
+ void openOutputInBrowser(config);
1565
+ }
1349
1566
  if (config.before) {
1350
1567
  await runUserModule(`${process.cwd()}/${config.before}`, config, "before");
1351
1568
  }
@@ -1358,19 +1575,24 @@ async function run(config) {
1358
1575
  ]);
1359
1576
  throw error;
1360
1577
  }
1578
+ if (config.watch) {
1579
+ const { ready: watcherReady } = setupFileWatchers(
1580
+ config.testFileLookupPaths,
1581
+ config,
1582
+ async (event, file) => {
1583
+ if (event === "addDir") return;
1584
+ if (["change", "unlink", "unlinkDir"].includes(event)) {
1585
+ if (event === "change" && !(file in config.fsTree)) return;
1586
+ cachedContent.allTestCode = null;
1587
+ return await runTestsInBrowser(config, cachedContent, connections);
1588
+ }
1589
+ await runTestsInBrowser(config, cachedContent, connections, [file]);
1590
+ },
1591
+ (_path, _event) => connections.server.publish("refresh", "refresh")
1592
+ );
1593
+ await watcherReady;
1594
+ }
1361
1595
  logWatcherAndKeyboardShortcutInfo(config, connections.server);
1362
- await setupFileWatchers(
1363
- config.testFileLookupPaths,
1364
- config,
1365
- async (event, file) => {
1366
- if (event === "addDir") return;
1367
- if (["unlink", "unlinkDir"].includes(event)) {
1368
- return await runTestsInBrowser(config, cachedContent, connections);
1369
- }
1370
- await runTestsInBrowser(config, cachedContent, connections, [file]);
1371
- },
1372
- (_path, _event) => connections.server.publish("refresh", "refresh")
1373
- );
1374
1596
  } else {
1375
1597
  const allFiles = Object.keys(config.fsTree);
1376
1598
  const groupCount = Math.min(allFiles.length, availableParallelism());
@@ -1397,6 +1619,9 @@ async function run(config) {
1397
1619
  )
1398
1620
  )
1399
1621
  ]);
1622
+ if (config.open) {
1623
+ void openOutputInBrowser(config);
1624
+ }
1400
1625
  const TIME_COUNTER = timeCounter();
1401
1626
  const GROUP_TIMEOUT_MS = 3 * 60 * 1e3;
1402
1627
  const keepAlive = setInterval(() => {
@@ -1473,14 +1698,14 @@ async function buildCachedContent(config, htmlPaths) {
1473
1698
  if (buffer === null) return result;
1474
1699
  const filePath = config.htmlPaths[index];
1475
1700
  const html = buffer.toString();
1476
- if (html.includes("{{content}}")) {
1701
+ if (htmlHasDynamicContentMarker(html)) {
1477
1702
  result.dynamicContentHTMLs[filePath] = html;
1478
1703
  result.htmlPathsToRunTests.push(filePath.replace(config.projectRoot, ""));
1479
1704
  } else {
1480
1705
  console.log(
1481
1706
  "#",
1482
1707
  yellow(
1483
- `WARNING: Static html file with no {{content}} detected. Therefore ignoring ${filePath}`
1708
+ `WARNING: Static html file with no {{qunitxScript}} or handlebars-style tokens detected. Therefore ignoring ${filePath}`
1484
1709
  )
1485
1710
  );
1486
1711
  result.staticHTMLs[filePath] = html;
@@ -1524,9 +1749,10 @@ function splitIntoGroups(files, groupCount) {
1524
1749
  return groups.filter((g) => g.length > 0);
1525
1750
  }
1526
1751
  function logWatcherAndKeyboardShortcutInfo(config, _server) {
1752
+ const prefix = "Watching files...";
1527
1753
  console.log(
1528
1754
  "#",
1529
- blue(`Watching files... You can browse the tests on http://localhost:${config.port} ...`)
1755
+ blue(`${prefix} You can browse the tests on http://localhost:${config.port} ...`)
1530
1756
  );
1531
1757
  console.log(
1532
1758
  "#",
@@ -1542,6 +1768,7 @@ function normalizeInternalAssetPathFromHTML(projectRoot, assetPath, htmlPath) {
1542
1768
  var init_run = __esm({
1543
1769
  "lib/commands/run.ts"() {
1544
1770
  init_browser();
1771
+ init_open_output_in_browser();
1545
1772
  init_color();
1546
1773
  init_tests_in_browser();
1547
1774
  init_file_watcher();
@@ -1552,6 +1779,7 @@ var init_run = __esm({
1552
1779
  init_time_counter();
1553
1780
  init_display_final_result();
1554
1781
  init_read_boilerplate();
1782
+ init_html_content_marker();
1555
1783
  }
1556
1784
  });
1557
1785
 
@@ -1566,7 +1794,7 @@ init_color();
1566
1794
  var package_default = {
1567
1795
  name: "qunitx-cli",
1568
1796
  type: "module",
1569
- version: "0.9.10",
1797
+ version: "0.10.0",
1570
1798
  description: "Browser runner for QUnitx: run your qunitx tests in google-chrome",
1571
1799
  author: "Izel Nakri",
1572
1800
  license: "MIT",
@@ -1595,7 +1823,7 @@ var package_default = {
1595
1823
  "changelog:unreleased": "git-cliff --unreleased --strip all",
1596
1824
  "changelog:preview": "git-cliff",
1597
1825
  "changelog:update": "git-cliff --output CHANGELOG.md",
1598
- postinstall: "deno install --allow-scripts=npm:playwright-core || true",
1826
+ postinstall: "PLAYWRIGHT_SKIP_DOWNLOAD=true deno install --allow-scripts=npm:playwright-core || true",
1599
1827
  test: `node --experimental-strip-types test/setup.ts && FORCE_COLOR=0 node --experimental-strip-types --test --test-concurrency=$(node -p 'require("os").availableParallelism()') test/**/*-test.ts`,
1600
1828
  "test:browser": `node --experimental-strip-types test/setup.ts && FORCE_COLOR=0 node --experimental-strip-types --test --test-concurrency=$(node -p 'require("os").availableParallelism()') test/flags/*-test.ts test/inputs/*-test.ts`,
1601
1829
  "test:sanity-first": "./cli.ts test/helpers/failing-tests.js test/helpers/failing-tests.ts",
@@ -1622,7 +1850,7 @@ var package_default = {
1622
1850
  express: "^5.2.1",
1623
1851
  "js-yaml": "^4.1.1",
1624
1852
  prettier: "^3.8.1",
1625
- qunitx: "^1.1.2",
1853
+ qunitx: "^1.2.1",
1626
1854
  typescript: "^6.0.2"
1627
1855
  },
1628
1856
  volta: {
@@ -1654,6 +1882,7 @@ ${highlight("Input options:")}
1654
1882
  ${highlight("Optional flags:")}
1655
1883
  ${color("--debug")} : print console output when tests run in browser
1656
1884
  ${color("--watch")} : run the target file or folders, watch them for continuous run and expose http server under localhost
1885
+ ${color("--open")} : run tests in a visible browser window instead of headless; keeps the server alive (short: ${color("-o")})
1657
1886
  ${color("--timeout")} : change default timeout per test case
1658
1887
  ${color("--output")} : folder to distribute built qunitx html and js that a webservers can run[default: tmp]
1659
1888
  ${color("--failFast")} : run the target file or folders with immediate abort if a single test fails
@@ -1902,6 +2131,10 @@ function parseCliFlags(projectRoot) {
1902
2131
  return Object.assign(result, { debug: parseBoolean(arg.split("=")[1]) });
1903
2132
  } else if (arg.startsWith("--watch")) {
1904
2133
  return Object.assign(result, { watch: parseBoolean(arg.split("=")[1]) });
2134
+ } else if (arg === "-o" || arg.startsWith("-o=") || arg.startsWith("--open")) {
2135
+ const value = arg.split("=")[1];
2136
+ const open = value === void 0 || value === "true" ? true : value === "false" ? false : value;
2137
+ return Object.assign(result, { open });
1905
2138
  } else if (arg.startsWith("--failfast") || arg.startsWith("--failFast")) {
1906
2139
  return Object.assign(result, { failFast: parseBoolean(arg.split("=")[1]) });
1907
2140
  } else if (arg.startsWith("--timeout")) {
@@ -1916,7 +2149,7 @@ function parseCliFlags(projectRoot) {
1916
2149
  }
1917
2150
  return result;
1918
2151
  } else if (arg.startsWith("--port")) {
1919
- return Object.assign(result, { port: Number(arg.split("=")[1]) });
2152
+ return Object.assign(result, { port: Number(arg.split("=")[1]), portExplicit: true });
1920
2153
  } else if (arg.startsWith("--extensions")) {
1921
2154
  return Object.assign(result, {
1922
2155
  extensions: arg.split("=")[1].split(",").map((e) => e.trim())
@@ -1937,6 +2170,10 @@ function parseCliFlags(projectRoot) {
1937
2170
  } else if (arg === "--trace-perf") {
1938
2171
  return result;
1939
2172
  }
2173
+ if (arg.startsWith("-")) {
2174
+ console.warn(`# Warning: Unknown flag "${arg}" \u2014 ignored`);
2175
+ return result;
2176
+ }
1940
2177
  result.inputs.add(arg.startsWith(projectRoot) ? arg : `${process.cwd()}/${arg}`);
1941
2178
  return result;
1942
2179
  },
@@ -2000,6 +2237,8 @@ process4.title = "qunitx";
2000
2237
  (async () => {
2001
2238
  if (!process4.argv[2]) {
2002
2239
  return await displayHelpOutput();
2240
+ } else if (["--version", "-v", "version"].includes(process4.argv[2])) {
2241
+ return process4.stdout.write(package_default.version + "\n");
2003
2242
  } else if (["help", "h", "p", "print"].includes(process4.argv[2])) {
2004
2243
  return await displayHelpOutput();
2005
2244
  } else if (["new", "n", "g", "generate"].includes(process4.argv[2])) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "qunitx-cli",
3
3
  "type": "module",
4
- "version": "0.9.10",
4
+ "version": "0.10.0",
5
5
  "description": "Browser runner for QUnitx: run your qunitx tests in google-chrome",
6
6
  "author": "Izel Nakri",
7
7
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  "changelog:unreleased": "git-cliff --unreleased --strip all",
31
31
  "changelog:preview": "git-cliff",
32
32
  "changelog:update": "git-cliff --output CHANGELOG.md",
33
- "postinstall": "deno install --allow-scripts=npm:playwright-core || true",
33
+ "postinstall": "PLAYWRIGHT_SKIP_DOWNLOAD=true deno install --allow-scripts=npm:playwright-core || true",
34
34
  "test": "node --experimental-strip-types test/setup.ts && FORCE_COLOR=0 node --experimental-strip-types --test --test-concurrency=$(node -p 'require(\"os\").availableParallelism()') test/**/*-test.ts",
35
35
  "test:browser": "node --experimental-strip-types test/setup.ts && FORCE_COLOR=0 node --experimental-strip-types --test --test-concurrency=$(node -p 'require(\"os\").availableParallelism()') test/flags/*-test.ts test/inputs/*-test.ts",
36
36
  "test:sanity-first": "./cli.ts test/helpers/failing-tests.js test/helpers/failing-tests.ts",
@@ -57,7 +57,7 @@
57
57
  "express": "^5.2.1",
58
58
  "js-yaml": "^4.1.1",
59
59
  "prettier": "^3.8.1",
60
- "qunitx": "^1.1.2",
60
+ "qunitx": "^1.2.1",
61
61
  "typescript": "^6.0.2"
62
62
  },
63
63
  "volta": {
@@ -10,6 +10,6 @@
10
10
  <div id="qunit"></div>
11
11
  <div id="qunit-fixture"></div>
12
12
 
13
- {{content}}
13
+ {{qunitxScript}}
14
14
  </body>
15
15
  </html>