qunitx-cli 0.9.1 → 0.9.3

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 (43) hide show
  1. package/{cli.js → cli.ts} +16 -8
  2. package/deno.lock +20 -14
  3. package/lib/commands/{generate.js → generate.ts} +5 -5
  4. package/lib/commands/{help.js → help.ts} +1 -1
  5. package/lib/commands/{init.js → init.ts} +26 -27
  6. package/lib/commands/run/{tests-in-browser.js → tests-in-browser.ts} +51 -22
  7. package/lib/commands/{run.js → run.ts} +100 -39
  8. package/lib/servers/{http.js → http.ts} +88 -35
  9. package/lib/setup/bind-server-to-port.ts +14 -0
  10. package/lib/setup/{browser.js → browser.ts} +14 -18
  11. package/lib/setup/config.ts +55 -0
  12. package/lib/setup/{file-watcher.js → file-watcher.ts} +21 -8
  13. package/lib/setup/{fs-tree.js → fs-tree.ts} +6 -4
  14. package/lib/setup/{keyboard-events.js → keyboard-events.ts} +10 -5
  15. package/lib/setup/{test-file-paths.js → test-file-paths.ts} +10 -4
  16. package/lib/setup/{web-server.js → web-server.ts} +22 -21
  17. package/lib/setup/{write-output-static-files.js → write-output-static-files.ts} +6 -2
  18. package/lib/tap/{display-final-result.js → display-final-result.ts} +6 -3
  19. package/lib/tap/{display-test-result.js → display-test-result.ts} +25 -11
  20. package/lib/tap/{dump-yaml.js → dump-yaml.ts} +19 -5
  21. package/lib/types.ts +61 -0
  22. package/lib/utils/{chromium-args.js → chromium-args.ts} +1 -1
  23. package/lib/utils/{color.js → color.ts} +24 -11
  24. package/lib/utils/{early-chrome.js → early-chrome.ts} +5 -5
  25. package/lib/utils/{find-chrome.js → find-chrome.ts} +2 -2
  26. package/lib/utils/{find-internal-assets-from-html.js → find-internal-assets-from-html.ts} +1 -1
  27. package/lib/utils/{find-project-root.js → find-project-root.ts} +4 -4
  28. package/lib/utils/{indent-string.js → indent-string.ts} +6 -2
  29. package/lib/utils/{listen-to-keyboard-key.js → listen-to-keyboard-key.ts} +12 -7
  30. package/lib/utils/{parse-cli-flags.js → parse-cli-flags.ts} +21 -8
  31. package/lib/utils/{path-exists.js → path-exists.ts} +2 -2
  32. package/lib/utils/{perf-logger.js → perf-logger.ts} +1 -1
  33. package/lib/utils/pre-launch-chrome.ts +45 -0
  34. package/lib/utils/{read-boilerplate.js → read-boilerplate.ts} +1 -1
  35. package/lib/utils/{resolve-port-number-for.js → resolve-port-number-for.ts} +2 -2
  36. package/lib/utils/{run-user-module.js → run-user-module.ts} +6 -2
  37. package/lib/utils/{search-in-parent-directories.js → search-in-parent-directories.ts} +5 -2
  38. package/lib/utils/{time-counter.js → time-counter.ts} +2 -2
  39. package/package.json +16 -15
  40. package/lib/setup/bind-server-to-port.js +0 -9
  41. package/lib/setup/config.js +0 -48
  42. package/lib/utils/pre-launch-chrome.js +0 -32
  43. /package/lib/setup/{default-project-config-values.js → default-project-config-values.ts} +0 -0
@@ -1,10 +1,44 @@
1
1
  import http from 'node:http';
2
2
  // @deno-types="npm:@types/ws"
3
3
  import WebSocket, { WebSocketServer } from 'ws';
4
- import bindServerToPort from '../setup/bind-server-to-port.js';
4
+ import bindServerToPort from '../setup/bind-server-to-port.ts';
5
+
6
+ declare module 'node:http' {
7
+ interface IncomingMessage {
8
+ send: (data: string) => void;
9
+ path: string;
10
+ query: Record<string, string>;
11
+ params: Record<string, string>;
12
+ }
13
+ interface ServerResponse {
14
+ json: (data: unknown) => void;
15
+ }
16
+ }
17
+
18
+ type NodeServerWithWSS = http.Server & { wss: WebSocketServer };
19
+
20
+ /** Route handler function signature for registered GET/POST/etc. routes. */
21
+ export type RouteHandler = (
22
+ req: http.IncomingMessage,
23
+ res: http.ServerResponse,
24
+ ) => void | Promise<void>;
25
+ /** Middleware function signature — call `next()` to continue the chain. */
26
+ export type Middleware = (
27
+ req: http.IncomingMessage,
28
+ res: http.ServerResponse,
29
+ next: () => void,
30
+ ) => void;
31
+
32
+ interface Route {
33
+ path: string;
34
+ handler: RouteHandler;
35
+ paramNames: string[];
36
+ isWildcard: boolean;
37
+ paramValues?: string[];
38
+ }
5
39
 
