qunitx-cli 0.21.3 → 0.22.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.
Files changed (3) hide show
  1. package/README.md +80 -15
  2. package/dist/cli.js +78 -25
  3. package/package.json +6 -3
package/README.md CHANGED
@@ -14,8 +14,9 @@ output to the terminal.
14
14
 
15
15
  ## Features
16
16
 
17
- - Runs `.js` and `.ts` test files in headless Chrome, Firefox, or WebKit (Playwright + esbuild)
18
- - TypeScript works with zero configuration — esbuild handles transpilation
17
+ - Runs `.js`, `.ts`, `.jsx`, and `.tsx` test files in headless Chrome, Firefox, or WebKit (Playwright + esbuild)
18
+ - TypeScript and JSX work with zero configuration — esbuild handles transpilation, including the React 17+ automatic JSX runtime
19
+ - Bring your own esbuild plugins through `package.json` for `.vue`, `.svelte`, and other custom loaders
19
20
  - Inline source maps for accurate stack traces pointing to original source files
20
21
  - Streams TAP-formatted output to the terminal in real time
21
22
  - Concurrent mode (default) splits test files across all CPU cores for fast parallel runs
@@ -165,29 +166,93 @@ All CLI flags can also be set in `package.json` under the `qunitx` key, so you d
165
166
  "qunitx": {
166
167
  "inputs": ["test/**/*-test.js", "test/**/*-test.ts"],
167
168
  "htmlPaths": ["test/tests.html"],
168
- "extensions": ["js", "ts"],
169
+ "extensions": ["js", "ts", "jsx", "tsx"],
169
170
  "output": "tmp",
170
171
  "timeout": 20000,
171
172
  "failFast": false,
172
173
  "port": 1234,
173
- "browser": "chromium"
174
+ "browser": "chromium",
175
+ "plugins": []
174
176
  }
175
177
  }
176
178
  ```
177
179
 
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. |
180
+ | Key | Default | Description |
181
+ | ------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
182
+ | `inputs` | `[]` | Glob patterns, file paths, or directories to use as test entry points. Merged with any paths given on the CLI. |
183
+ | `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. |
184
+ | `extensions` | `["js", "ts", "jsx", "tsx"]` | File extensions tracked for test discovery (directory scans) and watch-mode rebuild triggers. Add `"mjs"`, `"cjs"`, or any other extension your project uses. |
185
+ | `output` | `"tmp"` | Directory where compiled test bundles are written. |
186
+ | `timeout` | `20000` | Maximum milliseconds to wait for the full test suite before timing out. |
187
+ | `failFast` | `false` | Stop the run after the first failing test. |
188
+ | `port` | `1234` | Preferred HTTP server port. qunitx auto-selects a free port if this one is taken. |
189
+ | `browser` | `"chromium"` | Browser engine to use: `"chromium"`, `"firefox"`, or `"webkit"`. Overridden by `--browser` on the CLI. |
190
+ | `plugins` | `[]` | esbuild plugin specifiers loaded from your `node_modules` and applied to the test bundle. See [esbuild plugins](#esbuild-plugins). |
188
191
 
189
192
  CLI flags always override `package.json` values when both are present.
190
193
 
194
+ ## JSX / TSX
195
+
196
+ `.jsx` and `.tsx` files are picked up automatically — no configuration needed. The bundle uses esbuild's automatic JSX runtime so React 17+ "no `import React`" code just works:
197
+
198
+ ```tsx
199
+ // test/button-test.tsx
200
+ import { module, test } from 'qunitx';
201
+ import { flushSync } from 'react-dom';
202
+ import { createRoot } from 'react-dom/client';
203
+ import { Button } from '../src/button.tsx';
204
+
205
+ module('Button', (hooks) => {
206
+ let container;
207
+ hooks.beforeEach(() => {
208
+ container = document.createElement('div');
209
+ document.body.appendChild(container);
210
+ });
211
+ hooks.afterEach(() => container.remove());
212
+
213
+ test('renders the label', (assert) => {
214
+ flushSync(() => createRoot(container).render(<Button label="Save" />));
215
+ assert.equal(container.querySelector('button').textContent, 'Save');
216
+ });
217
+ });
218
+ ```
219
+
220
+ Vue, Preact, Solid, and other JSX dialects work via a one-line override at the top of each file:
221
+
222
+ ```tsx
223
+ /** @jsxImportSource vue */
224
+ import { createApp } from 'vue';
225
+ // ...JSX uses vue/jsx-runtime instead of react/jsx-runtime
226
+ ```
227
+
228
+ You can also set `compilerOptions.jsxImportSource` in your `tsconfig.json` to apply the override across a directory.
229
+
230
+ ## esbuild plugins
231
+
232
+ For file formats esbuild does not handle natively (e.g. `.vue` SFCs, `.svelte`), declare plugin specifiers in `package.json#qunitx.plugins`. qunitx dynamic-imports each one from your project's `node_modules` and passes it to the build:
233
+
234
+ ```json
235
+ {
236
+ "qunitx": {
237
+ "extensions": ["js", "ts", "jsx", "tsx", "vue"],
238
+ "plugins": [
239
+ "esbuild-plugin-vue-next",
240
+ ["esbuild-svelte", { "compilerOptions": { "css": "injected" } }]
241
+ ]
242
+ }
243
+ }
244
+ ```
245
+
246
+ Each entry is one of:
247
+
248
+ | Form | Behavior |
249
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
250
+ | `"<package-name>"` | Imports the package. If the default export is a function, it's called with no arguments to produce the plugin; otherwise the export is used as the plugin. |
251
+ | `["<package-name>", <options>]` | Same, but the factory is called with `<options>` as its only argument. Use this form to pass plugin-specific configuration. |
252
+ | `"./relative/plugin.js"` | Loads a plugin you wrote yourself. Resolved against the project root (where your `package.json` lives). |
253
+
254
+ Don't forget to add the plugin's file extension(s) to `qunitx.extensions` so directory scans and watch-mode rebuilds pick them up.
255
+
191
256
  ### Environment variables
192
257
 
193
258
  | Variable | Description |
@@ -218,7 +283,7 @@ Options:
218
283
  --debug Print the server URL; pipe browser console to stdout
219
284
  --timeout=<ms> Max ms to wait for the suite to finish [default: 20000]
220
285
  --output=<dir> Directory for compiled test assets [default: ./tmp]
221
- --extensions=<...> Comma-separated file extensions to track [default: js,ts]
286
+ --extensions=<...> Comma-separated file extensions to track [default: js,ts,jsx,tsx]
222
287
  --before=<file> Script to run (and optionally await) before tests start
223
288
  --after=<file> Script to run (and optionally await) after tests finish
224
289
  --open, -o Open output in the test browser as soon as the bundle is ready
package/dist/cli.js CHANGED
@@ -377,6 +377,21 @@ var init_color = __esm({
377
377
  }
378
378
  });
