qunitx-cli 0.9.10 → 0.11.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,30 @@ 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", () => {
133
+ if (!earlyChromeProcRef) return;
134
+ try {
135
+ earlyChromeProcRef.kill("SIGKILL");
136
+ } catch {
137
+ }
138
+ });
139
+ }
122
140
  perfLog("early-chrome.js: module evaluated");
123
141
  earlyBrowserPromise = isRunCommand && browserFromArgv === "chromium" ? findChrome().then((chromePath) => {
124
142
  perfLog("early-chrome.js: findChrome resolved", chromePath);
125
- return preLaunchChrome(chromePath, chromium_args_default);
143
+ return preLaunchChrome(chromePath, chromium_args_default, !openWatchMode);
126
144
  }).then((info) => {
127
145
  perfLog("early-chrome.js: Chrome CDP ready", info?.cdpEndpoint ?? null);
128
146
  if (info) earlyChromeProcRef = info.proc;
@@ -220,6 +238,37 @@ var init_find_internal_assets_from_html = __esm({
220
238
  }
221
239
  });
222
240
 
241
+ // lib/utils/html-content-marker.ts
242
+ function findHTMLContentMarker(html) {
243
+ return html.includes(HTML_CONTENT_MARKER) ? HTML_CONTENT_MARKER : void 0;
244
+ }
245
+ function htmlHasDynamicContentMarker(html) {
246
+ return !!findHTMLContentMarker(html) || HANDLEBARS_TOKEN_REGEX.test(html);
247
+ }
248
+ function replaceHTMLContentMarker(html, content) {
249
+ const marker = findHTMLContentMarker(html);
250
+ if (marker) {
251
+ return html.replace(marker, content);
252
+ }
253
+ if (htmlHasDynamicContentMarker(html)) {
254
+ if (html.includes("</body>")) {
255
+ return html.replace("</body>", `${content}</body>`);
256
+ }
257
+ if (html.includes("</html>")) {
258
+ return html.replace("</html>", `${content}</html>`);
259
+ }
260
+ return `${html}${content}`;
261
+ }
262
+ return html;
263
+ }
264
+ var HTML_CONTENT_MARKER, HANDLEBARS_TOKEN_REGEX;
265
+ var init_html_content_marker = __esm({
266
+ "lib/utils/html-content-marker.ts"() {
267
+ HTML_CONTENT_MARKER = "{{qunitxScript}}";
268
+ HANDLEBARS_TOKEN_REGEX = /{{\s*[^}]+\s*}}/;
269
+ }
270
+ });
271
+
223
272
  // lib/tap/dump-yaml.ts