6
40
  /** Map of file extensions to their corresponding MIME type strings. */
7
- export const MIME_TYPES = {
41
+ export const MIME_TYPES: Record<string, string> = {
8
42
  html: 'text/html; charset=UTF-8',
9
43
  js: 'application/javascript',
10
44
  css: 'text/css',
@@ -17,13 +51,27 @@ export const MIME_TYPES = {
17
51
 
18
52
  /** Minimal HTTP + WebSocket server used to serve test bundles and push reload events. */
19
53
  export default class HTTPServer {
54
+ /** Registered routes keyed by HTTP method then path. */
55
+ routes: Record<string, Record<string, Route>>;
56
+ /** Registered middleware functions, applied in order before each route handler. */
57
+ middleware: Middleware[];
58
+ /** Underlying Node.js HTTP server instance. */
59
+ _server: http.Server;
60
+ /** WebSocket server attached to the HTTP server for live-reload broadcasts. */
61
+ wss: WebSocketServer;
62
+
20
63
  /**
21
64
  * Creates and starts a plain `http.createServer` instance on the given port.
22
65
  * @returns {Promise<object>}
23
66
  */
24
- static serve(config = { port: 1234 }, handler) {
25
- const onListen = config.onListen || ((_server) => {});
26
- const onError = config.onError || ((_error) => {});
67
+ static serve(
68
+ config: { port: number; onListen?: (s: object) => void; onError?: (e: Error) => void } = {
69
+ port: 1234,
70
+ },
71
+ handler: (req: http.IncomingMessage, res: http.ServerResponse) => void,
72
+ ): Promise<http.Server> {
73
+ const onListen = config.onListen || ((_server: object) => {});
74
+ const onError = config.onError || ((_error: Error) => {});
27
75
 
28
76
  return new Promise((resolve, reject) => {
29
77
  const server = http.createServer((req, res) => {
@@ -38,14 +86,13 @@ export default class HTTPServer {
38
86
  onListen(Object.assign({ hostname: '127.0.0.1', server }, config));
39
87
  resolve(server);
40
88
  });
41
-
42
- server.wss = new WebSocketServer({ server });
43
- server.wss.on('error', (error) => {
89
+ (server as NodeServerWithWSS).wss = new WebSocketServer({ server });
90
+ (server as NodeServerWithWSS).wss.on('error', (error: Error) => {
44
91
  console.log('# [WebSocketServer] Error:');
45
92
  console.trace(error);
46
93
  });
47
94
 
48
- bindServerToPort(server, config);
95
+ bindServerToPort(server as unknown as HTTPServer, config as { port: number });
49
96
  });
50
97
  }
51
98
 
@@ -58,11 +105,11 @@ export default class HTTPServer {
58
105
  };
59
106
  this.middleware = [];
60
107
  this._server = http.createServer((req, res) => {
61
- res.send = (data) => {
108
+ req.send = (data: string) => {
62
109
  res.setHeader('Content-Type', 'text/plain');
63
110
  res.end(data);
64
111
  };
65
- res.json = (data) => {
112
+ res.json = (data: unknown) => {
66
113
  res.setHeader('Content-Type', 'application/json');
67
114
  res.end(JSON.stringify(data));
68
115
  };
@@ -77,15 +124,17 @@ export default class HTTPServer {
77
124
  }
78
125
 
79
126
  /**
80
- * Closes the underlying HTTP server.
81
- * @returns {object}
127
+ * Closes the underlying HTTP server and all active connections, returning a
128
+ * Promise that resolves once the server is fully closed.
129
+ * @returns {Promise<void>}
82
130
  */
83
- close() {
84
- return this._server.close();
131
+ close(): Promise<void> {
132
+ this._server.closeAllConnections?.();
133
+ return new Promise((resolve) => this._server.close(resolve as () => void));
85
134
  }
86
135
 
87
136
  /** Registers a GET route handler. */
88
- get(path, handler) {
137
+ get(path: string, handler: RouteHandler): void {
89
138
  this.#registerRouteHandler('GET', path, handler);
90
139
  }
91
140
 
@@ -93,9 +142,9 @@ export default class HTTPServer {
93
142
  * Starts listening on the given port (0 = OS-assigned).
94
143
  * @returns {Promise<void>}
95
144
  */
96
- listen(port = 0, callback = () => {}) {
145
+ listen(port = 0, callback: () => void = () => {}): Promise<void> {
97
146
  return new Promise((resolve, reject) => {
98
- const onError = (err) => {
147
+ const onError = (err: Error) => {
99
148
  this._server.off('listening', onListening);
100
149
  reject(err);
101
150
  };
@@ -110,7 +159,7 @@ export default class HTTPServer {
110
159
  }
111
160
 
112
161
  /** Broadcasts a message to all connected WebSocket clients. */
113
- publish(data) {
162
+ publish(data: string): void {
114
163
  this.wss.clients.forEach((client) => {
115
164
  if (client.readyState === WebSocket.OPEN) {
116
165
  client.send(data);
@@ -119,26 +168,26 @@ export default class HTTPServer {
119
168
  }
120
169
 
121
170
  /** Registers a POST route handler. */
122
- post(path, handler) {
171
+ post(path: string, handler: RouteHandler): void {
123
172
  this.#registerRouteHandler('POST', path, handler);
124
173
  }
125
174
 
126
175
  /** Registers a DELETE route handler. */
127
- delete(path, handler) {
176
+ delete(path: string, handler: RouteHandler): void {
128
177
  this.#registerRouteHandler('DELETE', path, handler);
129
178
  }
130
179
 
131
180
  /** Registers a PUT route handler. */
132
- put(path, handler) {
181
+ put(path: string, handler: RouteHandler): void {
133
182
  this.#registerRouteHandler('PUT', path, handler);
134
183
  }
135
184
 
136
185
  /** Adds a middleware function to the chain. */
137
- use(middleware) {
186
+ use(middleware: Middleware): void {
138
187
  this.middleware.push(middleware);
139
188
  }
140
189
 
141
- #registerRouteHandler(method, path, handler) {
190
+ #registerRouteHandler(method: string, path: string, handler: RouteHandler): void {
142
191
  if (!this.routes[method]) {
143
192
  this.routes[method] = {};
144
193
  }
@@ -151,13 +200,13 @@ export default class HTTPServer {
151
200
  };
152
201
  }
153
202
 
154
- #handleRequest(req, res) {
203
+ #handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
155
204
  const { method, url } = req;
156
- const urlObj = new URL(url, 'http://localhost');
205
+ const urlObj = new URL(url!, 'http://localhost');
157
206
  const pathname = urlObj.pathname;
158
207
  req.path = pathname;
159
208
  req.query = Object.fromEntries(urlObj.searchParams);
160
- const matchingRoute = this.#findRouteHandler(method, pathname);
209
+ const matchingRoute = this.#findRouteHandler(method!, pathname);
161
210
 
162
211
  if (matchingRoute) {
163
212
  req.params = this.#extractParams(matchingRoute, pathname);
@@ -169,7 +218,11 @@ export default class HTTPServer {
169
218
  }
170
219
  }
171
220
 
172
- #runMiddleware(req, res, callback) {
221
+ #runMiddleware(
222
+ req: http.IncomingMessage,
223
+ res: http.ServerResponse,
224
+ callback: RouteHandler,
225
+ ): void {
173
226
  let index = 0;
174
227
  const next = () => {
175
228
  if (index >= this.middleware.length) {
@@ -183,7 +236,7 @@ export default class HTTPServer {
183
236
  next();
184
237
  }
185
238
 
186
- #findRouteHandler(method, url) {
239
+ #findRouteHandler(method: string, url: string): Route | null {
187
240
  const routes = this.routes[method];
188
241
  if (!routes) {
189
242
  return null;
@@ -217,7 +270,7 @@ export default class HTTPServer {
217
270
  );
218
271
  }
219
272
 
220
- #matchPathSegments(path, url) {
273
+ #matchPathSegments(path: string, url: string): boolean {
221
274
  const pathSegments = path.split('/');
222
275
  const urlSegments = url.split('/');
223
276
 
@@ -241,26 +294,26 @@ export default class HTTPServer {
241
294
  return true;
242
295
  }
243
296
 
244
- #buildRegexPattern(path, _paramNames) {
297
+ #buildRegexPattern(path: string, _paramNames: string[]): string {
245
298
  let regexPattern = path.replace(/:[^/]+/g, '([^/]+)');
246
299
  regexPattern = regexPattern.replace(/\//g, '\\/');
247
300
 
248
301
  return regexPattern;
249
302
  }
250
303
 
251
- #extractParamNames(path) {
304
+ #extractParamNames(path: string): string[] {
252
305
  const paramRegex = /:(\w+)/g;
253
306
  const paramMatches = path.match(paramRegex);
254
307
 
255
308
  return paramMatches ? paramMatches.map((match) => match.slice(1)) : [];
256
309
  }
257
310
 
258
- #extractParams(route, _url) {
311
+ #extractParams(route: Route, _url: string): Record<string, string> {
259
312
  const { paramNames, paramValues } = route;
260
- const params = {};
313
+ const params: Record<string, string> = {};
261
314
 
262
315
  for (let i = 0; i < paramNames.length; i++) {
263
- params[paramNames[i]] = paramValues[i];
316
+ params[paramNames[i]] = paramValues![i];
264
317
  }
265
318
 
266
319
  return params;
@@ -0,0 +1,14 @@
1
+ import type HTTPServer from '../servers/http.ts';
2
+
3
+ /**
4
+ * Binds an HTTPServer to an OS-assigned port and writes the resolved port back to `config.port`.
5
+ * @returns {Promise<object>}
6
+ */
7
+ export default async function bindServerToPort(
8
+ server: HTTPServer,
9
+ config: { port: number },
10
+ ): Promise<HTTPServer> {
11
+ await server.listen(0);
12
+ config.port = (server._server.address() as import('node:net').AddressInfo).port;
13
+ return server;
14
+ }
@@ -1,15 +1,17 @@
1
- import setupWebServer from './web-server.js';
2
- import bindServerToPort from './bind-server-to-port.js';
3
- import findChrome from '../utils/find-chrome.js';
4
- import CHROMIUM_ARGS from '../utils/chromium-args.js';
5
- import { earlyBrowserPromise } from '../utils/early-chrome.js';
6
- import { perfLog } from '../utils/perf-logger.js';
1
+ import setupWebServer from './web-server.ts';
2
+ import bindServerToPort from './bind-server-to-port.ts';
3
+ import findChrome from '../utils/find-chrome.ts';
4
+ import CHROMIUM_ARGS from '../utils/chromium-args.ts';
5
+ import { earlyBrowserPromise } from '../utils/early-chrome.ts';
6
+ import { perfLog } from '../utils/perf-logger.ts';
7
+ import type { Browser } from 'playwright-core';
8
+ import type { Config, CachedContent, Connections } from '../types.ts';
7
9
 
8
10
  // Playwright-core starts loading the moment run.js imports this module.
9
11
  // browser.js is intentionally the first import in run.js so playwright-core
10
12
  // starts loading before heavier deps (esbuild, chokidar) queue up I/O reads
11
13
  // and saturate libuv's thread pool, which would delay the dynamic import resolution.
12
- // early-chrome.js (statically imported by cli.js) already started Chrome pre-launch,
14
+ // early-chrome.ts (statically imported by cli.ts) already started Chrome pre-launch,
13
15
  // so both race in parallel — Chrome is typically ready when playwright-core finishes.
14
16
  const playwrightCorePromise = import('playwright-core');
15
17
  perfLog('browser.js: playwright-core import started');
@@ -21,7 +23,7 @@ perfLog('browser.js: playwright-core import started');
21
23
  * For firefox/webkit: uses playwright's standard launch (requires `npx playwright install [browser]`).
22
24
  * @returns {Promise<object>}
23
25
  */
24
- export async function launchBrowser(config) {
26
+ export async function launchBrowser(config: Config): Promise<Browser> {
25
27
  const browserName = config.browser || 'chromium';
26
28
 
27
29
  if (browserName === 'chromium') {
@@ -60,16 +62,10 @@ export async function launchBrowser(config) {
60
62
  * @returns {Promise<{server: object, browser: object, page: object}>}
61
63
  */
62
64
  export default async function setupBrowser(
63
- config = {
64
- port: 1234,
65
- debug: false,
66
- watch: false,
67
- timeout: 10000,
68
- browser: 'chromium',
69
- },
70
- cachedContent,
71
- existingBrowser = null,
72
- ) {
65
+ config: Config,
66
+ cachedContent: CachedContent,
67
+ existingBrowser: Browser | null = null,
68
+ ): Promise<Connections> {
73
69
  const setupStart = Date.now();
74
70
  const [server, resolvedExistingBrowser] = await Promise.all([
75
71
  setupWebServer(config, cachedContent),
@@ -0,0 +1,55 @@
1
+ import fs from 'node:fs/promises';
2
+ import defaultProjectConfigValues from './default-project-config-values.ts';
3
+ import findProjectRoot from '../utils/find-project-root.ts';
4
+ import setupFSTree from './fs-tree.ts';
5
+ import setupTestFilePaths from './test-file-paths.ts';
6
+ import parseCliFlags from '../utils/parse-cli-flags.ts';
7
+ import type { Config } from '../types.ts';
8
+
9
+ /**
10
+ * Builds the merged qunitx config from package.json settings and CLI flags.
11
+ * @returns {Promise<object>}
12
+ */
13
+ export default async function setupConfig(): Promise<Config> {
14
+ const projectRoot = await findProjectRoot();
15
+ const cliConfigFlags = parseCliFlags(projectRoot);
16
+ const projectPackageJSON = await readConfigFromPackageJSON(projectRoot);
17
+ const inputs = cliConfigFlags.inputs.concat(readInputsFromPackageJSON(projectPackageJSON));
18
+ const config = {
19
+ ...defaultProjectConfigValues,
20
+ htmlPaths: [] as string[],
21
+ ...((projectPackageJSON.qunitx as Partial<Config>) || {}),
22
+ ...cliConfigFlags,
23
+ projectRoot,
24
+ inputs,
25
+ testFileLookupPaths: setupTestFilePaths(projectRoot, inputs),
26
+ lastFailedTestFiles: null as string[] | null,
27
+ lastRanTestFiles: null as string[] | null,
28
+ COUNTER: { testCount: 0, failCount: 0, skipCount: 0, passCount: 0, errorCount: 0 },
29
+ _testRunDone: null as (() => void) | null,
30
+ _resetTestTimeout: null as (() => void) | null,
31
+ };
32
+ config.htmlPaths = normalizeHTMLPaths(config.projectRoot, config.htmlPaths);
33
+ config.fsTree = await setupFSTree(config.testFileLookupPaths, config);
34
+
35
+ return config as Config;
36
+ }
37
+
38
+ async function readConfigFromPackageJSON(projectRoot: string) {
39
+ const packageJSON = await fs.readFile(`${projectRoot}/package.json`);
40
+
41
+ return JSON.parse(packageJSON.toString()) as { qunitx?: unknown; [key: string]: unknown };
42
+ }
43
+
44
+ function normalizeHTMLPaths(projectRoot: string, htmlPaths: string[]): string[] {
45
+ return Array.from(new Set(htmlPaths.map((htmlPath) => `${projectRoot}/${htmlPath}`)));
46
+ }
47
+
48
+ function readInputsFromPackageJSON(packageJSON: {
49
+ qunitx?: unknown;
50
+ [key: string]: unknown;
51
+ }): string[] {
52
+ const qunitx = packageJSON.qunitx as { inputs?: string[] } | undefined;
53
+
54
+ return qunitx && qunitx.inputs ? qunitx.inputs : [];
55
+ }
@@ -1,14 +1,21 @@
1
1
  import fs from 'node:fs';
2
2
  import { stat } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { green, magenta, red, yellow } from '../utils/color.js';
4
+ import { green, magenta, red, yellow } from '../utils/color.ts';
5
+ import type { FSWatcher } from 'node:fs';
6
+ import type { Config, FSTree } from '../types.ts';
5
7
 
6
8
  /**
7
9
  * Starts `fs.watch` watchers for each lookup path and calls `onEventFunc` on JS/TS file changes, debounced via a flag.
8
10
  * Uses `config.fsTree` to distinguish `unlink` (tracked file) from `unlinkDir` (directory) on deletion.
9
11
  * @returns {object}
10
12
  */
11
- export default function setupFileWatchers(testFileLookupPaths, config, onEventFunc, onFinishFunc) {
13
+ export default function setupFileWatchers(
14
+ testFileLookupPaths: string[],
15
+ config: Config,
16
+ onEventFunc: (event: string, file: string) => unknown,
17
+ onFinishFunc: ((path: string, event: string) => void) | null | undefined,
18
+ ): { fileWatchers: Record<string, FSWatcher>; killFileWatchers: () => Record<string, FSWatcher> } {
12
19
  const extensions = config.extensions || ['js', 'ts'];
13
20
  const fileWatchers = testFileLookupPaths.reduce((watchers, watchPath) => {
14
21
  let ready = false;
@@ -54,7 +61,14 @@ export default function setupFileWatchers(testFileLookupPaths, config, onEventFu
54
61
  * `unlinkDir` bypasses the extension filter so deleted directories always clean up fsTree.
55
62
  * @returns {void}
56
63
  */
57
- export function handleWatchEvent(config, extensions, event, filePath, onEventFunc, onFinishFunc) {
64
+ export function handleWatchEvent(
65
+ config: Config,
66
+ extensions: string[],
67
+ event: string,
68
+ filePath: string,
69
+ onEventFunc: (event: string, file: string) => unknown,
70
+ onFinishFunc: ((path: string, event: string) => void) | null | undefined,
71
+ ): void {
58
72
  const isFileEvent = extensions.some((ext) => filePath.endsWith(`.${ext}`));
59
73
 
60
74
  if (!isFileEvent && event !== 'unlinkDir') return;
@@ -86,9 +100,8 @@ export function handleWatchEvent(config, extensions, event, filePath, onEventFun
86
100
  .then(() => {
87
101
  onFinishFunc ? onFinishFunc(event, filePath) : null;
88
102
  })
89
- .catch(() => {
90
- // TODO: make an index.html to display the error
91
- // error type has to be derived from the error!
103
+ .catch((error) => {
104
+ console.error('#', red('Build error:'), error.message || error);
92
105
  })
93
106
  .finally(() => (config._building = false));
94
107
  }
@@ -98,7 +111,7 @@ export function handleWatchEvent(config, extensions, event, filePath, onEventFun
98
111
  * Mutates `fsTree` in place based on a chokidar file-system event.
99
112
  * @returns {void}
100
113
  */
101
- export function mutateFSTree(fsTree, event, path) {
114
+ export function mutateFSTree(fsTree: FSTree, event: string, path: string): void {
102
115
  if (event === 'add') {
103
116
  fsTree[path] = null;
104
117
  } else if (event === 'unlink') {
@@ -110,7 +123,7 @@ export function mutateFSTree(fsTree, event, path) {
110
123
  }
111
124
  }
112
125
 
113
- function getEventColor(event) {
126
+ function getEventColor(event: string): unknown {
114
127
  if (event === 'change') {
115
128
  return yellow('CHANGED:');
116
129
  } else if (event === 'add' || event === 'addDir') {
@@ -2,8 +2,9 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  // @deno-types="npm:@types/picomatch"
4
4
  import picomatch from 'picomatch';
5
+ import type { FSTree } from '../types.ts';
5
6
 
6
- async function readDirRecursive(dir, filter) {
7
+ async function readDirRecursive(dir: string, filter: (name: string) => boolean): Promise<string[]> {
7
8
  const entries = await fs.readdir(dir, { recursive: true, withFileTypes: true });
8
9
  return entries
9
10
  .filter((e) => e.isFile() && filter(e.name))
@@ -14,7 +15,10 @@ async function readDirRecursive(dir, filter) {
14
15
  * Resolves an array of file paths, directories, or glob patterns into a flat `{ absolutePath: null }` map.
15
16
  * @returns {Promise<object>}
16
17
  */
17
- export default async function buildFSTree(fileAbsolutePaths, config = {}) {
18
+ export default async function buildFSTree(
19
+ fileAbsolutePaths: string[],
20
+ config: { extensions?: string[] } = {},
21
+ ): Promise<FSTree> {
18
22
  const targetExtensions = config.extensions || ['js', 'ts'];
19
23
  const fsTree = {};
20
24
 
@@ -22,8 +26,6 @@ export default async function buildFSTree(fileAbsolutePaths, config = {}) {
22
26
  fileAbsolutePaths.map(async (fileAbsolutePath) => {
23
27
  const glob = picomatch.scan(fileAbsolutePath);
24
28
 
25
- // TODO: maybe allow absolute path references
26
-
27
29
  try {
28
30
  if (glob.isGlob) {
29
31
  const fileNames = await readDirRecursive(glob.base, (name) => {
@@ -1,12 +1,17 @@
1
- import { blue } from '../utils/color.js';
2
- import listenToKeyboardKey from '../utils/listen-to-keyboard-key.js';
3
- import runTestsInBrowser from '../commands/run/tests-in-browser.js';
1
+ import { blue } from '../utils/color.ts';
2
+ import listenToKeyboardKey from '../utils/listen-to-keyboard-key.ts';
3
+ import runTestsInBrowser from '../commands/run/tests-in-browser.ts';
4
+ import type { Config, CachedContent, Connections } from '../types.ts';
4
5
 
5
6
  /**
6
7
  * Registers watch-mode keyboard shortcuts: `qq` to abort, `qa` to run all, `qf` for last failed, `ql` for last run.
7
8
  * @returns {void}
8
9
  */
9
- export default function setupKeyboardEvents(config, cachedContent, connections) {
10
+ export default function setupKeyboardEvents(
11
+ config: Config,
12
+ cachedContent: CachedContent,
13
+ connections: Connections,
14
+ ): void {
10
15
  listenToKeyboardKey('qq', () => abortBrowserQUnit(config, connections));
11
16
  listenToKeyboardKey('qa', () => {
12
17
  abortBrowserQUnit(config, connections);
@@ -28,6 +33,6 @@ export default function setupKeyboardEvents(config, cachedContent, connections)
28
33
  });
29
34
  }
30
35
 
31
- function abortBrowserQUnit(_config, connections) {
36
+ function abortBrowserQUnit(_config: Config, connections: Connections): void {
32
37
  connections.server.publish('abort', 'abort');
33
38
  }
@@ -1,11 +1,17 @@
1
1
  // @deno-types="npm:@types/picomatch"
2
2
  import picomatch from 'picomatch';
3
3
 
4
+ interface PathMeta {
5
+ input: string;
6
+ isFile: boolean;
7
+ isGlob: boolean;
8
+ }
9
+
4
10
  /**
5
11
  * Deduplicates a list of file, folder, and glob inputs so that more-specific paths covered by broader ones are removed.
6
12
  * @returns {string[]}
7
13
  */
8
- export default function setupTestFilePaths(_projectRoot, inputs) {
14
+ export default function setupTestFilePaths(_projectRoot: string, inputs: string[]): string[] {
9
15
  // NOTE: very complex algorithm, order is very important
10
16
  const [folders, filesWithGlob, filesWithoutGlob] = inputs.reduce(
11
17
  (result, input) => {
@@ -52,13 +58,13 @@ export default function setupTestFilePaths(_projectRoot, inputs) {
52
58
  return result.map((metaItem) => metaItem.input);
53
59
  }
54
60
 
55
- function pathIsFile(path) {
61
+ function pathIsFile(path: string): boolean {
56
62
  const inputs = path.split('/');
57
63
 
58
64
  return inputs[inputs.length - 1].includes('.');
59
65
  }
60
66
 
61
- function pathIsIncludedInPaths(paths, targetPath) {
67
+ function pathIsIncludedInPaths(paths: PathMeta[], targetPath: PathMeta): boolean {
62
68
  return paths.some((path) => {
63
69
  if (path === targetPath) {
64
70
  return false;
@@ -70,7 +76,7 @@ function pathIsIncludedInPaths(paths, targetPath) {
70
76
  });
71
77
  }
72
78
 
73
- function buildGlobFormat(path) {
79
+ function buildGlobFormat(path: PathMeta): string {
74
80
  if (!path.isFile) {
75
81
  if (!path.isGlob) {
76
82
  return `${path.input}/*`;
@@ -1,9 +1,10 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import findInternalAssetsFromHTML from '../utils/find-internal-assets-from-html.js';
4
- import TAPDisplayTestResult from '../tap/display-test-result.js';
5
- import pathExists from '../utils/path-exists.js';
6
- import HTTPServer, { MIME_TYPES } from '../servers/http.js';
3
+ import findInternalAssetsFromHTML from '../utils/find-internal-assets-from-html.ts';
4
+ import TAPDisplayTestResult from '../tap/display-test-result.ts';
5
+ import pathExists from '../utils/path-exists.ts';
6
+ import HTTPServer, { MIME_TYPES } from '../servers/http.ts';
7
+ import type { Config, CachedContent } from '../types.ts';
7
8
 
8
9
  const fsPromise = fs.promises;
9
10
 
@@ -11,15 +12,7 @@ const fsPromise = fs.promises;
11
12
  * Creates and returns an HTTPServer with routes for the test HTML, filtered test page, and static assets, plus a WebSocket handler that streams TAP events.
12
13
  * @returns {object}
13
14
  */
14
- export default function setupWebServer(
15
- config = {
16
- port: 1234,
17
- debug: false,
18
- watch: false,
19
- timeout: 10000,
20
- },
21
- cachedContent,
22
- ) {
15
+ export default function setupWebServer(config: Config, cachedContent: CachedContent): HTTPServer {
23
16
  const STATIC_FILES_PATH = path.join(config.projectRoot, config.output);
24
17
  const server = new HTTPServer();
25
18
 
@@ -29,11 +22,13 @@ export default function setupWebServer(
29
22
 
30
23
  if (event === 'connection') {
31
24
  if (!config._groupMode) console.log('TAP version 13');
25
+ config._resetTestTimeout?.();
32
26
  } else if (event === 'testEnd' && !abort) {
33
27
  if (details.status === 'failed') {
34
28
  config.lastFailedTestFiles = config.lastRanTestFiles;
35
29
  }
36
30
 
31
+ config._resetTestTimeout?.();
37
32
  TAPDisplayTestResult(config.COUNTER, details);
38
33
  } else if (event === 'done') {
39
34
  // Signal test completion. TCP ordering guarantees all testEnd messages
@@ -133,7 +128,7 @@ export default function setupWebServer(
133
128
  return server;
134
129
  }
135
130
 
136
- function replaceAssetPaths(html, htmlPath, projectRoot) {
131
+ function replaceAssetPaths(html: string, htmlPath: string, projectRoot: string): string {
137
132
  const assetPaths = findInternalAssetsFromHTML(html);
138
133
  const htmlDirectory = htmlPath.split('/').slice(0, -1).join('/');
139
134
 
@@ -144,7 +139,7 @@ function replaceAssetPaths(html, htmlPath, projectRoot) {
144
139
  }, html);
145
140
  }
146
141
 
147
- function testRuntimeToInject(port, config) {
142
+ function testRuntimeToInject(port: number, config: Config): string {
148
143
  return `<script>
149
144
  window.testTimeout = 0;
150
145
  setInterval(() => {
@@ -214,7 +209,7 @@ function testRuntimeToInject(port, config) {
214
209
  }
215
210
 
216
211
  function setupQUnit() {
217
- window.QUNIT_RESULT = { totalTests: 0, finishedTests: 0, currentTest: '' };
212
+ window.QUNIT_RESULT = { totalTests: 0, finishedTests: 0, failedTests: 0, currentTest: '' };
218
213
 
219
214
  if (!window.QUnit) {
220
215
  console.log('QUnit not found after WebSocket connected');
@@ -239,6 +234,7 @@ function testRuntimeToInject(port, config) {
239
234
  window.QUnit.on('testEnd', (details) => { // NOTE: https://github.com/qunitjs/qunit/blob/master/src/html-reporter/diff.js
240
235
  window.testTimeout = 0;
241
236
  window.QUNIT_RESULT.finishedTests++;
237
+ if (details.status === 'failed') window.QUNIT_RESULT.failedTests++;
242
238
  window.QUNIT_RESULT.currentTest = null;
243
239
  if (window.IS_PLAYWRIGHT) {
244
240
  window.socket.send(JSON.stringify({ event: 'testEnd', details: details, abort: window.abortQUnit }, getCircularReplacer()));
@@ -251,10 +247,11 @@ function testRuntimeToInject(port, config) {
251
247
  window.QUnit.done((details) => {
252
248
  if (window.IS_PLAYWRIGHT) {
253
249
  window.socket.send(JSON.stringify({ event: 'done', details: details, abort: window.abortQUnit }, getCircularReplacer()));
254
- // Delay the testTimeout fallback so the WS done event can arrive at Node.js first.
255
- // Without this delay, waitForFunction resolves before Node.js processes the done WS
256
- // message, causing the shared COUNTER to miss testEnd results under concurrent load.
257
- window.setTimeout(() => { window.testTimeout = ${config.timeout}; }, 500);
250
+ // Do NOT set testTimeout here. The WS 'done' event (testsDone promise) is the
251
+ // canonical completion signal for Playwright runs. waitForFunction is reserved
252
+ // for true timeouts (test hangs) where testTimeout increments naturally via setInterval.
253
+ // Setting testTimeout after done caused a race: under CI load, waitForFunction could
254
+ // win before Node.js processed the WS done message, dropping all testEnd events.
258
255
  } else {
259
256
  window.testTimeout = ${config.timeout};
260
257
  }
@@ -265,7 +262,11 @@ function testRuntimeToInject(port, config) {
265
262
  </script>`;
266
263
  }
267
264
 
268
- function escapeAndInjectTestsToHTML(html, testRuntimeCode, testContentCode) {
265
+ function escapeAndInjectTestsToHTML(
266
+ html: string,
267
+ testRuntimeCode: string,
268
+ testContentCode: Buffer | string | null | undefined,
269
+ ): string {
269
270
  return html.replace(
270
271
  '{{content}}',
271
272
  testRuntimeCode.replace('{{allTestCode}}', testContentCode).replace('</script>', '<\/script>'), // NOTE: remove this when simple-html-tokenizer PR gets merged