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.
- package/{cli.js → cli.ts} +16 -8
- package/deno.lock +20 -14
- package/lib/commands/{generate.js → generate.ts} +5 -5
- package/lib/commands/{help.js → help.ts} +1 -1
- package/lib/commands/{init.js → init.ts} +26 -27
- package/lib/commands/run/{tests-in-browser.js → tests-in-browser.ts} +51 -22
- package/lib/commands/{run.js → run.ts} +100 -39
- package/lib/servers/{http.js → http.ts} +88 -35
- package/lib/setup/bind-server-to-port.ts +14 -0
- package/lib/setup/{browser.js → browser.ts} +14 -18
- package/lib/setup/config.ts +55 -0
- package/lib/setup/{file-watcher.js → file-watcher.ts} +21 -8
- package/lib/setup/{fs-tree.js → fs-tree.ts} +6 -4
- package/lib/setup/{keyboard-events.js → keyboard-events.ts} +10 -5
- package/lib/setup/{test-file-paths.js → test-file-paths.ts} +10 -4
- package/lib/setup/{web-server.js → web-server.ts} +22 -21
- package/lib/setup/{write-output-static-files.js → write-output-static-files.ts} +6 -2
- package/lib/tap/{display-final-result.js → display-final-result.ts} +6 -3
- package/lib/tap/{display-test-result.js → display-test-result.ts} +25 -11
- package/lib/tap/{dump-yaml.js → dump-yaml.ts} +19 -5
- package/lib/types.ts +61 -0
- package/lib/utils/{chromium-args.js → chromium-args.ts} +1 -1
- package/lib/utils/{color.js → color.ts} +24 -11
- package/lib/utils/{early-chrome.js → early-chrome.ts} +5 -5
- package/lib/utils/{find-chrome.js → find-chrome.ts} +2 -2
- package/lib/utils/{find-internal-assets-from-html.js → find-internal-assets-from-html.ts} +1 -1
- package/lib/utils/{find-project-root.js → find-project-root.ts} +4 -4
- package/lib/utils/{indent-string.js → indent-string.ts} +6 -2
- package/lib/utils/{listen-to-keyboard-key.js → listen-to-keyboard-key.ts} +12 -7
- package/lib/utils/{parse-cli-flags.js → parse-cli-flags.ts} +21 -8
- package/lib/utils/{path-exists.js → path-exists.ts} +2 -2
- package/lib/utils/{perf-logger.js → perf-logger.ts} +1 -1
- package/lib/utils/pre-launch-chrome.ts +45 -0
- package/lib/utils/{read-boilerplate.js → read-boilerplate.ts} +1 -1
- package/lib/utils/{resolve-port-number-for.js → resolve-port-number-for.ts} +2 -2
- package/lib/utils/{run-user-module.js → run-user-module.ts} +6 -2
- package/lib/utils/{search-in-parent-directories.js → search-in-parent-directories.ts} +5 -2
- package/lib/utils/{time-counter.js → time-counter.ts} +2 -2
- package/package.json +16 -15
- package/lib/setup/bind-server-to-port.js +0 -9
- package/lib/setup/config.js +0 -48
- package/lib/utils/pre-launch-chrome.js +0 -32
- /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.
|
|
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(
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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.
|
|
2
|
-
import bindServerToPort from './bind-server-to-port.
|
|
3
|
-
import findChrome from '../utils/find-chrome.
|
|
4
|
-
import CHROMIUM_ARGS from '../utils/chromium-args.
|
|
5
|
-
import { earlyBrowserPromise } from '../utils/early-chrome.
|
|
6
|
-
import { perfLog } from '../utils/perf-logger.
|
|
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.
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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.
|
|
2
|
-
import listenToKeyboardKey from '../utils/listen-to-keyboard-key.
|
|
3
|
-
import runTestsInBrowser from '../commands/run/tests-in-browser.
|
|
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(
|
|
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.
|
|
4
|
-
import TAPDisplayTestResult from '../tap/display-test-result.
|
|
5
|
-
import pathExists from '../utils/path-exists.
|
|
6
|
-
import HTTPServer, { MIME_TYPES } from '../servers/http.
|
|
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
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
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(
|
|
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
|