379
379
 
380
+ // lib/setup/default-project-config-values.ts
381
+ var defaultProjectConfigValues;
382
+ var init_default_project_config_values = __esm({
383
+ "lib/setup/default-project-config-values.ts"() {
384
+ defaultProjectConfigValues = {
385
+ output: "tmp",
386
+ timeout: 2e4,
387
+ failFast: false,
388
+ port: 1234,
389
+ extensions: ["js", "ts", "jsx", "tsx"],
390
+ browser: "chromium"
391
+ };
392
+ }
393
+ });
394
+
380
395
  // lib/utils/read-template.ts
381
396
  import fs3 from "node:fs/promises";
382
397
  import { dirname, join as join2 } from "node:path";
@@ -1987,10 +2002,10 @@ var init_browser = __esm({
1987
2002
  // lib/utils/open-output-in-browser.ts
1988
2003
  import { spawn as spawn2 } from "node:child_process";
1989
2004
  import path6 from "node:path";
1990
- import { pathToFileURL } from "node:url";
2005
+ import { pathToFileURL as pathToFileURL2 } from "node:url";
1991
2006
  async function openOutputInBrowser(config) {
1992
2007
  try {
1993
- const outputFile = config.watch ? `http://localhost:${config.port}` : pathToFileURL(path6.join(path6.resolve(config.projectRoot, config.output), "index.html")).href;
2008
+ const outputFile = config.watch ? `http://localhost:${config.port}` : pathToFileURL2(path6.join(path6.resolve(config.projectRoot, config.output), "index.html")).href;
1994
2009
  if (typeof config.open === "string") {
1995
2010
  spawnDetached(config.open, [outputFile]);
1996
2011
  return;
@@ -2036,10 +2051,10 @@ var init_time_counter = __esm({
2036
2051
  });
2037
2052
 
2038
2053
  // lib/utils/run-user-module.ts
2039
- import { pathToFileURL as pathToFileURL2 } from "node:url";
2054
+ import { pathToFileURL as pathToFileURL3 } from "node:url";
2040
2055
  async function runUserModule(modulePath, params, scriptPosition) {
2041
2056
  try {
2042
- const func = await import(pathToFileURL2(modulePath).href);
2057
+ const func = await import(pathToFileURL3(modulePath).href);
2043
2058
  if (func) {
2044
2059
  func.default ? await func.default(params) : typeof func === "function" ? await func(params) : null;
2045
2060
  }
@@ -2140,6 +2155,11 @@ async function buildTestBundle(config, cachedContent) {
2140
2155
  legalComments: "none",
2141
2156
  target: esbuildTarget(config.browser),
2142
2157
  sourcemap,
2158
+ // jsx: 'automatic' is a no-op for .ts/.js files (extension-gated by esbuild) and emits
2159
+ // `import { jsx } from 'react/jsx-runtime'` for .tsx/.jsx files. Per-file overrides via
2160
+ // tsconfig's `jsxImportSource` or a `@jsxImportSource <pkg>` pragma cover Vue/Preact/Solid.
2161
+ jsx: "automatic",
2162
+ plugins: config.plugins,
2143
2163
  // Signal the runtime that all test modules are registered. The runtime's maybeStart()
2144
2164
  // waits for both this event and the WebSocket 'open' event before calling QUnit.start().
2145
2165
  // Dispatching from the bundle (rather than from a script onload attr) is reliable across
@@ -2345,7 +2365,10 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
2345
2365
  in: `group-entry-${slotIndex}`,
2346
2366
  out: `group-${slotIndex}`
2347
2367
  })),
2348
- plugins: [groupEntryPlugin],
2368
+ // groupEntryPlugin must run first — it owns the virtual entry-point modules every other
2369
+ // plugin sees. User plugins follow and apply to the resolved test files just like in
2370
+ // single-group mode (`buildTestBundle`).
2371
+ plugins: [groupEntryPlugin, ...groupConfigs[0].plugins ?? []],
2349
2372
  nodePaths: ANCESTOR_NODE_MODULES,
2350
2373
  bundle: true,
2351
2374
  logLevel: "silent",
@@ -2357,6 +2380,7 @@ async function buildAllGroupBundles(groupConfigs, groupCachedContents) {
2357
2380
  target: esbuildTarget(browser),
2358
2381
  sourcemap,
2359
2382
  write: false,
2383
+ jsx: "automatic",
2360
2384
  footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
2361
2385
  };
2362
2386
  const hasSmallOutput = (result) => (result.outputFiles ?? []).some(
@@ -2425,6 +2449,8 @@ function buildFilteredTests(filteredTests, outputPath, config) {
2425
2449
  legalComments: "none",
2426
2450
  target: esbuildTarget(config.browser),
2427
2451
  sourcemap,
2452
+ jsx: "automatic",
2453
+ plugins: config.plugins,
2428
2454
  footer: { js: 'window.dispatchEvent(new CustomEvent("qunitx:tests-ready"));' }
2429
2455
  },
2430
2456
  needsDisk
@@ -2620,7 +2646,7 @@ import fs10 from "node:fs";
2620
2646
  import { readdir, stat, lstat } from "node:fs/promises";
2621
2647
  import path8 from "node:path";
2622
2648
  function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFunc) {
2623
- const extensions = config.extensions || ["js", "ts"];
2649
+ const extensions = config.extensions || defaultProjectConfigValues.extensions;
2624
2650
  const readyPromises = [];
2625
2651
  const parentWatchers = [];
2626
2652
  const rescanTimers = [];
@@ -2884,6 +2910,7 @@ var CHANGE_DEDUPE_MS, SYMLINK_POLL_INTERVAL_MS, OVERLAYFS_RENAME_RETRY_MS, RESCA
2884
2910
  var init_file_watcher = __esm({
2885
2911
  "lib/setup/file-watcher.ts"() {
2886
2912
  init_color();
2913
+ init_default_project_config_values();
2887
2914
  CHANGE_DEDUPE_MS = 10;
2888
2915
  SYMLINK_POLL_INTERVAL_MS = 500;
2889
2916
  OVERLAYFS_RENAME_RETRY_MS = 50;
@@ -3004,7 +3031,8 @@ __export(run_exports, {
3004
3031
  run: () => run
3005
3032
  });
3006
3033
  import fs12 from "node:fs/promises";
3007
- import { normalize } from "node:path";
3034
+ import { join as join3, normalize } from "node:path";
3035
+ import { createRequire as createRequire2 } from "node:module";
3008
3036
  import { availableParallelism } from "node:os";
3009
3037
  async function run(config) {
3010
3038
  const browserPromise = config.watch ? null : launchBrowser(config);
@@ -3296,7 +3324,7 @@ async function addCachedContentMainHTML(projectRoot, cachedContent) {
3296
3324
  } else {
3297
3325
  const html = await readTemplate("setup/tests.hbs");
3298
3326
  cachedContent.mainHTML = { filePath: `${projectRoot}/test/tests.html`, html };
3299
- cachedContent.assets.add(`${projectRoot}/node_modules/qunitx/vendor/qunit.css`);
3327
+ cachedContent.assets.add(join3(resolveQunitxRoot(projectRoot), "vendor/qunit.css"));
3300
3328
  }
3301
3329
  return cachedContent;
3302
3330
  }
@@ -3369,6 +3397,12 @@ function normalizeInternalAssetPathFromHTML(projectRoot, assetPath, htmlPath) {
3369
3397
  const currentDirectory = htmlPath ? htmlPath.split("/").slice(0, -1).join("/") : projectRoot;
3370
3398
  return assetPath.startsWith("./") ? normalize(`${currentDirectory}/${assetPath.slice(2)}`) : normalize(`${currentDirectory}/${assetPath}`);
3371
3399
  }
3400
+ function resolveQunitxRoot(projectRoot) {
3401
+ const mainEntry = createRequire2(`${projectRoot}/package.json`).resolve("qunitx");
3402
+ const match = /^(.*[\\/]qunitx)[\\/]/.exec(mainEntry);
3403
+ if (!match) throw new Error(`Could not derive qunitx root from ${mainEntry}`);
3404
+ return match[1];
3405
+ }
3372
3406
  var WATCH_NAV_TIMEOUT_MS, PAGE_CLOSE_GRACE_MS, STDOUT_FLUSH_GRACE_MS, KEEP_ALIVE_INTERVAL_MS, EXIT_CODE_SIGTERM;
3373
3407
  var init_run = __esm({
3374
3408
  "lib/commands/run.ts"() {
@@ -3408,7 +3442,7 @@ init_color();
3408
3442
  var package_default = {
3409
3443
  name: "qunitx-cli",
3410
3444
  type: "module",
3411
- version: "0.21.3",
3445
+ version: "0.22.0",
3412
3446
  description: "Browser runner for QUnitx: run your qunitx tests in google-chrome",
3413
3447
  author: "Izel Nakri",
3414
3448
  license: "MIT",
@@ -3465,8 +3499,11 @@ var package_default = {
3465
3499
  devDependencies: {
3466
3500
  "js-yaml": "^4.1.1",
3467
3501
  prettier: "^3.8.3",
3468
- qunitx: "^1.2.8",
3469
- typescript: "^6.0.3"
3502
+ qunitx: "^1.2.9",
3503
+ react: "^19.2.5",
3504
+ "react-dom": "^19.2.5",
3505
+ typescript: "^6.0.3",
3506
+ vue: "^3.5.33"
3470
3507
  },
3471
3508
  volta: {
3472
3509
  node: "24.14.0"
@@ -3502,7 +3539,7 @@ ${color("--timeout")} : change default timeout per test case
3502
3539
  ${color("--output")} : folder to distribute built qunitx html and js that a webservers can run[default: tmp]
3503
3540
  ${color("--failFast")} : run the target file or folders with immediate abort if a single test fails
3504
3541
  ${color("--port")} : HTTP server port (auto-selects a free port if the given port is taken)[default: 1234]
3505
- ${color("--extensions")} : comma-separated file extensions to track for discovery and watch-mode rebuilds[default: js,ts]
3542
+ ${color("--extensions")} : comma-separated file extensions to track for discovery and watch-mode rebuilds[default: js,ts,jsx,tsx]
3506
3543
  ${color("--browser")} : browser engine to run tests in: chromium, firefox, webkit[default: chromium]
3507
3544
  ${color("--before")} : run a script before the tests(i.e start a new web server before tests)
3508
3545
  ${color("--after")} : run a script after the tests(i.e save test results to a file)
@@ -3561,17 +3598,8 @@ async function findProjectRoot() {
3561
3598
  }
3562
3599
  }
3563
3600
 
3564
- // lib/setup/default-project-config-values.ts
3565
- var defaultProjectConfigValues = {
3566
- output: "tmp",
3567
- timeout: 2e4,
3568
- failFast: false,
3569
- port: 1234,
3570
- extensions: ["js", "ts"],
3571
- browser: "chromium"
3572
- };
3573
-
3574
3601
  // lib/commands/init.ts
3602
+ init_default_project_config_values();
3575
3603
  init_read_template();
3576
3604
  async function initializeProject() {
3577
3605
  const projectRoot = await findProjectRoot();
@@ -3658,13 +3686,17 @@ function pathToModuleName(filePath) {
3658
3686
  }
3659
3687
 
3660
3688
  // lib/setup/config.ts
3689
+ init_default_project_config_values();
3661
3690
  import fs7 from "node:fs/promises";
3691
+ import { createRequire } from "node:module";
3692
+ import { pathToFileURL } from "node:url";
3662
3693
 
3663
3694
  // lib/setup/fs-tree.ts
3695
+ init_default_project_config_values();
3664
3696
  import fs6, { glob as fsGlob } from "node:fs/promises";
3665
3697
  import path3 from "node:path";
3666
3698
  async function buildFSTree(fileAbsolutePaths, config = {}) {
3667
- const targetExtensions = config.extensions || ["js", "ts"];
3699
+ const targetExtensions = config.extensions || defaultProjectConfigValues.extensions;
3668
3700
  const fsTree = {};
3669
3701
  await Promise.all(
3670
3702
  fileAbsolutePaths.map(async (fileAbsolutePath) => {
@@ -3849,11 +3881,13 @@ async function setupConfig() {
3849
3881
  const projectRoot = await findProjectRoot();
3850
3882
  const cliConfigFlags = parseCliFlags(projectRoot);
3851
3883
  const projectPackageJSON = await readConfigFromPackageJSON(projectRoot);
3884
+ const { plugins: rawPlugins, ...userQunitx } = projectPackageJSON.qunitx ?? {};
3885
+ const pluginsPromise = resolvePlugins(rawPlugins, projectRoot);
3852
3886
  const inputs2 = cliConfigFlags.inputs.concat(readInputsFromPackageJSON(projectPackageJSON));
3853
3887
  const config = {
3854
3888
  ...defaultProjectConfigValues,
3855
3889
  htmlPaths: [],
3856
- ...projectPackageJSON.qunitx || {},
3890
+ ...userQunitx,
3857
3891
  ...cliConfigFlags,
3858
3892
  projectRoot,
3859
3893
  inputs: inputs2,
@@ -3874,7 +3908,10 @@ async function setupConfig() {
3874
3908
  _onTestsJsServed: null
3875
3909
  };
3876
3910
  config.htmlPaths = normalizeHTMLPaths(config.projectRoot, config.htmlPaths);
3877
- config.fsTree = await buildFSTree(config.testFileLookupPaths, config);
3911
+ [config.fsTree, config.plugins] = await Promise.all([
3912
+ buildFSTree(config.testFileLookupPaths, config),
3913
+ pluginsPromise
3914
+ ]);
3878
3915
  return config;
3879
3916
  }
3880
3917
  async function readConfigFromPackageJSON(projectRoot) {
@@ -3888,6 +3925,22 @@ function readInputsFromPackageJSON(packageJSON) {
3888
3925
  const qunitx = packageJSON.qunitx;
3889
3926
  return qunitx && qunitx.inputs ? qunitx.inputs : [];
3890
3927
  }
3928
+ function resolvePlugins(raw, projectRoot) {
3929
+ if (raw == null) return Promise.resolve([]);
3930
+ if (!Array.isArray(raw)) {
3931
+ console.error(`# qunitx: package.json#qunitx.plugins must be an array`);
3932
+ process.exit(1);
3933
+ }
3934
+ const projectRequire = createRequire(`${projectRoot}/package.json`);
3935
+ return Promise.all(
3936
+ raw.map(async (entry) => {
3937
+ const [spec, options] = Array.isArray(entry) ? entry : [entry];
3938
+ const mod = await import(pathToFileURL(projectRequire.resolve(spec)).href);
3939
+ const exported = mod.default ?? mod;
3940
+ return typeof exported === "function" ? exported(options) : exported;
3941
+ })
3942
+ );
3943
+ }
3891
3944
 
3892
3945
  // cli.ts
3893
3946
  process4.title = "qunitx";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "qunitx-cli",
3
3
  "type": "module",
4
- "version": "0.21.3",
4
+ "version": "0.22.0",
5
5
  "description": "Browser runner for QUnitx: run your qunitx tests in google-chrome",
6
6
  "author": "Izel Nakri",
7
7
  "license": "MIT",
@@ -58,8 +58,11 @@
58
58
  "devDependencies": {
59
59
  "js-yaml": "^4.1.1",
60
60
  "prettier": "^3.8.3",
61
- "qunitx": "^1.2.8",
62
- "typescript": "^6.0.3"
61
+ "qunitx": "^1.2.9",
62
+ "react": "^19.2.5",
63
+ "react-dom": "^19.2.5",
64
+ "typescript": "^6.0.3",
65
+ "vue": "^3.5.33"
63
66
  },
64
67
  "volta": {
65
68
  "node": "24.14.0"