224
273
  function needsQuoting(str) {
225
274
  return NEEDS_QUOTING.test(str);
@@ -354,7 +403,19 @@ var init_display_test_result = __esm({
354
403
 
355
404
  // lib/setup/bind-server-to-port.ts
356
405
  async function bindServerToPort(server, config) {
357
- await server.listen(0);
406
+ let port = config.port;
407
+ while (true) {
408
+ try {
409
+ await server.listen(port);
410
+ break;
411
+ } catch (err) {
412
+ if (err.code === "EADDRINUSE" && !config.portExplicit) {
413
+ port++;
414
+ continue;
415
+ }
416
+ throw err;
417
+ }
418
+ }
358
419
  config.port = server._server.address().port;
359
420
  return server;
360
421
  }
@@ -440,6 +501,7 @@ var init_http = __esm({
440
501
  });
441
502
  this.wss = new WebSocketServer({ server: this._server });
442
503
  this.wss.on("error", (error) => {
504
+ if (error.code === "EADDRINUSE") return;
443
505
  console.log("# [WebSocketServer] Error:");
444
506
  console.log(error);
445
507
  });
@@ -611,10 +673,17 @@ import path3 from "node:path";
611
673
  function setupWebServer(config, cachedContent) {
612
674
  const STATIC_FILES_PATH = path3.join(config.projectRoot, config.output);
613
675
  const server = new HTTPServer();
676
+ const mainHTMLWithReplacedAssets = replaceAssetPaths(
677
+ cachedContent.mainHTML.html,
678
+ cachedContent.mainHTML.filePath,
679
+ config.projectRoot
680
+ );
614
681
  server.wss.on("connection", function connection(socket) {
615
682
  socket.on("message", function message(data) {
616
683
  const { event, details, abort } = JSON.parse(data);
617
- if (event === "connection") {
684
+ if (event === "wsOpen") {
685
+ config._onWsOpen?.();
686
+ } else if (event === "connection") {
618
687
  if (!config._groupMode) console.log("TAP version 13");
619
688
  config._resetTestTimeout?.();
620
689
  } else if (event === "testEnd" && !abort) {
@@ -631,17 +700,46 @@ function setupWebServer(config, cachedContent) {
631
700
  }
632
701
  });
633
702
  });
703
+ server.get("/tests.js", (_req, res) => {
704
+ const bytes = cachedContent.allTestCode?.length ?? null;
705
+ console.log(
706
+ `# [HTTPServer] GET /tests.js \u2192 ${bytes !== null ? `${bytes} bytes` : "NOT READY (allTestCode is null)"}`
707
+ );
708
+ if (bytes === null) {
709
+ res.writeHead(503, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
710
+ res.end(
711
+ 'console.error("[qunitx] /tests.js requested before bundle was built \u2014 allTestCode is null");'
712
+ );
713
+ return;
714
+ }
715
+ config._onTestsJsServed?.();
716
+ res.writeHead(200, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
717
+ res.end(cachedContent.allTestCode);
718
+ });
719
+ server.get("/filtered-tests.js", (_req, res) => {
720
+ const bytes = cachedContent.filteredTestCode?.length ?? null;
721
+ console.log(
722
+ `# [HTTPServer] GET /filtered-tests.js \u2192 ${bytes !== null ? `${bytes} bytes` : "NOT READY (filteredTestCode is null)"}`
723
+ );
724
+ if (bytes === null) {
725
+ res.writeHead(503, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
726
+ res.end(
727
+ 'console.error("[qunitx] /filtered-tests.js requested before bundle was built \u2014 filteredTestCode is null");'
728
+ );
729
+ return;
730
+ }
731
+ config._onTestsJsServed?.();
732
+ res.writeHead(200, { "Content-Type": "application/javascript", "Cache-Control": "no-store" });
733
+ res.end(cachedContent.filteredTestCode);
734
+ });
634
735
  server.get("/", async (_req, res) => {
635
736
  const TEST_RUNTIME_TO_INJECT = testRuntimeToInject(config.port, config);
636
737
  const htmlContent = escapeAndInjectTestsToHTML(
637
- replaceAssetPaths(
638
- cachedContent.mainHTML.html,
639
- cachedContent.mainHTML.filePath,
640
- config.projectRoot
641
- ),
738
+ mainHTMLWithReplacedAssets,
642
739
  TEST_RUNTIME_TO_INJECT,
643
- cachedContent.allTestCode
740
+ "./tests.js"
644
741
  );
742
+ res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
645
743
  res.write(htmlContent);
646
744
  res.end();
647
745
  return await fsPromise.writeFile(
@@ -652,14 +750,11 @@ function setupWebServer(config, cachedContent) {
652
750
  server.get("/qunitx.html", async (_req, res) => {
653
751
  const TEST_RUNTIME_TO_INJECT = testRuntimeToInject(config.port, config);
654
752
  const htmlContent = escapeAndInjectTestsToHTML(
655
- replaceAssetPaths(
656
- cachedContent.mainHTML.html,
657
- cachedContent.mainHTML.filePath,
658
- config.projectRoot
659
- ),
753
+ mainHTMLWithReplacedAssets,
660
754
  TEST_RUNTIME_TO_INJECT,
661
- cachedContent.filteredTestCode
755
+ "./filtered-tests.js"
662
756
  );
757
+ res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
663
758
  res.write(htmlContent);
664
759
  res.end();
665
760
  return await fsPromise.writeFile(
@@ -674,8 +769,9 @@ function setupWebServer(config, cachedContent) {
674
769
  const htmlContent = escapeAndInjectTestsToHTML(
675
770
  possibleDynamicHTML,
676
771
  TEST_RUNTIME_TO_INJECT,
677
- cachedContent.allTestCode
772
+ "/tests.js"
678
773
  );
774
+ res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-store" });
679
775
  res.write(htmlContent);
680
776
  res.end();
681
777
  return await fsPromise.writeFile(
@@ -715,8 +811,31 @@ function testRuntimeToInject(port, config) {
715
811
  }, 1000);
716
812
 
717
813
  (function() {
814
+ // wsOpenStatus: true once the WebSocket 'open' event fires (or immediately for static files).
815
+ // testsLoaded: true once tests.js has executed and dispatched 'qunitx:tests-ready'.
816
+ // Both must be true before setupQUnit() is called, ensuring QUnit.start() only runs
817
+ // after all test modules are registered.
818
+ let wsOpenStatus = window.location.protocol === 'file:';
819
+ let testsLoaded = false;
820
+
821
+ function maybeStart() {
822
+ if (wsOpenStatus && testsLoaded) setupQUnit();
823
+ }
824
+
825
+ // tests.js (loaded as an async external script) dispatches this event after registering
826
+ // all test modules. Decoupled from WS so Chrome can compile tests.js in a background
827
+ // thread while the main thread handles the WebSocket handshake.
828
+ window.addEventListener('qunitx:tests-ready', function() {
829
+ testsLoaded = true;
830
+ maybeStart();
831
+ });
832
+
833
+ // For static files (file:// protocol) there is no WebSocket server.
834
+ // wsOpenStatus is already true above; setupQUnit fires when tests load.
835
+ if (window.location.protocol === 'file:') return;
836
+
718
837
  let wsRetryCount = 0;
719
- const WS_MAX_RETRIES = 50; // 500ms total before giving up
838
+ const WS_MAX_RETRIES = Math.ceil(${config.timeout} / 10); // retry for the full test timeout window
720
839
 
721
840
  function setupWebSocket() {
722
841
  try {
@@ -728,7 +847,14 @@ function testRuntimeToInject(port, config) {
728
847
  }
729
848
 
730
849
  window.socket.addEventListener('open', function() {
731
- setupQUnit();
850
+ wsOpenStatus = true;
851
+ // Notify Node.js that the WS socket is open. This fires immediately (< 1 s) because
852
+ // this runtime script is tiny \u2014 tests.js background compilation hasn't finished yet.
853
+ // Node.js uses this to distinguish "WS never connected" from "WS connected but bundle slow".
854
+ if (window.IS_PLAYWRIGHT) {
855
+ window.socket.send(JSON.stringify({ event: 'wsOpen' }));
856
+ }
857
+ maybeStart();
732
858
  });
733
859
  window.socket.addEventListener('error', function() {
734
860
  retryOrFail();
@@ -757,8 +883,6 @@ function testRuntimeToInject(port, config) {
757
883
  setupWebSocket();
758
884
  })();
759
885
 
760
- {{allTestCode}}
761
-
762
886
  function getCircularReplacer() {
763
887
  const ancestors = [];
764
888
  return function (key, value) {
@@ -829,17 +953,18 @@ function testRuntimeToInject(port, config) {
829
953
  }
830
954
  </script>`;
831
955
  }
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
956
+ function escapeAndInjectTestsToHTML(html, testRuntimeCode, testBundleUrl) {
957
+ return replaceHTMLContentMarker(
958
+ html,
959
+ `${testRuntimeCode}
960
+ <script src="${testBundleUrl}" async></script>`
837
961
  );
838
962
  }
839
963
  var fsPromise;
840
964
  var init_web_server = __esm({
841
965
  "lib/setup/web-server.ts"() {
842
966
  init_find_internal_assets_from_html();
967
+ init_html_content_marker();
843
968
  init_display_test_result();
844
969
  init_path_exists();
845
970
  init_http();
@@ -869,12 +994,28 @@ async function launchBrowser(config) {
869
994
  return browser;
870
995
  }
871
996
  const executablePath = await findChrome();
872
- const launchOptions = { args: chromium_args_default, headless: true };
997
+ const launchOptions = {
998
+ args: chromium_args_default,
999
+ headless: true,
1000
+ // Disable Playwright's async SIGTERM/SIGHUP handlers. When the CLI is killed by an
1001
+ // external signal (e.g. exec() timeout in tests), those handlers start an async browser
1002
+ // graceful-close that can hang indefinitely on CI, preventing the process from exiting
1003
+ // and blocking the test runner. With these disabled, Node.js's default signal behaviour
1004
+ // (synchronous process.exit) runs instead, and Playwright's synchronous exitHandler
1005
+ // (registered via process.on('exit')) still kills the browser correctly.
1006
+ handleSIGTERM: false,
1007
+ handleSIGHUP: false
1008
+ };
873
1009
  if (executablePath) launchOptions.executablePath = executablePath;
874
1010
  return playwrightCore2.chromium.launch(launchOptions);
875
1011
  }
876
1012
  const playwrightCore = await playwrightCorePromise;
877
- return playwrightCore[browserName].launch({ headless: true });
1013
+ return playwrightCore[browserName].launch({
1014
+ headless: !(config.open && config.watch),
1015
+ // See comment in the chromium fallback path above for why these are disabled.
1016
+ handleSIGTERM: false,
1017
+ handleSIGHUP: false
1018
+ });
878
1019
  }
879
1020
  async function setupBrowser(config, cachedContent, existingBrowser = null) {
880
1021
  const setupStart = Date.now();
@@ -891,7 +1032,9 @@ async function setupBrowser(config, cachedContent, existingBrowser = null) {
891
1032
  window.IS_PLAYWRIGHT = true;
892
1033
  });
893
1034
  page.on("console", async (msg) => {
894
- if (!config.debug) return;
1035
+ const type = msg.type();
1036
+ const alwaysShow = type === "warning" || type === "error";
1037
+ if (!alwaysShow && !config.debug) return;
895
1038
  try {
896
1039
  const values = await Promise.all(msg.args().map((arg) => arg.jsonValue()));
897
1040
  console.log(...values);
@@ -919,6 +1062,42 @@ var init_browser = __esm({
919
1062
  }
920
1063
  });
921
1064
 
1065
+ // lib/utils/open-output-in-browser.ts
1066
+ import { spawn as spawn2 } from "node:child_process";
1067
+ async function openOutputInBrowser(config) {
1068
+ try {
1069
+ const outputFile = config.watch ? `http://localhost:${config.port}` : `file://${config.projectRoot}/${config.output}/index.html`;
1070
+ if (typeof config.open === "string") {
1071
+ spawnDetached(config.open, [outputFile]);
1072
+ return;
1073
+ }
1074
+ const browserName = config.browser || "chromium";
1075
+ if (browserName === "firefox") {
1076
+ spawnDetached("firefox", [outputFile]);
1077
+ return;
1078
+ }
1079
+ if (browserName === "webkit") {
1080
+ if (process.platform === "darwin") spawnDetached("open", ["-a", "Safari", outputFile]);
1081
+ return;
1082
+ }
1083
+ const chromePath = await findChrome() ?? (await import("playwright-core")).chromium.executablePath();
1084
+ if (chromePath) spawnDetached(chromePath, [outputFile]);
1085
+ } catch (err) {
1086
+ console.error("# Warning: --open could not launch browser:", err);
1087
+ }
1088
+ }
1089
+ function spawnDetached(cmd, args) {
1090
+ const child = spawn2(cmd, args, { detached: true, stdio: "ignore" });
1091
+ child.on("error", () => {
1092
+ });
1093
+ child.unref();
1094
+ }
1095
+ var init_open_output_in_browser = __esm({
1096
+ "lib/utils/open-output-in-browser.ts"() {
1097
+ init_find_chrome();
1098
+ }
1099
+ });
1100
+
922
1101
  // lib/utils/time-counter.ts
923
1102
  function timeCounter() {
924
1103
  const startTime = /* @__PURE__ */ new Date();
@@ -974,18 +1153,31 @@ import esbuild from "esbuild";
974
1153
  async function buildTestBundle(config, cachedContent) {
975
1154
  const { projectRoot, output } = config;
976
1155
  const allTestFilePaths = Object.keys(config.fsTree);
1156
+ if (allTestFilePaths.length === 0) {
1157
+ console.log("# [buildTestBundle] fsTree is empty \u2014 skipping build (no test files found)");
1158
+ return;
1159
+ }
1160
+ const outfile = `${projectRoot}/${output}/tests.js`;
977
1161
  await Promise.all([
978
- esbuild.build({
979
- stdin: {
980
- contents: allTestFilePaths.map((f) => `import "${f}";`).join(""),
981
- resolveDir: process.cwd()
1162
+ buildWithOverlayfsRetry(
1163
+ {
1164
+ stdin: {
1165
+ contents: allTestFilePaths.map((f) => `import "${f}";`).join(""),
1166
+ resolveDir: process.cwd()
1167
+ },
1168
+ bundle: true,
1169
+ logLevel: "error",
1170
+ outfile,
1171
+ keepNames: true,
1172
+ sourcemap: config.debug ? "inline" : config.watch ? "linked" : false,
1173
+ // Signal the runtime that all test modules are registered. The runtime's maybeStart()
1174
+ // waits for both this event and the WebSocket 'open' event before calling QUnit.start().
1175
+ // Dispatching from the bundle (rather than from a script onload attr) is reliable across
1176
+ // all browsers and does not require changes to user test code.
1177
+ footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
982
1178
  },
983
- bundle: true,
984
- logLevel: "error",
985
- outfile: `${projectRoot}/${output}/tests.js`,
986
- keepNames: true,
987
- sourcemap: config.debug || config.watch ? "inline" : false
988
- }),
1179
+ outfile
1180
+ ),
989
1181
  Promise.all(
990
1182
  cachedContent.htmlPathsToRunTests.map(async (htmlPath) => {
991
1183
  const targetPath = `${config.projectRoot}/${config.output}${htmlPath}`;
@@ -996,7 +1188,7 @@ async function buildTestBundle(config, cachedContent) {
996
1188
  })
997
1189
  )
998
1190
  ]);
999
- cachedContent.allTestCode = await fs8.readFile(`${projectRoot}/${output}/tests.js`);
1191
+ cachedContent.allTestCode = await fs8.readFile(outfile);
1000
1192
  }
1001
1193
  async function runTestsInBrowser(config, cachedContent = {}, connections, targetTestFilesToFilter) {
1002
1194
  const { projectRoot, output } = config;
@@ -1010,6 +1202,9 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1010
1202
  if (!cachedContent.allTestCode) {
1011
1203
  await buildTestBundle(config, cachedContent);
1012
1204
  }
1205
+ if (!cachedContent.allTestCode) {
1206
+ return connections;
1207
+ }
1013
1208
  if (runHasFilter) {
1014
1209
  const outputPath = `${projectRoot}/${output}/filtered-tests.js`;
1015
1210
  await buildFilteredTests(targetTestFilesToFilter, outputPath, config);
@@ -1052,34 +1247,78 @@ async function runTestsInBrowser(config, cachedContent = {}, connections, target
1052
1247
  return connections;
1053
1248
  }
1054
1249
  function buildFilteredTests(filteredTests, outputPath, config) {
1055
- return esbuild.build({
1056
- stdin: {
1057
- contents: filteredTests.map((f) => `import "${f}";`).join(""),
1058
- resolveDir: process.cwd()
1250
+ return buildWithOverlayfsRetry(
1251
+ {
1252
+ stdin: {
1253
+ contents: filteredTests.map((f) => `import "${f}";`).join(""),
1254
+ resolveDir: process.cwd()
1255
+ },
1256
+ bundle: true,
1257
+ logLevel: "error",
1258
+ outfile: outputPath,
1259
+ sourcemap: config.debug ? "inline" : config.watch ? "linked" : false,
1260
+ footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
1059
1261
  },
1060
- bundle: true,
1061
- logLevel: "error",
1062
- outfile: outputPath,
1063
- sourcemap: config.debug || config.watch ? "inline" : false
1064
- });
1262
+ outputPath
1263
+ );
1264
+ }
1265
+ async function buildWithOverlayfsRetry(options, outfile) {
1266
+ const RETRY_DELAY_MS = 100;
1267
+ const MAX_RETRIES = 3;
1268
+ const EMPTY_BUNDLE_THRESHOLD = 500;
1269
+ let result = await esbuild.build(options);
1270
+ for (let retry = 1; retry <= MAX_RETRIES; retry++) {
1271
+ const bytes2 = (await fs8.stat(outfile)).size;
1272
+ if (bytes2 >= EMPTY_BUNDLE_THRESHOLD) return result;
1273
+ console.log(
1274
+ `# [buildWithOverlayfsRetry] bundle is ${bytes2} bytes (< ${EMPTY_BUNDLE_THRESHOLD}) on attempt ${retry}/${MAX_RETRIES} \u2014 overlayfs flush race, retrying in ${RETRY_DELAY_MS}ms`
1275
+ );
1276
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
1277
+ result = await esbuild.build(options);
1278
+ }
1279
+ const bytes = (await fs8.stat(outfile)).size;
1280
+ if (bytes < EMPTY_BUNDLE_THRESHOLD) {
1281
+ console.log(
1282
+ `# [buildWithOverlayfsRetry] bundle is ${bytes} bytes after ${MAX_RETRIES} retries \u2014 proceeding`
1283
+ );
1284
+ }
1285
+ return result;
1065
1286
  }
1066
1287
  async function runTestInsideHTMLFile(filePath, { page, server, browser }, config) {
1067
1288
  let QUNIT_RESULT;
1068
1289
  let targetError;
1069
1290
  let timeoutHandle;
1291
+ let wsConnected = false;
1070
1292
  try {
1071
1293
  console.log("#", blue(`QUnitX running: http://localhost:${config.port}${filePath}`));
1294
+ let resolveTestRace;
1072
1295
  const testRaceResult = new Promise((resolve) => {
1073
- config._testRunDone = () => resolve(false);
1074
- config._resetTestTimeout = () => {
1075
- clearTimeout(timeoutHandle);
1076
- timeoutHandle = setTimeout(() => resolve(true), config.timeout);
1077
- };
1078
- });
1079
- await page.goto(`http://localhost:${config.port}${filePath}`, {
1080
- timeout: config.timeout + 1e4
1296
+ resolveTestRace = resolve;
1081
1297
  });
1082
- config._resetTestTimeout();
1298
+ config._testRunDone = resolveTestRace;
1299
+ config._onWsOpen = () => {
1300
+ wsConnected = true;
1301
+ clearTimeout(timeoutHandle);
1302
+ timeoutHandle = setTimeout(resolveTestRace, config.timeout * 3);
1303
+ };
1304
+ config._onTestsJsServed = () => {
1305
+ clearTimeout(timeoutHandle);
1306
+ timeoutHandle = setTimeout(resolveTestRace, config.timeout * 4);
1307
+ };
1308
+ config._resetTestTimeout = () => {
1309
+ wsConnected = true;
1310
+ clearTimeout(timeoutHandle);
1311
+ timeoutHandle = setTimeout(resolveTestRace, config.timeout);
1312
+ };
1313
+ const targetUrl = `http://localhost:${config.port}${filePath}`;
1314
+ const navOptions = { timeout: config.timeout + 1e4, waitUntil: "commit" };
1315
+ if (page.url().split("?")[0] === targetUrl) {
1316
+ await page.reload(navOptions);
1317
+ } else {
1318
+ await page.goto(targetUrl, navOptions);
1319
+ }
1320
+ clearTimeout(timeoutHandle);
1321
+ timeoutHandle = setTimeout(resolveTestRace, config.timeout * 3);
1083
1322
  await testRaceResult;
1084
1323
  QUNIT_RESULT = await page.evaluate(() => window.QUNIT_RESULT);
1085
1324
  } catch (error) {
@@ -1088,15 +1327,23 @@ async function runTestInsideHTMLFile(filePath, { page, server, browser }, config
1088
1327
  console.error(error);
1089
1328
  } finally {
1090
1329
  clearTimeout(timeoutHandle);
1330
+ config._onWsOpen = null;
1331
+ config._onTestsJsServed = null;
1091
1332
  config._resetTestTimeout = null;
1333
+ config._testRunDone = null;
1092
1334
  }
1093
1335
  if (!QUNIT_RESULT || QUNIT_RESULT.totalTests === 0) {
1094
- console.log(targetError);
1336
+ if (targetError) console.log(targetError);
1337
+ 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";
1338
+ console.log(`# TIMEOUT: ${wsReason}`);
1095
1339
  console.log("BROWSER: runtime error thrown during executing tests");
1096
1340
  console.error("BROWSER: runtime error thrown during executing tests");
1097
1341
  await failOnNonWatchMode(config.watch, { server, browser }, config._groupMode);
1098
1342
  } else if (QUNIT_RESULT.totalTests > QUNIT_RESULT.finishedTests) {
1099
- console.log(targetError);
1343
+ if (targetError) console.log(targetError);
1344
+ console.log(
1345
+ `# TIMEOUT: test stalled after ${QUNIT_RESULT.finishedTests}/${QUNIT_RESULT.totalTests} finished \u2014 last active: ${QUNIT_RESULT.currentTest}`
1346
+ );
1100
1347
  console.log(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
1101
1348
  console.error(`BROWSER: TEST TIMED OUT: ${QUNIT_RESULT.currentTest}`);
1102
1349
  await failOnNonWatchMode(config.watch, { server, browser }, config._groupMode);
@@ -1139,12 +1386,21 @@ import { stat } from "node:fs/promises";
1139
1386
  import path4 from "node:path";
1140
1387
  function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFunc) {
1141
1388
  const extensions = config.extensions || ["js", "ts"];
1389
+ const readyPromises = [];
1390
+ const parentWatchers = [];
1142
1391
  const fileWatchers = testFileLookupPaths.reduce((watchers, watchPath) => {
1143
1392
  let ready = false;
1144
- const watcher = fs9.watch(watchPath, { recursive: true }, async (eventType, filename) => {
1393
+ const lastChangeMs = {};
1394
+ const CHANGE_DEDUPE_MS = 30;
1395
+ const childWatcher = fs9.watch(watchPath, { recursive: true }, async (eventType, filename) => {
1145
1396
  if (!ready || !filename) return;
1146
1397
  const fullPath = path4.join(watchPath, filename);
1147
1398
  if (eventType === "change") {
1399
+ if (!config._building) {
1400
+ const now = Date.now();
1401
+ if (now - (lastChangeMs[fullPath] ?? 0) < CHANGE_DEDUPE_MS) return;
1402
+ lastChangeMs[fullPath] = now;
1403
+ }
1148
1404
  return handleWatchEvent(config, extensions, "change", fullPath, onEventFunc, onFinishFunc);
1149
1405
  }
1150
1406
  try {
@@ -1158,19 +1414,55 @@ function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFun
1158
1414
  onFinishFunc
1159
1415
  );
1160
1416
  } catch {
1161
- const event = config.fsTree && fullPath in config.fsTree ? "unlink" : "unlinkDir";
1162
- handleWatchEvent(config, extensions, event, fullPath, onEventFunc, onFinishFunc);
1417
+ await new Promise((resolve) => setTimeout(resolve, 50));
1418
+ try {
1419
+ const s = await stat(fullPath);
1420
+ handleWatchEvent(
1421
+ config,
1422
+ extensions,
1423
+ s.isDirectory() ? "addDir" : "add",
1424
+ fullPath,
1425
+ onEventFunc,
1426
+ onFinishFunc
1427
+ );
1428
+ return;
1429
+ } catch {
1430
+ }
1431
+ if (!(config.fsTree && fullPath in config.fsTree)) return;
1432
+ handleWatchEvent(config, extensions, "unlink", fullPath, onEventFunc, onFinishFunc);
1163
1433
  }
1164
1434
  });
1165
- setImmediate(() => {
1166
- ready = true;
1435
+ const parentDir = path4.dirname(watchPath);
1436
+ const watchedBasename = path4.basename(watchPath);
1437
+ const parentWatcher = fs9.watch(parentDir, async (eventType, filename) => {
1438
+ if (!ready || filename !== watchedBasename || eventType !== "rename") return;
1439
+ try {
1440
+ await stat(watchPath);
1441
+ } catch {
1442
+ handleWatchEvent(config, extensions, "unlinkDir", watchPath, onEventFunc, onFinishFunc);
1443
+ childWatcher.close();
1444
+ parentWatcher.close();
1445
+ delete watchers[watchPath];
1446
+ }
1167
1447
  });
1168
- return Object.assign(watchers, { [watchPath]: watcher });
1448
+ parentWatchers.push(parentWatcher);
1449
+ readyPromises.push(
1450
+ new Promise(
1451
+ (resolve) => setImmediate(() => {
1452
+ ready = true;
1453
+ resolve();
1454
+ })
1455
+ )
1456
+ );
1457
+ return Object.assign(watchers, { [watchPath]: childWatcher });
1169
1458
  }, {});
1170
1459
  return {
1171
1460
  fileWatchers,
1461
+ ready: Promise.all(readyPromises).then(() => {
1462
+ }),
1172
1463
  killFileWatchers() {
1173
- Object.keys(fileWatchers).forEach((watcherKey) => fileWatchers[watcherKey].close());
1464
+ Object.keys(fileWatchers).forEach((key) => fileWatchers[key].close());
1465
+ parentWatchers.forEach((pw) => pw.close());
1174
1466
  return fileWatchers;
1175
1467
  }
1176
1468
  };
@@ -1199,7 +1491,16 @@ function handleWatchEvent(config, extensions, event, filePath, onEventFunc, onFi
1199
1491
  onFinishFunc ? onFinishFunc(event, filePath) : null;
1200
1492
  }).catch((error) => {
1201
1493
  console.error("#", red("Build error:"), error.message || error);
1202
- }).finally(() => config._building = false);
1494
+ }).finally(() => {
1495
+ config._building = false;
1496
+ if (config._pendingBuildTrigger) {
1497
+ const trigger = config._pendingBuildTrigger;
1498
+ config._pendingBuildTrigger = null;
1499
+ trigger();
1500
+ }
1501
+ });
1502
+ } else {
1503
+ config._pendingBuildTrigger = () => handleWatchEvent(config, extensions, event, filePath, onEventFunc, onFinishFunc);
1203
1504
  }
1204
1505
  }
1205
1506
  function mutateFSTree(fsTree, event, path5) {
@@ -1346,6 +1647,9 @@ async function run(config) {
1346
1647
  ]);
1347
1648
  config.expressApp = connections.server;
1348
1649
  setupKeyboardEvents(config, cachedContent, connections);
1650
+ if (config.open) {
1651
+ void openOutputInBrowser(config);
1652
+ }
1349
1653
  if (config.before) {
1350
1654
  await runUserModule(`${process.cwd()}/${config.before}`, config, "before");
1351
1655
  }
@@ -1358,19 +1662,24 @@ async function run(config) {
1358
1662
  ]);
1359
1663
  throw error;
1360
1664
  }
1665
+ if (config.watch) {
1666
+ const { ready: watcherReady } = setupFileWatchers(
1667
+ config.testFileLookupPaths,
1668
+ config,
1669
+ async (event, file) => {
1670
+ if (event === "addDir") return;
1671
+ if (["change", "unlink", "unlinkDir"].includes(event)) {
1672
+ if (event === "change" && !(file in config.fsTree)) return;
1673
+ cachedContent.allTestCode = null;
1674
+ return await runTestsInBrowser(config, cachedContent, connections);
1675
+ }
1676
+ await runTestsInBrowser(config, cachedContent, connections, [file]);
1677
+ },
1678
+ (_path, _event) => connections.server.publish("refresh", "refresh")
1679
+ );
1680
+ await watcherReady;
1681
+ }
1361
1682
  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
1683
  } else {
1375
1684
  const allFiles = Object.keys(config.fsTree);
1376
1685
  const groupCount = Math.min(allFiles.length, availableParallelism());
@@ -1397,6 +1706,9 @@ async function run(config) {
1397
1706
  )
1398
1707
  )
1399
1708
  ]);
1709
+ if (config.open) {
1710
+ void openOutputInBrowser(config);
1711
+ }
1400
1712
  const TIME_COUNTER = timeCounter();
1401
1713
  const GROUP_TIMEOUT_MS = 3 * 60 * 1e3;
1402
1714
  const keepAlive = setInterval(() => {
@@ -1473,14 +1785,14 @@ async function buildCachedContent(config, htmlPaths) {
1473
1785
  if (buffer === null) return result;
1474
1786
  const filePath = config.htmlPaths[index];
1475
1787
  const html = buffer.toString();
1476
- if (html.includes("{{content}}")) {
1788
+ if (htmlHasDynamicContentMarker(html)) {
1477
1789
  result.dynamicContentHTMLs[filePath] = html;
1478
1790
  result.htmlPathsToRunTests.push(filePath.replace(config.projectRoot, ""));
1479
1791
  } else {
1480
1792
  console.log(
1481
1793
  "#",
1482
1794
  yellow(
1483
- `WARNING: Static html file with no {{content}} detected. Therefore ignoring ${filePath}`
1795
+ `WARNING: Static html file with no {{qunitxScript}} or handlebars-style tokens detected. Therefore ignoring ${filePath}`
1484
1796
  )
1485
1797
  );
1486
1798
  result.staticHTMLs[filePath] = html;
@@ -1524,9 +1836,10 @@ function splitIntoGroups(files, groupCount) {
1524
1836
  return groups.filter((g) => g.length > 0);
1525
1837
  }
1526
1838
  function logWatcherAndKeyboardShortcutInfo(config, _server) {
1839
+ const prefix = "Watching files...";
1527
1840
  console.log(
1528
1841
  "#",
1529
- blue(`Watching files... You can browse the tests on http://localhost:${config.port} ...`)
1842
+ blue(`${prefix} You can browse the tests on http://localhost:${config.port} ...`)
1530
1843
  );
1531
1844
  console.log(
1532
1845
  "#",
@@ -1542,6 +1855,7 @@ function normalizeInternalAssetPathFromHTML(projectRoot, assetPath, htmlPath) {
1542
1855
  var init_run = __esm({
1543
1856
  "lib/commands/run.ts"() {
1544
1857
  init_browser();
1858
+ init_open_output_in_browser();
1545
1859
  init_color();
1546
1860
  init_tests_in_browser();
1547
1861
  init_file_watcher();
@@ -1552,6 +1866,7 @@ var init_run = __esm({
1552
1866
  init_time_counter();
1553
1867
  init_display_final_result();
1554
1868
  init_read_boilerplate();
1869
+ init_html_content_marker();
1555
1870
  }
1556
1871
  });
1557
1872
 
@@ -1566,7 +1881,7 @@ init_color();
1566
1881
  var package_default = {
1567
1882
  name: "qunitx-cli",
1568
1883
  type: "module",
1569
- version: "0.9.10",
1884
+ version: "0.11.0",
1570
1885
  description: "Browser runner for QUnitx: run your qunitx tests in google-chrome",
1571
1886
  author: "Izel Nakri",
1572
1887
  license: "MIT",
@@ -1595,9 +1910,10 @@ var package_default = {
1595
1910
  "changelog:unreleased": "git-cliff --unreleased --strip all",
1596
1911
  "changelog:preview": "git-cliff",
1597
1912
  "changelog:update": "git-cliff --output CHANGELOG.md",
1598
- postinstall: "deno install --allow-scripts=npm:playwright-core || true",
1913
+ postinstall: "PLAYWRIGHT_SKIP_DOWNLOAD=true deno install --allow-scripts=npm:playwright-core || true",
1599
1914
  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
1915
  "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`,
1916
+ "test:release": "bash scripts/test-release.sh",
1601
1917
  "test:sanity-first": "./cli.ts test/helpers/failing-tests.js test/helpers/failing-tests.ts",
1602
1918
  "test:sanity-second": "./cli.ts test/helpers/passing-tests.js test/helpers/passing-tests.ts"
1603
1919
  },
@@ -1613,16 +1929,16 @@ var package_default = {
1613
1929
  url: "git+https://github.com/izelnakri/qunitx-cli.git"
1614
1930
  },
1615
1931
  dependencies: {
1616
- esbuild: "^0.27.3",
1617
- "playwright-core": "^1.58.2",
1932
+ esbuild: "^0.28.0",
1933
+ "playwright-core": "^1.59.1",
1618
1934
  ws: "^8.20.0"
1619
1935
  },
1620
1936
  devDependencies: {
1621
1937
  cors: "^2.8.6",
1622
1938
  express: "^5.2.1",
1623
1939
  "js-yaml": "^4.1.1",
1624
- prettier: "^3.8.1",
1625
- qunitx: "^1.1.2",
1940
+ prettier: "^3.8.2",
1941
+ qunitx: "^1.2.1",
1626
1942
  typescript: "^6.0.2"
1627
1943
  },
1628
1944
  volta: {
@@ -1654,6 +1970,7 @@ ${highlight("Input options:")}
1654
1970
  ${highlight("Optional flags:")}
1655
1971
  ${color("--debug")} : print console output when tests run in browser
1656
1972
  ${color("--watch")} : run the target file or folders, watch them for continuous run and expose http server under localhost
1973
+ ${color("--open")} : run tests in a visible browser window instead of headless; keeps the server alive (short: ${color("-o")})
1657
1974
  ${color("--timeout")} : change default timeout per test case
1658
1975
  ${color("--output")} : folder to distribute built qunitx html and js that a webservers can run[default: tmp]
1659
1976
  ${color("--failFast")} : run the target file or folders with immediate abort if a single test fails
@@ -1902,6 +2219,10 @@ function parseCliFlags(projectRoot) {
1902
2219
  return Object.assign(result, { debug: parseBoolean(arg.split("=")[1]) });
1903
2220
  } else if (arg.startsWith("--watch")) {
1904
2221
  return Object.assign(result, { watch: parseBoolean(arg.split("=")[1]) });
2222
+ } else if (arg === "-o" || arg.startsWith("-o=") || arg.startsWith("--open")) {
2223
+ const value = arg.split("=")[1];
2224
+ const open = value === void 0 || value === "true" ? true : value === "false" ? false : value;
2225
+ return Object.assign(result, { open });
1905
2226
  } else if (arg.startsWith("--failfast") || arg.startsWith("--failFast")) {
1906
2227
  return Object.assign(result, { failFast: parseBoolean(arg.split("=")[1]) });
1907
2228
  } else if (arg.startsWith("--timeout")) {
@@ -1916,7 +2237,7 @@ function parseCliFlags(projectRoot) {
1916
2237
  }
1917
2238
  return result;
1918
2239
  } else if (arg.startsWith("--port")) {
1919
- return Object.assign(result, { port: Number(arg.split("=")[1]) });
2240
+ return Object.assign(result, { port: Number(arg.split("=")[1]), portExplicit: true });
1920
2241
  } else if (arg.startsWith("--extensions")) {
1921
2242
  return Object.assign(result, {
1922
2243
  extensions: arg.split("=")[1].split(",").map((e) => e.trim())
@@ -1937,6 +2258,10 @@ function parseCliFlags(projectRoot) {
1937
2258
  } else if (arg === "--trace-perf") {
1938
2259
  return result;
1939
2260
  }
2261
+ if (arg.startsWith("-")) {
2262
+ console.warn(`# Warning: Unknown flag "${arg}" \u2014 ignored`);
2263
+ return result;
2264
+ }
1940
2265
  result.inputs.add(arg.startsWith(projectRoot) ? arg : `${process.cwd()}/${arg}`);
1941
2266
  return result;
1942
2267
  },
@@ -1977,7 +2302,9 @@ async function setupConfig() {
1977
2302
  lastRanTestFiles: null,
1978
2303
  COUNTER: { testCount: 0, failCount: 0, skipCount: 0, passCount: 0, errorCount: 0 },
1979
2304
  _testRunDone: null,
1980
- _resetTestTimeout: null
2305
+ _resetTestTimeout: null,
2306
+ _onWsOpen: null,
2307
+ _onTestsJsServed: null
1981
2308
  };
1982
2309
  config.htmlPaths = normalizeHTMLPaths(config.projectRoot, config.htmlPaths);
1983
2310
  config.fsTree = await buildFSTree(config.testFileLookupPaths, config);
@@ -2000,6 +2327,8 @@ process4.title = "qunitx";
2000
2327
  (async () => {
2001
2328
  if (!process4.argv[2]) {
2002
2329
  return await displayHelpOutput();
2330
+ } else if (["--version", "-v", "version"].includes(process4.argv[2])) {
2331
+ return process4.stdout.write(package_default.version + "\n");
2003
2332
  } else if (["help", "h", "p", "print"].includes(process4.argv[2])) {
2004
2333
  return await displayHelpOutput();
2005
2334
  } 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.11.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,9 +30,10 @@
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
+ "test:release": "bash scripts/test-release.sh",
36
37
  "test:sanity-first": "./cli.ts test/helpers/failing-tests.js test/helpers/failing-tests.ts",
37
38
  "test:sanity-second": "./cli.ts test/helpers/passing-tests.js test/helpers/passing-tests.ts"
38
39
  },
@@ -48,16 +49,16 @@
48
49
  "url": "git+https://github.com/izelnakri/qunitx-cli.git"
49
50
  },
50
51
  "dependencies": {
51
- "esbuild": "^0.27.3",
52
- "playwright-core": "^1.58.2",
52
+ "esbuild": "^0.28.0",
53
+ "playwright-core": "^1.59.1",
53
54
  "ws": "^8.20.0"
54
55
  },
55
56
  "devDependencies": {
56
57
  "cors": "^2.8.6",
57
58
  "express": "^5.2.1",
58
59
  "js-yaml": "^4.1.1",
59
- "prettier": "^3.8.1",
60
- "qunitx": "^1.1.2",
60
+ "prettier": "^3.8.2",
61
+ "qunitx": "^1.2.1",
61
62
  "typescript": "^6.0.2"
62
63
  },
63
64
  "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>