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 +36 -9
- package/dist/cli.js +424 -95
- package/package.json +7 -6
- package/templates/setup/tests.hbs +1 -1
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
|
|
167
|
-
|
|
168
|
-
| `inputs`
|
|
169
|
-
| `
|
|
170
|
-
| `
|
|
171
|
-
| `
|
|
172
|
-
| `
|
|
173
|
-
| `
|
|
174
|
-
| `
|
|
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",
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 === "
|
|
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
|
-
|
|
638
|
-
cachedContent.mainHTML.html,
|
|
639
|
-
cachedContent.mainHTML.filePath,
|
|
640
|
-
config.projectRoot
|
|
641
|
-
),
|
|
738
|
+
mainHTMLWithReplacedAssets,
|
|
642
739
|
TEST_RUNTIME_TO_INJECT,
|
|
643
|
-
|
|
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
|
-
|
|
656
|
-
cachedContent.mainHTML.html,
|
|
657
|
-
cachedContent.mainHTML.filePath,
|
|
658
|
-
config.projectRoot
|
|
659
|
-
),
|
|
753
|
+
mainHTMLWithReplacedAssets,
|
|
660
754
|
TEST_RUNTIME_TO_INJECT,
|
|
661
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
833
|
-
return
|
|
834
|
-
|
|
835
|
-
testRuntimeCode
|
|
836
|
-
|
|
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 = {
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
-
|
|
984
|
-
|
|
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(
|
|
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
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
1162
|
-
|
|
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
|
-
|
|
1166
|
-
|
|
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
|
-
|
|
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((
|
|
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(() =>
|
|
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
|
|
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 {{
|
|
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(
|
|
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.
|
|
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.
|
|
1617
|
-
"playwright-core": "^1.
|
|
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.
|
|
1625
|
-
qunitx: "^1.1
|
|
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.
|
|
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.
|
|
52
|
-
"playwright-core": "^1.
|
|
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.
|
|
60
|
-
"qunitx": "^1.1
|
|
60
|
+
"prettier": "^3.8.2",
|
|
61
|
+
"qunitx": "^1.2.1",
|
|
61
62
|
"typescript": "^6.0.2"
|
|
62
63
|
},
|
|
63
64
|
"volta": {
|