@rstest/browser 0.7.9
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/dist/browser-container/container-static/css/container.dc438e35.css +1 -0
- package/dist/browser-container/container-static/js/916.5aee8d2f.js +23549 -0
- package/dist/browser-container/container-static/js/916.5aee8d2f.js.LICENSE.txt +1 -0
- package/dist/browser-container/container-static/js/container.5ddd46c3.js +2281 -0
- package/dist/browser-container/container-static/js/lib-react.a9c9a89b.js +8464 -0
- package/dist/browser-container/container-static/js/lib-react.a9c9a89b.js.LICENSE.txt +1 -0
- package/dist/browser-container/container.html +14 -0
- package/dist/client/entry.d.ts +7 -0
- package/dist/client/fakeTimersStub.d.ts +25 -0
- package/dist/client/public.d.ts +9 -0
- package/dist/client/snapshot.d.ts +35 -0
- package/dist/client/sourceMapSupport.d.ts +45 -0
- package/dist/hostController.d.ts +17 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +2576 -0
- package/dist/protocol.d.ts +134 -0
- package/dist/rslib-runtime.js +18 -0
- package/package.json +80 -0
- package/src/client/entry.ts +628 -0
- package/src/client/fakeTimersStub.ts +91 -0
- package/src/client/public.ts +12 -0
- package/src/client/snapshot.ts +177 -0
- package/src/client/sourceMapSupport.ts +178 -0
- package/src/env.d.ts +43 -0
- package/src/hostController.ts +1761 -0
- package/src/index.ts +18 -0
- package/src/manifest.d.ts +41 -0
- package/src/protocol.ts +132 -0
|
@@ -0,0 +1,1761 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import {
|
|
6
|
+
color,
|
|
7
|
+
type FormattedError,
|
|
8
|
+
getSetupFiles,
|
|
9
|
+
getTestEntries,
|
|
10
|
+
isDebug,
|
|
11
|
+
type ListCommandResult,
|
|
12
|
+
logger,
|
|
13
|
+
type ProjectContext,
|
|
14
|
+
type Reporter,
|
|
15
|
+
type Rstest,
|
|
16
|
+
type RuntimeConfig,
|
|
17
|
+
rsbuild,
|
|
18
|
+
serializableConfig,
|
|
19
|
+
TEMP_RSTEST_OUTPUT_DIR,
|
|
20
|
+
type Test,
|
|
21
|
+
type TestFileResult,
|
|
22
|
+
type TestResult,
|
|
23
|
+
type UserConsoleLog,
|
|
24
|
+
} from '@rstest/core/browser';
|
|
25
|
+
import { type BirpcReturn, createBirpc } from 'birpc';
|
|
26
|
+
import openEditor from 'open-editor';
|
|
27
|
+
import { basename, dirname, join, normalize, relative, resolve } from 'pathe';
|
|
28
|
+
import * as picomatch from 'picomatch';
|
|
29
|
+
import type { BrowserContext, ConsoleMessage, Page } from 'playwright';
|
|
30
|
+
import sirv from 'sirv';
|
|
31
|
+
import { type WebSocket, WebSocketServer } from 'ws';
|
|
32
|
+
import type {
|
|
33
|
+
BrowserHostConfig,
|
|
34
|
+
BrowserProjectRuntime,
|
|
35
|
+
TestFileInfo,
|
|
36
|
+
} from './protocol';
|
|
37
|
+
|
|
38
|
+
const { createRsbuild, rspack } = rsbuild;
|
|
39
|
+
type RsbuildDevServer = rsbuild.RsbuildDevServer;
|
|
40
|
+
type RsbuildInstance = rsbuild.RsbuildInstance;
|
|
41
|
+
|
|
42
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// Type Definitions
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
type VirtualModulesPluginInstance = InstanceType<
|
|
49
|
+
(typeof rspack.experiments)['VirtualModulesPlugin']
|
|
50
|
+
>;
|
|
51
|
+
|
|
52
|
+
type PlaywrightModule = typeof import('playwright');
|
|
53
|
+
type BrowserType = PlaywrightModule['chromium'];
|
|
54
|
+
type BrowserInstance = Awaited<ReturnType<BrowserType['launch']>>;
|
|
55
|
+
|
|
56
|
+
type BrowserProjectEntries = {
|
|
57
|
+
project: ProjectContext;
|
|
58
|
+
setupFiles: string[];
|
|
59
|
+
testFiles: string[];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Payload for test file start event */
|
|
63
|
+
type TestFileStartPayload = {
|
|
64
|
+
testPath: string;
|
|
65
|
+
projectName: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Payload for log event */
|
|
69
|
+
type LogPayload = {
|
|
70
|
+
level: 'log' | 'warn' | 'error' | 'info' | 'debug';
|
|
71
|
+
content: string;
|
|
72
|
+
testPath: string;
|
|
73
|
+
type: 'stdout' | 'stderr';
|
|
74
|
+
trace?: string;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Payload for fatal error event */
|
|
78
|
+
type FatalPayload = {
|
|
79
|
+
message: string;
|
|
80
|
+
stack?: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** RPC methods exposed by the host (server) to the container (client) */
|
|
84
|
+
type HostRpcMethods = {
|
|
85
|
+
rerunTest: (testFile: string, testNamePattern?: string) => Promise<void>;
|
|
86
|
+
getTestFiles: () => Promise<TestFileInfo[]>;
|
|
87
|
+
// Test result callbacks from container
|
|
88
|
+
onTestFileStart: (payload: TestFileStartPayload) => Promise<void>;
|
|
89
|
+
onTestCaseResult: (payload: TestResult) => Promise<void>;
|
|
90
|
+
onTestFileComplete: (payload: TestFileResult) => Promise<void>;
|
|
91
|
+
onLog: (payload: LogPayload) => Promise<void>;
|
|
92
|
+
onFatal: (payload: FatalPayload) => Promise<void>;
|
|
93
|
+
// Snapshot file operations (for browser mode snapshot support)
|
|
94
|
+
resolveSnapshotPath: (testPath: string) => Promise<string>;
|
|
95
|
+
readSnapshotFile: (filepath: string) => Promise<string | null>;
|
|
96
|
+
saveSnapshotFile: (filepath: string, content: string) => Promise<void>;
|
|
97
|
+
removeSnapshotFile: (filepath: string) => Promise<void>;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** RPC methods exposed by the container (client) to the host (server) */
|
|
101
|
+
type ContainerRpcMethods = {
|
|
102
|
+
onTestFileUpdate: (testFiles: TestFileInfo[]) => Promise<void>;
|
|
103
|
+
reloadTestFile: (testFile: string, testNamePattern?: string) => Promise<void>;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
type ContainerRpc = BirpcReturn<ContainerRpcMethods, HostRpcMethods>;
|
|
107
|
+
|
|
108
|
+
// ============================================================================
|
|
109
|
+
// RPC Manager - Encapsulates WebSocket and birpc management
|
|
110
|
+
// ============================================================================
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Manages the WebSocket connection and birpc communication with the container UI.
|
|
114
|
+
* Provides a clean interface for sending RPC calls and handling connections.
|
|
115
|
+
*/
|
|
116
|
+
class ContainerRpcManager {
|
|
117
|
+
private wss: WebSocketServer;
|
|
118
|
+
private ws: WebSocket | null = null;
|
|
119
|
+
private rpc: ContainerRpc | null = null;
|
|
120
|
+
private methods: HostRpcMethods;
|
|
121
|
+
|
|
122
|
+
constructor(wss: WebSocketServer, methods: HostRpcMethods) {
|
|
123
|
+
this.wss = wss;
|
|
124
|
+
this.methods = methods;
|
|
125
|
+
this.setupConnectionHandler();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Update the RPC methods (used when starting a new test run) */
|
|
129
|
+
updateMethods(methods: HostRpcMethods): void {
|
|
130
|
+
this.methods = methods;
|
|
131
|
+
// Re-create birpc with new methods if already connected
|
|
132
|
+
if (this.ws && this.ws.readyState === this.ws.OPEN) {
|
|
133
|
+
this.attachWebSocket(this.ws);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private setupConnectionHandler(): void {
|
|
138
|
+
this.wss.on('connection', (ws: WebSocket) => {
|
|
139
|
+
logger.log(color.gray('[Browser UI] Container WebSocket connected'));
|
|
140
|
+
logger.log(
|
|
141
|
+
color.gray(
|
|
142
|
+
`[Browser UI] Current ws: ${this.ws ? 'exists' : 'null'}, new ws: ${ws ? 'exists' : 'null'}`,
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
this.attachWebSocket(ws);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private attachWebSocket(ws: WebSocket): void {
|
|
150
|
+
this.ws = ws;
|
|
151
|
+
|
|
152
|
+
this.rpc = createBirpc<ContainerRpcMethods, HostRpcMethods>(this.methods, {
|
|
153
|
+
post: (data) => {
|
|
154
|
+
if (ws.readyState === ws.OPEN) {
|
|
155
|
+
ws.send(JSON.stringify(data));
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
on: (fn) => {
|
|
159
|
+
ws.on('message', (message) => {
|
|
160
|
+
try {
|
|
161
|
+
const data = JSON.parse(message.toString());
|
|
162
|
+
fn(data);
|
|
163
|
+
} catch {
|
|
164
|
+
// ignore invalid messages
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
ws.on('close', () => {
|
|
171
|
+
// Only clear if this is still the active connection
|
|
172
|
+
// This prevents a race condition when a new connection is established
|
|
173
|
+
// before the old one's close event fires
|
|
174
|
+
if (this.ws === ws) {
|
|
175
|
+
this.ws = null;
|
|
176
|
+
this.rpc = null;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Check if a container is currently connected */
|
|
182
|
+
get isConnected(): boolean {
|
|
183
|
+
return this.ws !== null && this.ws.readyState === this.ws.OPEN;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Get the current WebSocket instance (for reuse in watch mode) */
|
|
187
|
+
get currentWebSocket(): WebSocket | null {
|
|
188
|
+
return this.ws;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Reattach an existing WebSocket (for watch mode reuse) */
|
|
192
|
+
reattach(ws: WebSocket): void {
|
|
193
|
+
this.attachWebSocket(ws);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Notify container of test file changes */
|
|
197
|
+
async notifyTestFileUpdate(files: TestFileInfo[]): Promise<void> {
|
|
198
|
+
await this.rpc?.onTestFileUpdate(files);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Request container to reload a specific test file */
|
|
202
|
+
async reloadTestFile(
|
|
203
|
+
testFile: string,
|
|
204
|
+
testNamePattern?: string,
|
|
205
|
+
): Promise<void> {
|
|
206
|
+
logger.log(
|
|
207
|
+
color.gray(
|
|
208
|
+
`[Browser UI] reloadTestFile called, rpc: ${this.rpc ? 'exists' : 'null'}, ws: ${this.ws ? 'exists' : 'null'}`,
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
if (!this.rpc) {
|
|
212
|
+
logger.log(
|
|
213
|
+
color.yellow('[Browser UI] RPC not available, skipping reloadTestFile'),
|
|
214
|
+
);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
logger.log(color.gray(`[Browser UI] Calling reloadTestFile: ${testFile}`));
|
|
218
|
+
await this.rpc.reloadTestFile(testFile, testNamePattern);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ============================================================================
|
|
223
|
+
// Browser Runtime - Core runtime state
|
|
224
|
+
// ============================================================================
|
|
225
|
+
|
|
226
|
+
type BrowserRuntime = {
|
|
227
|
+
rsbuildInstance: RsbuildInstance;
|
|
228
|
+
devServer: RsbuildDevServer;
|
|
229
|
+
browser: BrowserInstance;
|
|
230
|
+
port: number;
|
|
231
|
+
wsPort: number;
|
|
232
|
+
manifestPath: string;
|
|
233
|
+
tempDir: string;
|
|
234
|
+
manifestPlugin: VirtualModulesPluginInstance;
|
|
235
|
+
containerPage?: Page;
|
|
236
|
+
containerContext?: BrowserContext;
|
|
237
|
+
setContainerOptions: (options: BrowserHostConfig) => void;
|
|
238
|
+
wss: WebSocketServer;
|
|
239
|
+
rpcManager?: ContainerRpcManager;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// ============================================================================
|
|
243
|
+
// Watch Mode Context - Encapsulates all watch mode state
|
|
244
|
+
// ============================================================================
|
|
245
|
+
|
|
246
|
+
type WatchContext = {
|
|
247
|
+
runtime: BrowserRuntime | null;
|
|
248
|
+
lastTestFiles: TestFileInfo[];
|
|
249
|
+
hooksEnabled: boolean;
|
|
250
|
+
cleanupRegistered: boolean;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const watchContext: WatchContext = {
|
|
254
|
+
runtime: null,
|
|
255
|
+
lastTestFiles: [],
|
|
256
|
+
hooksEnabled: false,
|
|
257
|
+
cleanupRegistered: false,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// ============================================================================
|
|
261
|
+
// Utility Functions
|
|
262
|
+
// ============================================================================
|
|
263
|
+
|
|
264
|
+
const ensureProcessExitCode = (code: number): void => {
|
|
265
|
+
if (process.exitCode === undefined || process.exitCode === 0) {
|
|
266
|
+
process.exitCode = code;
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Convert a single glob pattern to RegExp using picomatch
|
|
272
|
+
* Based on Storybook's implementation
|
|
273
|
+
*/
|
|
274
|
+
const globToRegexp = (glob: string): RegExp => {
|
|
275
|
+
const regex = picomatch.makeRe(glob, {
|
|
276
|
+
fastpaths: false,
|
|
277
|
+
noglobstar: false,
|
|
278
|
+
bash: false,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
if (!regex) {
|
|
282
|
+
throw new Error(`Invalid glob pattern: ${glob}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// picomatch generates regex starting with ^
|
|
286
|
+
// For patterns starting with ./, we need special handling
|
|
287
|
+
if (!glob.startsWith('./')) {
|
|
288
|
+
return regex;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// makeRe is sort of funny. If you pass it a directory starting with `./` it
|
|
292
|
+
// creates a matcher that expects files with no prefix (e.g. `src/file.js`)
|
|
293
|
+
// but if you pass it a directory that starts with `../` it expects files that
|
|
294
|
+
// start with `../`. Let's make it consistent.
|
|
295
|
+
// Globs starting `**` need special treatment due to the regex they produce
|
|
296
|
+
return new RegExp(
|
|
297
|
+
[
|
|
298
|
+
'^\\.',
|
|
299
|
+
glob.startsWith('./**') ? '' : '[\\\\/]',
|
|
300
|
+
regex.source.substring(1),
|
|
301
|
+
].join(''),
|
|
302
|
+
);
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Convert rstest include glob patterns to RegExp for import.meta.webpackContext
|
|
307
|
+
* Uses picomatch for robust glob-to-regexp conversion
|
|
308
|
+
*/
|
|
309
|
+
const globPatternsToRegExp = (patterns: string[]): RegExp => {
|
|
310
|
+
const regexParts = patterns.map((pattern) => {
|
|
311
|
+
const regex = globToRegexp(pattern);
|
|
312
|
+
// Remove ^ anchor and $ anchor to allow combining patterns
|
|
313
|
+
let source = regex.source;
|
|
314
|
+
if (source.startsWith('^')) {
|
|
315
|
+
source = source.substring(1);
|
|
316
|
+
}
|
|
317
|
+
if (source.endsWith('$')) {
|
|
318
|
+
source = source.substring(0, source.length - 1);
|
|
319
|
+
}
|
|
320
|
+
return source;
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
return new RegExp(`(?:${regexParts.join('|')})$`);
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Convert exclude patterns to a RegExp for import.meta.webpackContext's exclude option
|
|
328
|
+
* This is used at compile time to filter out files during bundling
|
|
329
|
+
*
|
|
330
|
+
* Example:
|
|
331
|
+
* Input: ['**\/node_modules\/**', '**\/dist\/**']
|
|
332
|
+
* Output: /[\\/](node_modules|dist)[\\/]/
|
|
333
|
+
*/
|
|
334
|
+
const excludePatternsToRegExp = (patterns: string[]): RegExp | null => {
|
|
335
|
+
const keywords: string[] = [];
|
|
336
|
+
for (const pattern of patterns) {
|
|
337
|
+
// Extract the core part between ** wildcards
|
|
338
|
+
// e.g., '**/node_modules/**' -> 'node_modules'
|
|
339
|
+
// e.g., '**/dist/**' -> 'dist'
|
|
340
|
+
// e.g., '**/.{idea,git,cache,output,temp}/**' -> extract each part
|
|
341
|
+
const match = pattern.match(
|
|
342
|
+
/\*\*\/\.?\{?([^/*{}]+(?:,[^/*{}]+)*)\}?\/?\*?\*?/,
|
|
343
|
+
);
|
|
344
|
+
if (match) {
|
|
345
|
+
// Handle {a,b,c} patterns
|
|
346
|
+
const parts = match[1]!.split(',');
|
|
347
|
+
for (const part of parts) {
|
|
348
|
+
// Clean up the part (remove leading dots for hidden dirs)
|
|
349
|
+
const cleaned = part.replace(/^\./, '');
|
|
350
|
+
if (cleaned && !keywords.includes(cleaned)) {
|
|
351
|
+
keywords.push(cleaned);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (keywords.length === 0) {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Create regex that matches paths containing these directory names
|
|
362
|
+
// Use [\\/] to match both forward and back slashes
|
|
363
|
+
return new RegExp(`[\\\\/](${keywords.join('|')})[\\\\/]`);
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const getRuntimeConfigFromProject = (
|
|
367
|
+
project: ProjectContext,
|
|
368
|
+
): RuntimeConfig => {
|
|
369
|
+
const {
|
|
370
|
+
testNamePattern,
|
|
371
|
+
testTimeout,
|
|
372
|
+
passWithNoTests,
|
|
373
|
+
retry,
|
|
374
|
+
globals,
|
|
375
|
+
clearMocks,
|
|
376
|
+
resetMocks,
|
|
377
|
+
restoreMocks,
|
|
378
|
+
unstubEnvs,
|
|
379
|
+
unstubGlobals,
|
|
380
|
+
maxConcurrency,
|
|
381
|
+
printConsoleTrace,
|
|
382
|
+
disableConsoleIntercept,
|
|
383
|
+
testEnvironment,
|
|
384
|
+
hookTimeout,
|
|
385
|
+
isolate,
|
|
386
|
+
coverage,
|
|
387
|
+
snapshotFormat,
|
|
388
|
+
env,
|
|
389
|
+
bail,
|
|
390
|
+
logHeapUsage,
|
|
391
|
+
chaiConfig,
|
|
392
|
+
includeTaskLocation,
|
|
393
|
+
} = project.normalizedConfig;
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
env,
|
|
397
|
+
testNamePattern,
|
|
398
|
+
testTimeout,
|
|
399
|
+
hookTimeout,
|
|
400
|
+
passWithNoTests,
|
|
401
|
+
retry,
|
|
402
|
+
globals,
|
|
403
|
+
clearMocks,
|
|
404
|
+
resetMocks,
|
|
405
|
+
restoreMocks,
|
|
406
|
+
unstubEnvs,
|
|
407
|
+
unstubGlobals,
|
|
408
|
+
maxConcurrency,
|
|
409
|
+
printConsoleTrace,
|
|
410
|
+
disableConsoleIntercept,
|
|
411
|
+
testEnvironment,
|
|
412
|
+
isolate,
|
|
413
|
+
coverage,
|
|
414
|
+
snapshotFormat,
|
|
415
|
+
bail,
|
|
416
|
+
logHeapUsage,
|
|
417
|
+
chaiConfig,
|
|
418
|
+
includeTaskLocation,
|
|
419
|
+
};
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const getBrowserProjects = (context: Rstest): ProjectContext[] => {
|
|
423
|
+
return context.projects.filter(
|
|
424
|
+
(project) => project.normalizedConfig.browser.enabled,
|
|
425
|
+
);
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const collectProjectEntries = async (
|
|
429
|
+
context: Rstest,
|
|
430
|
+
): Promise<BrowserProjectEntries[]> => {
|
|
431
|
+
const projectEntries: BrowserProjectEntries[] = [];
|
|
432
|
+
|
|
433
|
+
// Only collect entries for browser mode projects
|
|
434
|
+
const browserProjects = getBrowserProjects(context);
|
|
435
|
+
|
|
436
|
+
for (const project of browserProjects) {
|
|
437
|
+
const {
|
|
438
|
+
normalizedConfig: { include, exclude, includeSource, setupFiles },
|
|
439
|
+
} = project;
|
|
440
|
+
|
|
441
|
+
const tests = await getTestEntries({
|
|
442
|
+
include,
|
|
443
|
+
exclude: exclude.patterns,
|
|
444
|
+
includeSource,
|
|
445
|
+
rootPath: context.rootPath,
|
|
446
|
+
projectRoot: project.rootPath,
|
|
447
|
+
fileFilters: context.fileFilters || [],
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
const setup = getSetupFiles(setupFiles, project.rootPath);
|
|
451
|
+
|
|
452
|
+
projectEntries.push({
|
|
453
|
+
project,
|
|
454
|
+
setupFiles: Object.values(setup),
|
|
455
|
+
testFiles: Object.values(tests),
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return projectEntries;
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const resolveBrowserFile = (relativePath: string): string => {
|
|
463
|
+
// __dirname points to packages/browser/dist when running from built code
|
|
464
|
+
// or packages/browser/src when running from source
|
|
465
|
+
const candidates = [
|
|
466
|
+
// When running from built dist: look in ../src for source files
|
|
467
|
+
resolve(__dirname, '../src', relativePath),
|
|
468
|
+
// When running from source (dev mode)
|
|
469
|
+
resolve(__dirname, relativePath),
|
|
470
|
+
];
|
|
471
|
+
|
|
472
|
+
for (const candidate of candidates) {
|
|
473
|
+
if (existsSync(candidate)) {
|
|
474
|
+
return candidate;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
throw new Error(`Unable to resolve browser client file: ${relativePath}`);
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const resolveContainerDist = (): string => {
|
|
482
|
+
// When running from built dist: browser-container is in the same dist folder
|
|
483
|
+
const distPath = resolve(__dirname, 'browser-container');
|
|
484
|
+
if (existsSync(distPath)) {
|
|
485
|
+
return distPath;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
throw new Error(
|
|
489
|
+
`Browser container build not found at ${distPath}. Please run "pnpm --filter @rstest/browser build".`,
|
|
490
|
+
);
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
// ============================================================================
|
|
494
|
+
// Manifest Generation
|
|
495
|
+
// ============================================================================
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Format environment name to a valid JavaScript identifier.
|
|
499
|
+
* Replaces non-alphanumeric characters with underscores.
|
|
500
|
+
*/
|
|
501
|
+
const toSafeVarName = (name: string): string => {
|
|
502
|
+
return name.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
const generateManifestModule = ({
|
|
506
|
+
manifestPath,
|
|
507
|
+
entries,
|
|
508
|
+
}: {
|
|
509
|
+
manifestPath: string;
|
|
510
|
+
entries: BrowserProjectEntries[];
|
|
511
|
+
}): string => {
|
|
512
|
+
const manifestDirPosix = normalize(dirname(manifestPath));
|
|
513
|
+
|
|
514
|
+
const toRelativeImport = (filePath: string): string => {
|
|
515
|
+
const posixPath = normalize(filePath);
|
|
516
|
+
let relativePath = relative(manifestDirPosix, posixPath);
|
|
517
|
+
if (!relativePath.startsWith('.')) {
|
|
518
|
+
relativePath = `./${relativePath}`;
|
|
519
|
+
}
|
|
520
|
+
return relativePath;
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const lines: string[] = [];
|
|
524
|
+
|
|
525
|
+
// 1. Export all projects configuration
|
|
526
|
+
lines.push('// All projects configuration');
|
|
527
|
+
lines.push('export const projects = [');
|
|
528
|
+
for (const { project } of entries) {
|
|
529
|
+
lines.push(' {');
|
|
530
|
+
lines.push(` name: ${JSON.stringify(project.name)},`);
|
|
531
|
+
lines.push(
|
|
532
|
+
` environmentName: ${JSON.stringify(project.environmentName)},`,
|
|
533
|
+
);
|
|
534
|
+
lines.push(
|
|
535
|
+
` projectRoot: ${JSON.stringify(normalize(project.rootPath))},`,
|
|
536
|
+
);
|
|
537
|
+
lines.push(' },');
|
|
538
|
+
}
|
|
539
|
+
lines.push('];');
|
|
540
|
+
lines.push('');
|
|
541
|
+
|
|
542
|
+
// 2. Setup loaders for each project
|
|
543
|
+
lines.push('// Setup loaders for each project');
|
|
544
|
+
lines.push('export const projectSetupLoaders = {');
|
|
545
|
+
for (const { project, setupFiles } of entries) {
|
|
546
|
+
lines.push(` ${JSON.stringify(project.name)}: [`);
|
|
547
|
+
for (const filePath of setupFiles) {
|
|
548
|
+
const relativePath = toRelativeImport(filePath);
|
|
549
|
+
lines.push(` () => import(${JSON.stringify(relativePath)}),`);
|
|
550
|
+
}
|
|
551
|
+
lines.push(' ],');
|
|
552
|
+
}
|
|
553
|
+
lines.push('};');
|
|
554
|
+
lines.push('');
|
|
555
|
+
|
|
556
|
+
// 3. Test context for each project
|
|
557
|
+
lines.push('// Test context for each project');
|
|
558
|
+
for (const { project } of entries) {
|
|
559
|
+
const varName = `context_${toSafeVarName(project.environmentName)}`;
|
|
560
|
+
const projectRootPosix = normalize(project.rootPath);
|
|
561
|
+
const includeRegExp = globPatternsToRegExp(
|
|
562
|
+
project.normalizedConfig.include,
|
|
563
|
+
);
|
|
564
|
+
const excludePatterns = project.normalizedConfig.exclude.patterns;
|
|
565
|
+
const excludeRegExp = excludePatternsToRegExp(excludePatterns);
|
|
566
|
+
|
|
567
|
+
lines.push(
|
|
568
|
+
`const ${varName} = import.meta.webpackContext(${JSON.stringify(projectRootPosix)}, {`,
|
|
569
|
+
);
|
|
570
|
+
lines.push(' recursive: true,');
|
|
571
|
+
lines.push(` regExp: ${includeRegExp.toString()},`);
|
|
572
|
+
if (excludeRegExp) {
|
|
573
|
+
lines.push(` exclude: ${excludeRegExp.toString()},`);
|
|
574
|
+
}
|
|
575
|
+
lines.push(" mode: 'lazy',");
|
|
576
|
+
lines.push('});');
|
|
577
|
+
lines.push('');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// 4. Export test contexts object
|
|
581
|
+
lines.push('export const projectTestContexts = {');
|
|
582
|
+
for (const { project } of entries) {
|
|
583
|
+
const varName = `context_${toSafeVarName(project.environmentName)}`;
|
|
584
|
+
lines.push(` ${JSON.stringify(project.name)}: {`);
|
|
585
|
+
lines.push(` getTestKeys: () => ${varName}.keys(),`);
|
|
586
|
+
lines.push(` loadTest: (key) => ${varName}(key),`);
|
|
587
|
+
lines.push(
|
|
588
|
+
` projectRoot: ${JSON.stringify(normalize(project.rootPath))},`,
|
|
589
|
+
);
|
|
590
|
+
lines.push(' },');
|
|
591
|
+
}
|
|
592
|
+
lines.push('};');
|
|
593
|
+
lines.push('');
|
|
594
|
+
|
|
595
|
+
// 5. Backward compatibility exports (use first project as default)
|
|
596
|
+
lines.push('// Backward compatibility: export first project as default');
|
|
597
|
+
lines.push('export const projectConfig = projects[0];');
|
|
598
|
+
lines.push(
|
|
599
|
+
'export const setupLoaders = projectSetupLoaders[projects[0].name] || [];',
|
|
600
|
+
);
|
|
601
|
+
lines.push('const _defaultCtx = projectTestContexts[projects[0].name];');
|
|
602
|
+
lines.push(
|
|
603
|
+
'export const getTestKeys = () => _defaultCtx ? _defaultCtx.getTestKeys() : [];',
|
|
604
|
+
);
|
|
605
|
+
lines.push(
|
|
606
|
+
'export const loadTest = (key) => _defaultCtx ? _defaultCtx.loadTest(key) : Promise.reject(new Error("No project found"));',
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
return `${lines.join('\n')}\n`;
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
const htmlTemplate = `<!DOCTYPE html>
|
|
613
|
+
<html lang="en">
|
|
614
|
+
<head>
|
|
615
|
+
<meta charset="UTF-8" />
|
|
616
|
+
<title>Rstest Browser Runner</title>
|
|
617
|
+
</head>
|
|
618
|
+
<body>
|
|
619
|
+
<script type="module" src="/static/js/runner.js"></script>
|
|
620
|
+
</body>
|
|
621
|
+
</html>
|
|
622
|
+
`;
|
|
623
|
+
|
|
624
|
+
// ============================================================================
|
|
625
|
+
// Browser Runtime Lifecycle
|
|
626
|
+
// ============================================================================
|
|
627
|
+
|
|
628
|
+
const destroyBrowserRuntime = async (
|
|
629
|
+
runtime: BrowserRuntime,
|
|
630
|
+
): Promise<void> => {
|
|
631
|
+
try {
|
|
632
|
+
await runtime.browser?.close?.();
|
|
633
|
+
} catch {
|
|
634
|
+
// ignore
|
|
635
|
+
}
|
|
636
|
+
try {
|
|
637
|
+
await runtime.devServer?.close?.();
|
|
638
|
+
} catch {
|
|
639
|
+
// ignore
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
runtime.wss?.close();
|
|
643
|
+
} catch {
|
|
644
|
+
// ignore
|
|
645
|
+
}
|
|
646
|
+
await fs
|
|
647
|
+
.rm(runtime.tempDir, { recursive: true, force: true })
|
|
648
|
+
.catch(() => {});
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
const registerWatchCleanup = (): void => {
|
|
652
|
+
if (watchContext.cleanupRegistered) {
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const cleanup = async () => {
|
|
657
|
+
if (!watchContext.runtime) {
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
await destroyBrowserRuntime(watchContext.runtime);
|
|
661
|
+
watchContext.runtime = null;
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
665
|
+
process.once(signal, () => {
|
|
666
|
+
void cleanup();
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
process.once('exit', () => {
|
|
671
|
+
void cleanup();
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
watchContext.cleanupRegistered = true;
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
const createBrowserRuntime = async ({
|
|
678
|
+
context,
|
|
679
|
+
manifestPath,
|
|
680
|
+
manifestSource,
|
|
681
|
+
tempDir,
|
|
682
|
+
isWatchMode,
|
|
683
|
+
onTriggerRerun,
|
|
684
|
+
containerDistPath,
|
|
685
|
+
containerDevServer,
|
|
686
|
+
forceHeadless,
|
|
687
|
+
}: {
|
|
688
|
+
context: Rstest;
|
|
689
|
+
manifestPath: string;
|
|
690
|
+
manifestSource: string;
|
|
691
|
+
tempDir: string;
|
|
692
|
+
isWatchMode: boolean;
|
|
693
|
+
onTriggerRerun?: () => Promise<void>;
|
|
694
|
+
containerDistPath?: string;
|
|
695
|
+
containerDevServer?: string;
|
|
696
|
+
/** Force headless mode regardless of user config (used for list command) */
|
|
697
|
+
forceHeadless?: boolean;
|
|
698
|
+
}): Promise<BrowserRuntime> => {
|
|
699
|
+
const virtualManifestPlugin = new rspack.experiments.VirtualModulesPlugin({
|
|
700
|
+
[manifestPath]: manifestSource,
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
const optionsPlaceholder = '__RSTEST_OPTIONS_PLACEHOLDER__';
|
|
704
|
+
const containerHtmlTemplate = containerDistPath
|
|
705
|
+
? await fs.readFile(join(containerDistPath, 'container.html'), 'utf-8')
|
|
706
|
+
: null;
|
|
707
|
+
|
|
708
|
+
let injectedContainerHtml: string | null = null;
|
|
709
|
+
let serializedOptions = 'null';
|
|
710
|
+
|
|
711
|
+
const setContainerOptions = (options: BrowserHostConfig): void => {
|
|
712
|
+
serializedOptions = JSON.stringify(options).replace(/</g, '\\u003c');
|
|
713
|
+
if (containerHtmlTemplate) {
|
|
714
|
+
injectedContainerHtml = containerHtmlTemplate.replace(
|
|
715
|
+
optionsPlaceholder,
|
|
716
|
+
serializedOptions,
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
// Get user Rsbuild config from the first browser project
|
|
722
|
+
const browserProjects = getBrowserProjects(context);
|
|
723
|
+
const firstProject = browserProjects[0];
|
|
724
|
+
const userPlugins = firstProject?.normalizedConfig.plugins || [];
|
|
725
|
+
const userRsbuildConfig = firstProject?.normalizedConfig ?? {};
|
|
726
|
+
|
|
727
|
+
// Rstest internal aliases that must not be overridden by user config
|
|
728
|
+
const browserRuntimePath = fileURLToPath(
|
|
729
|
+
import.meta.resolve('@rstest/core/browser-runtime'),
|
|
730
|
+
);
|
|
731
|
+
|
|
732
|
+
const rstestInternalAliases = {
|
|
733
|
+
'@rstest/browser-manifest': manifestPath,
|
|
734
|
+
// User test code: import { describe, it } from '@rstest/core'
|
|
735
|
+
'@rstest/core': resolveBrowserFile('client/public.ts'),
|
|
736
|
+
// Browser runtime APIs for entry.ts and public.ts
|
|
737
|
+
// Uses dist file with extractSourceMap to preserve sourcemap chain for inline snapshots
|
|
738
|
+
'@rstest/core/browser-runtime': browserRuntimePath,
|
|
739
|
+
'@sinonjs/fake-timers': resolveBrowserFile('client/fakeTimersStub.ts'),
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
const rsbuildInstance = await createRsbuild({
|
|
743
|
+
callerName: 'rstest-browser',
|
|
744
|
+
rsbuildConfig: {
|
|
745
|
+
root: context.rootPath,
|
|
746
|
+
mode: 'development',
|
|
747
|
+
plugins: userPlugins,
|
|
748
|
+
server: {
|
|
749
|
+
printUrls: false,
|
|
750
|
+
port: context.normalizedConfig.browser.port,
|
|
751
|
+
strictPort: context.normalizedConfig.browser.port !== undefined,
|
|
752
|
+
},
|
|
753
|
+
dev: {
|
|
754
|
+
client: {
|
|
755
|
+
logLevel: 'error',
|
|
756
|
+
},
|
|
757
|
+
},
|
|
758
|
+
environments: {
|
|
759
|
+
web: {},
|
|
760
|
+
},
|
|
761
|
+
},
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
// Add plugin to merge user Rsbuild config with rstest required config
|
|
765
|
+
rsbuildInstance.addPlugins([
|
|
766
|
+
{
|
|
767
|
+
name: 'rstest:browser-user-config',
|
|
768
|
+
setup(api) {
|
|
769
|
+
api.modifyEnvironmentConfig((config, { mergeEnvironmentConfig }) => {
|
|
770
|
+
// Merge order: current config -> userConfig -> rstest required config (highest priority)
|
|
771
|
+
const merged = mergeEnvironmentConfig(config, userRsbuildConfig, {
|
|
772
|
+
source: {
|
|
773
|
+
entry: {
|
|
774
|
+
runner: resolveBrowserFile('client/entry.ts'),
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
resolve: {
|
|
778
|
+
alias: rstestInternalAliases,
|
|
779
|
+
},
|
|
780
|
+
output: {
|
|
781
|
+
target: 'web',
|
|
782
|
+
// Enable source map for inline snapshot support
|
|
783
|
+
sourceMap: {
|
|
784
|
+
js: 'source-map',
|
|
785
|
+
},
|
|
786
|
+
},
|
|
787
|
+
tools: {
|
|
788
|
+
rspack: (rspackConfig) => {
|
|
789
|
+
rspackConfig.mode = 'development';
|
|
790
|
+
rspackConfig.lazyCompilation = {
|
|
791
|
+
imports: true,
|
|
792
|
+
entries: false,
|
|
793
|
+
};
|
|
794
|
+
rspackConfig.plugins = rspackConfig.plugins || [];
|
|
795
|
+
rspackConfig.plugins.push(virtualManifestPlugin);
|
|
796
|
+
|
|
797
|
+
// Extract and merge sourcemaps from pre-built @rstest/core files
|
|
798
|
+
// This preserves the sourcemap chain for inline snapshot support
|
|
799
|
+
// See: https://rspack.dev/config/module-rules#rulesextractsourcemap
|
|
800
|
+
const browserRuntimeDir = dirname(browserRuntimePath);
|
|
801
|
+
rspackConfig.module = rspackConfig.module || {};
|
|
802
|
+
rspackConfig.module.rules = rspackConfig.module.rules || [];
|
|
803
|
+
rspackConfig.module.rules.unshift({
|
|
804
|
+
test: /\.js$/,
|
|
805
|
+
include: browserRuntimeDir,
|
|
806
|
+
extractSourceMap: true,
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
if (isDebug()) {
|
|
810
|
+
logger.log(
|
|
811
|
+
`[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`,
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
},
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
return merged;
|
|
819
|
+
});
|
|
820
|
+
},
|
|
821
|
+
},
|
|
822
|
+
]);
|
|
823
|
+
|
|
824
|
+
// Register watch plugin if in watch mode
|
|
825
|
+
if (isWatchMode && onTriggerRerun) {
|
|
826
|
+
rsbuildInstance.addPlugins([
|
|
827
|
+
{
|
|
828
|
+
name: 'rstest:browser-watch',
|
|
829
|
+
setup(api) {
|
|
830
|
+
api.onBeforeDevCompile(() => {
|
|
831
|
+
if (!watchContext.hooksEnabled) {
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
logger.log(color.cyan('\nFile changed, re-running tests...\n'));
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
api.onAfterDevCompile(async () => {
|
|
838
|
+
if (!watchContext.hooksEnabled) {
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
await onTriggerRerun();
|
|
842
|
+
});
|
|
843
|
+
},
|
|
844
|
+
},
|
|
845
|
+
]);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const devServer = await rsbuildInstance.createDevServer({
|
|
849
|
+
getPortSilently: true,
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
// Serve prebuilt container assets (SPA) via sirv
|
|
853
|
+
const serveContainer = containerDistPath
|
|
854
|
+
? sirv(containerDistPath, {
|
|
855
|
+
dev: false,
|
|
856
|
+
single: 'container.html',
|
|
857
|
+
})
|
|
858
|
+
: null;
|
|
859
|
+
|
|
860
|
+
const containerDevBase = containerDevServer
|
|
861
|
+
? new URL(containerDevServer)
|
|
862
|
+
: null;
|
|
863
|
+
|
|
864
|
+
const respondWithDevServerHtml = async (
|
|
865
|
+
url: URL,
|
|
866
|
+
res: ServerResponse,
|
|
867
|
+
): Promise<boolean> => {
|
|
868
|
+
if (!containerDevBase) {
|
|
869
|
+
return false;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
try {
|
|
873
|
+
const target = new URL(url.pathname + url.search, containerDevBase);
|
|
874
|
+
const response = await fetch(target);
|
|
875
|
+
if (!response.ok) {
|
|
876
|
+
return false;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
let html = await response.text();
|
|
880
|
+
html = html.replace(optionsPlaceholder, serializedOptions);
|
|
881
|
+
|
|
882
|
+
res.statusCode = response.status;
|
|
883
|
+
response.headers.forEach((value, key) => {
|
|
884
|
+
if (key.toLowerCase() === 'content-length') {
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
res.setHeader(key, value);
|
|
888
|
+
});
|
|
889
|
+
res.setHeader('Content-Type', 'text/html');
|
|
890
|
+
res.end(html);
|
|
891
|
+
return true;
|
|
892
|
+
} catch (error) {
|
|
893
|
+
logger.log(
|
|
894
|
+
color.yellow(
|
|
895
|
+
`[Browser UI] Failed to fetch container HTML from dev server: ${String(error)}`,
|
|
896
|
+
),
|
|
897
|
+
);
|
|
898
|
+
return false;
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
const proxyDevServerAsset = async (
|
|
903
|
+
req: IncomingMessage,
|
|
904
|
+
res: ServerResponse,
|
|
905
|
+
): Promise<boolean> => {
|
|
906
|
+
if (!containerDevBase || !req.url) {
|
|
907
|
+
return false;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
try {
|
|
911
|
+
const target = new URL(req.url, containerDevBase);
|
|
912
|
+
const response = await fetch(target);
|
|
913
|
+
if (!response.ok) {
|
|
914
|
+
return false;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
918
|
+
res.statusCode = response.status;
|
|
919
|
+
response.headers.forEach((value, key) => {
|
|
920
|
+
if (key.toLowerCase() === 'content-length') {
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
res.setHeader(key, value);
|
|
924
|
+
});
|
|
925
|
+
res.end(buffer);
|
|
926
|
+
return true;
|
|
927
|
+
} catch (error) {
|
|
928
|
+
logger.log(
|
|
929
|
+
color.yellow(
|
|
930
|
+
`[Browser UI] Failed to proxy asset from dev server: ${String(error)}`,
|
|
931
|
+
),
|
|
932
|
+
);
|
|
933
|
+
return false;
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
devServer.middlewares.use(async (req, res, next) => {
|
|
938
|
+
if (!req.url) {
|
|
939
|
+
next();
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
const url = new URL(req.url, 'http://localhost');
|
|
943
|
+
if (url.pathname === '/__open-in-editor') {
|
|
944
|
+
const file = url.searchParams.get('file');
|
|
945
|
+
if (!file) {
|
|
946
|
+
res.statusCode = 400;
|
|
947
|
+
res.end('Missing file');
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
try {
|
|
951
|
+
await openEditor([{ file }]);
|
|
952
|
+
res.statusCode = 204;
|
|
953
|
+
res.end();
|
|
954
|
+
} catch (error) {
|
|
955
|
+
logger.log(
|
|
956
|
+
color.yellow(`[Browser UI] Failed to open editor: ${String(error)}`),
|
|
957
|
+
);
|
|
958
|
+
res.statusCode = 500;
|
|
959
|
+
res.end('Failed to open editor');
|
|
960
|
+
}
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
if (url.pathname === '/' || url.pathname === '/container.html') {
|
|
964
|
+
if (await respondWithDevServerHtml(url, res)) {
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const html =
|
|
969
|
+
injectedContainerHtml ||
|
|
970
|
+
containerHtmlTemplate?.replace(optionsPlaceholder, 'null');
|
|
971
|
+
|
|
972
|
+
if (html) {
|
|
973
|
+
res.setHeader('Content-Type', 'text/html');
|
|
974
|
+
res.end(html);
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
res.statusCode = 502;
|
|
979
|
+
res.end('Container UI is not available.');
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
if (url.pathname.startsWith('/container-static/')) {
|
|
983
|
+
if (await proxyDevServerAsset(req, res)) {
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
if (serveContainer) {
|
|
988
|
+
serveContainer(req, res, next);
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
res.statusCode = 502;
|
|
993
|
+
res.end('Container assets are not available.');
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
if (url.pathname === '/runner.html') {
|
|
997
|
+
res.setHeader('Content-Type', 'text/html');
|
|
998
|
+
res.end(htmlTemplate);
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
next();
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
const { port } = await devServer.listen();
|
|
1005
|
+
|
|
1006
|
+
// Create WebSocket server on a different port
|
|
1007
|
+
const wsPort = port + 1;
|
|
1008
|
+
const wss = new WebSocketServer({ port: wsPort });
|
|
1009
|
+
logger.log(
|
|
1010
|
+
color.gray(`[Browser UI] WebSocket server started on port ${wsPort}`),
|
|
1011
|
+
);
|
|
1012
|
+
|
|
1013
|
+
let browserLauncher: BrowserType;
|
|
1014
|
+
const browserName = context.normalizedConfig.browser.browser;
|
|
1015
|
+
try {
|
|
1016
|
+
const playwright = await import('playwright');
|
|
1017
|
+
browserLauncher = playwright[browserName];
|
|
1018
|
+
} catch (_error) {
|
|
1019
|
+
wss.close();
|
|
1020
|
+
await devServer.close();
|
|
1021
|
+
throw _error;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
let browser: BrowserInstance;
|
|
1025
|
+
try {
|
|
1026
|
+
browser = await browserLauncher.launch({
|
|
1027
|
+
headless: forceHeadless ?? context.normalizedConfig.browser.headless,
|
|
1028
|
+
// Chromium-specific args (ignored by other browsers)
|
|
1029
|
+
args:
|
|
1030
|
+
browserName === 'chromium'
|
|
1031
|
+
? [
|
|
1032
|
+
'--disable-popup-blocking',
|
|
1033
|
+
'--no-first-run',
|
|
1034
|
+
'--no-default-browser-check',
|
|
1035
|
+
]
|
|
1036
|
+
: undefined,
|
|
1037
|
+
});
|
|
1038
|
+
} catch (_error) {
|
|
1039
|
+
wss.close();
|
|
1040
|
+
await devServer.close();
|
|
1041
|
+
throw _error;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
return {
|
|
1045
|
+
rsbuildInstance,
|
|
1046
|
+
devServer,
|
|
1047
|
+
browser,
|
|
1048
|
+
port,
|
|
1049
|
+
wsPort,
|
|
1050
|
+
manifestPath,
|
|
1051
|
+
tempDir,
|
|
1052
|
+
manifestPlugin: virtualManifestPlugin,
|
|
1053
|
+
setContainerOptions,
|
|
1054
|
+
wss,
|
|
1055
|
+
};
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1058
|
+
// ============================================================================
|
|
1059
|
+
// Main Entry Point
|
|
1060
|
+
// ============================================================================
|
|
1061
|
+
|
|
1062
|
+
export const runBrowserController = async (context: Rstest): Promise<void> => {
|
|
1063
|
+
const buildStart = Date.now();
|
|
1064
|
+
const containerDevServerEnv = process.env.RSTEST_CONTAINER_DEV_SERVER;
|
|
1065
|
+
let containerDevServer: string | undefined;
|
|
1066
|
+
let containerDistPath: string | undefined;
|
|
1067
|
+
|
|
1068
|
+
if (containerDevServerEnv) {
|
|
1069
|
+
try {
|
|
1070
|
+
containerDevServer = new URL(containerDevServerEnv).toString();
|
|
1071
|
+
logger.log(
|
|
1072
|
+
color.gray(
|
|
1073
|
+
`[Browser UI] Using dev server for container: ${containerDevServer}`,
|
|
1074
|
+
),
|
|
1075
|
+
);
|
|
1076
|
+
} catch (error) {
|
|
1077
|
+
logger.error(
|
|
1078
|
+
color.red(
|
|
1079
|
+
`Invalid RSTEST_CONTAINER_DEV_SERVER value: ${String(error)}`,
|
|
1080
|
+
),
|
|
1081
|
+
);
|
|
1082
|
+
ensureProcessExitCode(1);
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
if (!containerDevServer) {
|
|
1088
|
+
try {
|
|
1089
|
+
containerDistPath = resolveContainerDist();
|
|
1090
|
+
} catch (error) {
|
|
1091
|
+
logger.error(color.red(String(error)));
|
|
1092
|
+
ensureProcessExitCode(1);
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
const projectEntries = await collectProjectEntries(context);
|
|
1098
|
+
const totalTests = projectEntries.reduce(
|
|
1099
|
+
(total, item) => total + item.testFiles.length,
|
|
1100
|
+
0,
|
|
1101
|
+
);
|
|
1102
|
+
|
|
1103
|
+
if (totalTests === 0) {
|
|
1104
|
+
const code = context.normalizedConfig.passWithNoTests ? 0 : 1;
|
|
1105
|
+
logger.log(
|
|
1106
|
+
color[code ? 'red' : 'yellow'](
|
|
1107
|
+
`No test files found, exiting with code ${code}.`,
|
|
1108
|
+
),
|
|
1109
|
+
);
|
|
1110
|
+
if (code !== 0) {
|
|
1111
|
+
ensureProcessExitCode(code);
|
|
1112
|
+
}
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const isWatchMode = context.command === 'watch';
|
|
1117
|
+
const tempDir =
|
|
1118
|
+
isWatchMode && watchContext.runtime
|
|
1119
|
+
? watchContext.runtime.tempDir
|
|
1120
|
+
: isWatchMode
|
|
1121
|
+
? join(context.rootPath, TEMP_RSTEST_OUTPUT_DIR, 'browser', 'watch')
|
|
1122
|
+
: join(
|
|
1123
|
+
context.rootPath,
|
|
1124
|
+
TEMP_RSTEST_OUTPUT_DIR,
|
|
1125
|
+
'browser',
|
|
1126
|
+
Date.now().toString(),
|
|
1127
|
+
);
|
|
1128
|
+
const manifestPath = join(tempDir, 'manifest.ts');
|
|
1129
|
+
|
|
1130
|
+
const manifestSource = generateManifestModule({
|
|
1131
|
+
manifestPath,
|
|
1132
|
+
entries: projectEntries,
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
// Track initial test files for watch mode
|
|
1136
|
+
if (isWatchMode) {
|
|
1137
|
+
watchContext.lastTestFiles = projectEntries.flatMap((entry) =>
|
|
1138
|
+
entry.testFiles.map((testPath) => ({
|
|
1139
|
+
testPath,
|
|
1140
|
+
projectName: entry.project.name,
|
|
1141
|
+
})),
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
let runtime = isWatchMode ? watchContext.runtime : null;
|
|
1146
|
+
|
|
1147
|
+
// Define rerun callback for watch mode (will be populated later)
|
|
1148
|
+
let triggerRerun: (() => Promise<void>) | undefined;
|
|
1149
|
+
|
|
1150
|
+
if (!runtime) {
|
|
1151
|
+
try {
|
|
1152
|
+
runtime = await createBrowserRuntime({
|
|
1153
|
+
context,
|
|
1154
|
+
manifestPath,
|
|
1155
|
+
manifestSource,
|
|
1156
|
+
tempDir,
|
|
1157
|
+
isWatchMode,
|
|
1158
|
+
onTriggerRerun: isWatchMode
|
|
1159
|
+
? async () => {
|
|
1160
|
+
await triggerRerun?.();
|
|
1161
|
+
}
|
|
1162
|
+
: undefined,
|
|
1163
|
+
containerDistPath,
|
|
1164
|
+
containerDevServer,
|
|
1165
|
+
});
|
|
1166
|
+
} catch (error) {
|
|
1167
|
+
logger.error(error instanceof Error ? error : new Error(String(error)));
|
|
1168
|
+
ensureProcessExitCode(1);
|
|
1169
|
+
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
if (isWatchMode) {
|
|
1174
|
+
watchContext.runtime = runtime;
|
|
1175
|
+
registerWatchCleanup();
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
const { browser, port, wsPort, wss } = runtime;
|
|
1180
|
+
const buildTime = Date.now() - buildStart;
|
|
1181
|
+
|
|
1182
|
+
// Collect all test files from project entries with project info
|
|
1183
|
+
// Normalize paths to posix format for cross-platform compatibility
|
|
1184
|
+
const allTestFiles: TestFileInfo[] = projectEntries.flatMap((entry) =>
|
|
1185
|
+
entry.testFiles.map((testPath) => ({
|
|
1186
|
+
testPath: normalize(testPath),
|
|
1187
|
+
projectName: entry.project.name,
|
|
1188
|
+
})),
|
|
1189
|
+
);
|
|
1190
|
+
|
|
1191
|
+
// Only include browser mode projects in runtime configs
|
|
1192
|
+
// Normalize projectRoot to posix format for cross-platform compatibility
|
|
1193
|
+
const browserProjectsForRuntime = getBrowserProjects(context);
|
|
1194
|
+
const projectRuntimeConfigs: BrowserProjectRuntime[] =
|
|
1195
|
+
browserProjectsForRuntime.map((project: ProjectContext) => ({
|
|
1196
|
+
name: project.name,
|
|
1197
|
+
environmentName: project.environmentName,
|
|
1198
|
+
projectRoot: normalize(project.rootPath),
|
|
1199
|
+
runtimeConfig: serializableConfig(getRuntimeConfigFromProject(project)),
|
|
1200
|
+
}));
|
|
1201
|
+
|
|
1202
|
+
// Get max testTimeout from all browser projects for RPC timeout
|
|
1203
|
+
const maxTestTimeoutForRpc = Math.max(
|
|
1204
|
+
...browserProjectsForRuntime.map(
|
|
1205
|
+
(p) => p.normalizedConfig.testTimeout ?? 5000,
|
|
1206
|
+
),
|
|
1207
|
+
);
|
|
1208
|
+
|
|
1209
|
+
const hostOptions: BrowserHostConfig = {
|
|
1210
|
+
rootPath: normalize(context.rootPath),
|
|
1211
|
+
projects: projectRuntimeConfigs,
|
|
1212
|
+
snapshot: {
|
|
1213
|
+
updateSnapshot: context.snapshotManager.options.updateSnapshot,
|
|
1214
|
+
},
|
|
1215
|
+
runnerUrl: `http://localhost:${port}`,
|
|
1216
|
+
wsPort,
|
|
1217
|
+
debug: isDebug(),
|
|
1218
|
+
rpcTimeout: maxTestTimeoutForRpc,
|
|
1219
|
+
};
|
|
1220
|
+
|
|
1221
|
+
runtime.setContainerOptions(hostOptions);
|
|
1222
|
+
|
|
1223
|
+
// Track test results from iframes
|
|
1224
|
+
const reporterResults: TestFileResult[] = [];
|
|
1225
|
+
const caseResults: TestResult[] = [];
|
|
1226
|
+
let completedTests = 0;
|
|
1227
|
+
let fatalError: Error | null = null;
|
|
1228
|
+
|
|
1229
|
+
// Promise that resolves when all tests complete
|
|
1230
|
+
let resolveAllTests: (() => void) | undefined;
|
|
1231
|
+
const allTestsPromise = new Promise<void>((resolve) => {
|
|
1232
|
+
resolveAllTests = resolve;
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
// Open a container page for user to view (reuse in watch mode)
|
|
1236
|
+
let containerContext: BrowserContext;
|
|
1237
|
+
let containerPage: Page;
|
|
1238
|
+
let isNewPage = false;
|
|
1239
|
+
|
|
1240
|
+
if (isWatchMode && runtime.containerPage && runtime.containerContext) {
|
|
1241
|
+
containerContext = runtime.containerContext;
|
|
1242
|
+
containerPage = runtime.containerPage;
|
|
1243
|
+
logger.log(color.gray('\n[Watch] Reusing existing container page\n'));
|
|
1244
|
+
} else {
|
|
1245
|
+
isNewPage = true;
|
|
1246
|
+
containerContext = await browser.newContext({
|
|
1247
|
+
viewport: null,
|
|
1248
|
+
});
|
|
1249
|
+
containerPage = await containerContext.newPage();
|
|
1250
|
+
|
|
1251
|
+
// Prevent popup windows from being created
|
|
1252
|
+
containerPage.on('popup', async (popup: Page) => {
|
|
1253
|
+
await popup.close().catch(() => {});
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
containerContext.on('page', async (page: Page) => {
|
|
1257
|
+
if (page !== containerPage) {
|
|
1258
|
+
await page.close().catch(() => {});
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
if (isWatchMode) {
|
|
1263
|
+
runtime.containerPage = containerPage;
|
|
1264
|
+
runtime.containerContext = containerContext;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// Forward browser console to terminal
|
|
1268
|
+
containerPage.on('console', (msg: ConsoleMessage) => {
|
|
1269
|
+
const text = msg.text();
|
|
1270
|
+
if (text.includes('[Container]') || text.includes('[Runner]')) {
|
|
1271
|
+
logger.log(color.gray(`[Browser Console] ${text}`));
|
|
1272
|
+
}
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// Create RPC methods that can access test state variables
|
|
1277
|
+
const createRpcMethods = (): HostRpcMethods => ({
|
|
1278
|
+
async rerunTest(testFile: string, testNamePattern?: string) {
|
|
1279
|
+
logger.log(
|
|
1280
|
+
color.cyan(
|
|
1281
|
+
`\nRe-running test: ${testFile}${testNamePattern ? ` (pattern: ${testNamePattern})` : ''}\n`,
|
|
1282
|
+
),
|
|
1283
|
+
);
|
|
1284
|
+
await rpcManager.reloadTestFile(testFile, testNamePattern);
|
|
1285
|
+
},
|
|
1286
|
+
async getTestFiles() {
|
|
1287
|
+
return allTestFiles;
|
|
1288
|
+
},
|
|
1289
|
+
async onTestFileStart(payload: TestFileStartPayload) {
|
|
1290
|
+
await Promise.all(
|
|
1291
|
+
context.reporters.map((reporter) =>
|
|
1292
|
+
(reporter as Reporter).onTestFileStart?.({
|
|
1293
|
+
testPath: payload.testPath,
|
|
1294
|
+
tests: [],
|
|
1295
|
+
}),
|
|
1296
|
+
),
|
|
1297
|
+
);
|
|
1298
|
+
},
|
|
1299
|
+
async onTestCaseResult(payload: TestResult) {
|
|
1300
|
+
caseResults.push(payload);
|
|
1301
|
+
await Promise.all(
|
|
1302
|
+
context.reporters.map((reporter) =>
|
|
1303
|
+
(reporter as Reporter).onTestCaseResult?.(payload),
|
|
1304
|
+
),
|
|
1305
|
+
);
|
|
1306
|
+
},
|
|
1307
|
+
async onTestFileComplete(payload: TestFileResult) {
|
|
1308
|
+
reporterResults.push(payload);
|
|
1309
|
+
if (payload.snapshotResult) {
|
|
1310
|
+
context.snapshotManager.add(payload.snapshotResult);
|
|
1311
|
+
}
|
|
1312
|
+
await Promise.all(
|
|
1313
|
+
context.reporters.map((reporter) =>
|
|
1314
|
+
(reporter as Reporter).onTestFileResult?.(payload),
|
|
1315
|
+
),
|
|
1316
|
+
);
|
|
1317
|
+
|
|
1318
|
+
completedTests++;
|
|
1319
|
+
if (completedTests >= allTestFiles.length && resolveAllTests) {
|
|
1320
|
+
resolveAllTests();
|
|
1321
|
+
}
|
|
1322
|
+
},
|
|
1323
|
+
async onLog(payload: LogPayload) {
|
|
1324
|
+
const log: UserConsoleLog = {
|
|
1325
|
+
content: payload.content,
|
|
1326
|
+
name: payload.level,
|
|
1327
|
+
testPath: payload.testPath,
|
|
1328
|
+
type: payload.type,
|
|
1329
|
+
trace: payload.trace,
|
|
1330
|
+
};
|
|
1331
|
+
|
|
1332
|
+
// Check onConsoleLog filter
|
|
1333
|
+
const shouldLog =
|
|
1334
|
+
context.normalizedConfig.onConsoleLog?.(log.content) ?? true;
|
|
1335
|
+
|
|
1336
|
+
if (shouldLog) {
|
|
1337
|
+
await Promise.all(
|
|
1338
|
+
context.reporters.map((reporter) =>
|
|
1339
|
+
(reporter as Reporter).onUserConsoleLog?.(log),
|
|
1340
|
+
),
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
},
|
|
1344
|
+
async onFatal(payload: FatalPayload) {
|
|
1345
|
+
fatalError = new Error(payload.message);
|
|
1346
|
+
fatalError.stack = payload.stack;
|
|
1347
|
+
if (resolveAllTests) {
|
|
1348
|
+
resolveAllTests();
|
|
1349
|
+
}
|
|
1350
|
+
},
|
|
1351
|
+
// Snapshot file operations
|
|
1352
|
+
async resolveSnapshotPath(testPath: string) {
|
|
1353
|
+
const snapExtension = '.snap';
|
|
1354
|
+
const resolver =
|
|
1355
|
+
context.normalizedConfig.resolveSnapshotPath ||
|
|
1356
|
+
// test/index.ts -> test/__snapshots__/index.ts.snap
|
|
1357
|
+
(() =>
|
|
1358
|
+
join(
|
|
1359
|
+
dirname(testPath),
|
|
1360
|
+
'__snapshots__',
|
|
1361
|
+
`${basename(testPath)}${snapExtension}`,
|
|
1362
|
+
));
|
|
1363
|
+
return resolver(testPath, snapExtension);
|
|
1364
|
+
},
|
|
1365
|
+
async readSnapshotFile(filepath: string) {
|
|
1366
|
+
try {
|
|
1367
|
+
return await fs.readFile(filepath, 'utf-8');
|
|
1368
|
+
} catch {
|
|
1369
|
+
return null;
|
|
1370
|
+
}
|
|
1371
|
+
},
|
|
1372
|
+
async saveSnapshotFile(filepath: string, content: string) {
|
|
1373
|
+
const dir = dirname(filepath);
|
|
1374
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1375
|
+
await fs.writeFile(filepath, content, 'utf-8');
|
|
1376
|
+
},
|
|
1377
|
+
async removeSnapshotFile(filepath: string) {
|
|
1378
|
+
try {
|
|
1379
|
+
await fs.unlink(filepath);
|
|
1380
|
+
} catch {
|
|
1381
|
+
// ignore if file doesn't exist
|
|
1382
|
+
}
|
|
1383
|
+
},
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
// Setup RPC manager
|
|
1387
|
+
let rpcManager: ContainerRpcManager;
|
|
1388
|
+
|
|
1389
|
+
if (isWatchMode && runtime.rpcManager) {
|
|
1390
|
+
rpcManager = runtime.rpcManager;
|
|
1391
|
+
// Update methods with new test state (caseResults, completedTests, etc.)
|
|
1392
|
+
rpcManager.updateMethods(createRpcMethods());
|
|
1393
|
+
// Reattach if we have an existing WebSocket
|
|
1394
|
+
const existingWs = rpcManager.currentWebSocket;
|
|
1395
|
+
if (existingWs) {
|
|
1396
|
+
rpcManager.reattach(existingWs);
|
|
1397
|
+
}
|
|
1398
|
+
} else {
|
|
1399
|
+
rpcManager = new ContainerRpcManager(wss, createRpcMethods());
|
|
1400
|
+
|
|
1401
|
+
if (isWatchMode) {
|
|
1402
|
+
runtime.rpcManager = rpcManager;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// Only navigate on first creation
|
|
1407
|
+
if (isNewPage) {
|
|
1408
|
+
await containerPage.goto(`http://localhost:${port}/container.html`, {
|
|
1409
|
+
waitUntil: 'load',
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
logger.log(
|
|
1413
|
+
color.cyan(
|
|
1414
|
+
`\nContainer page opened at http://localhost:${port}/container.html\n`,
|
|
1415
|
+
),
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Wait for all tests to complete
|
|
1420
|
+
// Calculate total timeout based on config: max testTimeout * file count + buffer
|
|
1421
|
+
const maxTestTimeout = Math.max(
|
|
1422
|
+
...browserProjectsForRuntime.map(
|
|
1423
|
+
(p) => p.normalizedConfig.testTimeout ?? 5000,
|
|
1424
|
+
),
|
|
1425
|
+
);
|
|
1426
|
+
const totalTimeoutMs = maxTestTimeout * allTestFiles.length + 30_000;
|
|
1427
|
+
|
|
1428
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
1429
|
+
const testTimeout = new Promise<void>((resolve) => {
|
|
1430
|
+
timeoutId = setTimeout(() => {
|
|
1431
|
+
logger.log(
|
|
1432
|
+
color.yellow(
|
|
1433
|
+
`\nTest execution timeout after ${totalTimeoutMs / 1000}s. ` +
|
|
1434
|
+
`Completed: ${completedTests}/${allTestFiles.length}\n`,
|
|
1435
|
+
),
|
|
1436
|
+
);
|
|
1437
|
+
resolve();
|
|
1438
|
+
}, totalTimeoutMs);
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
const testStart = Date.now();
|
|
1442
|
+
await Promise.race([allTestsPromise, testTimeout]);
|
|
1443
|
+
|
|
1444
|
+
if (timeoutId) {
|
|
1445
|
+
clearTimeout(timeoutId);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
const testTime = Date.now() - testStart;
|
|
1449
|
+
|
|
1450
|
+
// Define rerun logic for watch mode
|
|
1451
|
+
if (isWatchMode) {
|
|
1452
|
+
triggerRerun = async () => {
|
|
1453
|
+
const newProjectEntries = await collectProjectEntries(context);
|
|
1454
|
+
// Normalize paths to posix format for cross-platform compatibility
|
|
1455
|
+
const currentTestFiles: TestFileInfo[] = newProjectEntries.flatMap(
|
|
1456
|
+
(entry) =>
|
|
1457
|
+
entry.testFiles.map((testPath) => ({
|
|
1458
|
+
testPath: normalize(testPath),
|
|
1459
|
+
projectName: entry.project.name,
|
|
1460
|
+
})),
|
|
1461
|
+
);
|
|
1462
|
+
|
|
1463
|
+
// Compare test files by serializing to JSON for deep comparison
|
|
1464
|
+
const serialize = (files: TestFileInfo[]) =>
|
|
1465
|
+
JSON.stringify(
|
|
1466
|
+
files.map((f) => `${f.projectName}:${f.testPath}`).sort(),
|
|
1467
|
+
);
|
|
1468
|
+
|
|
1469
|
+
const filesChanged =
|
|
1470
|
+
serialize(currentTestFiles) !== serialize(watchContext.lastTestFiles);
|
|
1471
|
+
|
|
1472
|
+
if (filesChanged) {
|
|
1473
|
+
watchContext.lastTestFiles = currentTestFiles;
|
|
1474
|
+
await rpcManager.notifyTestFileUpdate(currentTestFiles);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
logger.log(color.cyan('Tests will be re-executed automatically\n'));
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
if (!isWatchMode) {
|
|
1482
|
+
await destroyBrowserRuntime(runtime);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
if (fatalError) {
|
|
1486
|
+
logger.error(
|
|
1487
|
+
color.red(`Browser test run failed: ${(fatalError as Error).message}`),
|
|
1488
|
+
);
|
|
1489
|
+
ensureProcessExitCode(1);
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
const duration = {
|
|
1494
|
+
totalTime: buildTime + testTime,
|
|
1495
|
+
buildTime,
|
|
1496
|
+
testTime,
|
|
1497
|
+
};
|
|
1498
|
+
|
|
1499
|
+
context.updateReporterResultState(reporterResults, caseResults);
|
|
1500
|
+
|
|
1501
|
+
const isFailure = reporterResults.some(
|
|
1502
|
+
(result: TestFileResult) => result.status === 'fail',
|
|
1503
|
+
);
|
|
1504
|
+
if (isFailure) {
|
|
1505
|
+
ensureProcessExitCode(1);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
for (const reporter of context.reporters) {
|
|
1509
|
+
await reporter.onTestRunEnd?.({
|
|
1510
|
+
results: context.reporterResults.results,
|
|
1511
|
+
testResults: context.reporterResults.testResults,
|
|
1512
|
+
duration,
|
|
1513
|
+
snapshotSummary: context.snapshotManager.summary,
|
|
1514
|
+
getSourcemap: async () => null,
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// Enable watch hooks AFTER initial test run to avoid duplicate runs
|
|
1519
|
+
if (isWatchMode && triggerRerun) {
|
|
1520
|
+
watchContext.hooksEnabled = true;
|
|
1521
|
+
logger.log(
|
|
1522
|
+
color.cyan('\nWatch mode enabled - will re-run tests on file changes\n'),
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
};
|
|
1526
|
+
|
|
1527
|
+
// ============================================================================
|
|
1528
|
+
// List Browser Tests
|
|
1529
|
+
// ============================================================================
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* Result from collecting browser tests.
|
|
1533
|
+
* This is the return type for listBrowserTests, designed for future extraction
|
|
1534
|
+
* to a separate browser package.
|
|
1535
|
+
*/
|
|
1536
|
+
export type ListBrowserTestsResult = {
|
|
1537
|
+
list: ListCommandResult[];
|
|
1538
|
+
close: () => Promise<void>;
|
|
1539
|
+
};
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* Collect test metadata from browser mode projects without running them.
|
|
1543
|
+
* This function creates a headless browser runtime, loads test files,
|
|
1544
|
+
* and collects their test structure (describe/test declarations).
|
|
1545
|
+
*/
|
|
1546
|
+
export const listBrowserTests = async (
|
|
1547
|
+
context: Rstest,
|
|
1548
|
+
): Promise<ListBrowserTestsResult> => {
|
|
1549
|
+
const projectEntries = await collectProjectEntries(context);
|
|
1550
|
+
const totalTests = projectEntries.reduce(
|
|
1551
|
+
(total, item) => total + item.testFiles.length,
|
|
1552
|
+
0,
|
|
1553
|
+
);
|
|
1554
|
+
|
|
1555
|
+
if (totalTests === 0) {
|
|
1556
|
+
return {
|
|
1557
|
+
list: [],
|
|
1558
|
+
close: async () => {},
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
const tempDir = join(
|
|
1563
|
+
context.rootPath,
|
|
1564
|
+
TEMP_RSTEST_OUTPUT_DIR,
|
|
1565
|
+
'browser',
|
|
1566
|
+
`list-${Date.now()}`,
|
|
1567
|
+
);
|
|
1568
|
+
const manifestPath = join(tempDir, 'manifest.ts');
|
|
1569
|
+
|
|
1570
|
+
const manifestSource = generateManifestModule({
|
|
1571
|
+
manifestPath,
|
|
1572
|
+
entries: projectEntries,
|
|
1573
|
+
});
|
|
1574
|
+
|
|
1575
|
+
// Create a simplified browser runtime for collect mode
|
|
1576
|
+
let runtime: BrowserRuntime;
|
|
1577
|
+
try {
|
|
1578
|
+
runtime = await createBrowserRuntime({
|
|
1579
|
+
context,
|
|
1580
|
+
manifestPath,
|
|
1581
|
+
manifestSource,
|
|
1582
|
+
tempDir,
|
|
1583
|
+
isWatchMode: false,
|
|
1584
|
+
containerDistPath: undefined,
|
|
1585
|
+
containerDevServer: undefined,
|
|
1586
|
+
forceHeadless: true, // Always use headless for list command
|
|
1587
|
+
});
|
|
1588
|
+
} catch (error) {
|
|
1589
|
+
logger.error(
|
|
1590
|
+
color.red(
|
|
1591
|
+
'Failed to load Playwright. Please install "playwright" to use browser mode.',
|
|
1592
|
+
),
|
|
1593
|
+
error,
|
|
1594
|
+
);
|
|
1595
|
+
throw error;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
const { browser, port } = runtime;
|
|
1599
|
+
|
|
1600
|
+
// Get browser projects for runtime config
|
|
1601
|
+
// Normalize projectRoot to posix format for cross-platform compatibility
|
|
1602
|
+
const browserProjects = getBrowserProjects(context);
|
|
1603
|
+
const projectRuntimeConfigs: BrowserProjectRuntime[] = browserProjects.map(
|
|
1604
|
+
(project: ProjectContext) => ({
|
|
1605
|
+
name: project.name,
|
|
1606
|
+
environmentName: project.environmentName,
|
|
1607
|
+
projectRoot: normalize(project.rootPath),
|
|
1608
|
+
runtimeConfig: serializableConfig(getRuntimeConfigFromProject(project)),
|
|
1609
|
+
}),
|
|
1610
|
+
);
|
|
1611
|
+
|
|
1612
|
+
// Get max testTimeout from all browser projects for RPC timeout
|
|
1613
|
+
const maxTestTimeoutForRpc = Math.max(
|
|
1614
|
+
...browserProjects.map((p) => p.normalizedConfig.testTimeout ?? 5000),
|
|
1615
|
+
);
|
|
1616
|
+
|
|
1617
|
+
const hostOptions: BrowserHostConfig = {
|
|
1618
|
+
rootPath: normalize(context.rootPath),
|
|
1619
|
+
projects: projectRuntimeConfigs,
|
|
1620
|
+
snapshot: {
|
|
1621
|
+
updateSnapshot: context.snapshotManager.options.updateSnapshot,
|
|
1622
|
+
},
|
|
1623
|
+
mode: 'collect', // Use collect mode
|
|
1624
|
+
debug: isDebug(),
|
|
1625
|
+
rpcTimeout: maxTestTimeoutForRpc,
|
|
1626
|
+
};
|
|
1627
|
+
|
|
1628
|
+
runtime.setContainerOptions(hostOptions);
|
|
1629
|
+
|
|
1630
|
+
// Collect results
|
|
1631
|
+
const collectResults: ListCommandResult[] = [];
|
|
1632
|
+
let fatalError: Error | null = null;
|
|
1633
|
+
let collectCompleted = false;
|
|
1634
|
+
|
|
1635
|
+
// Promise that resolves when collection is complete
|
|
1636
|
+
let resolveCollect: (() => void) | undefined;
|
|
1637
|
+
const collectPromise = new Promise<void>((resolve) => {
|
|
1638
|
+
resolveCollect = resolve;
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
// Create a headless page to run collection
|
|
1642
|
+
const browserContext = await browser.newContext({ viewport: null });
|
|
1643
|
+
const page = await browserContext.newPage();
|
|
1644
|
+
|
|
1645
|
+
// Expose dispatch function for browser client to send messages
|
|
1646
|
+
await page.exposeFunction(
|
|
1647
|
+
'__rstest_dispatch__',
|
|
1648
|
+
(message: { type: string; payload?: unknown }) => {
|
|
1649
|
+
switch (message.type) {
|
|
1650
|
+
case 'collect-result': {
|
|
1651
|
+
const payload = message.payload as {
|
|
1652
|
+
testPath: string;
|
|
1653
|
+
project: string;
|
|
1654
|
+
tests: Test[];
|
|
1655
|
+
};
|
|
1656
|
+
collectResults.push({
|
|
1657
|
+
testPath: payload.testPath,
|
|
1658
|
+
project: payload.project,
|
|
1659
|
+
tests: payload.tests,
|
|
1660
|
+
});
|
|
1661
|
+
break;
|
|
1662
|
+
}
|
|
1663
|
+
case 'collect-complete':
|
|
1664
|
+
collectCompleted = true;
|
|
1665
|
+
resolveCollect?.();
|
|
1666
|
+
break;
|
|
1667
|
+
case 'fatal': {
|
|
1668
|
+
const payload = message.payload as {
|
|
1669
|
+
message: string;
|
|
1670
|
+
stack?: string;
|
|
1671
|
+
};
|
|
1672
|
+
fatalError = new Error(payload.message);
|
|
1673
|
+
fatalError.stack = payload.stack;
|
|
1674
|
+
resolveCollect?.();
|
|
1675
|
+
break;
|
|
1676
|
+
}
|
|
1677
|
+
case 'ready':
|
|
1678
|
+
case 'log':
|
|
1679
|
+
// Ignore these messages during collection
|
|
1680
|
+
break;
|
|
1681
|
+
default:
|
|
1682
|
+
// Log unexpected messages for debugging
|
|
1683
|
+
logger.debug(`[List] Unexpected message: ${message.type}`);
|
|
1684
|
+
}
|
|
1685
|
+
},
|
|
1686
|
+
);
|
|
1687
|
+
|
|
1688
|
+
// Inject host options before navigation so the runner can access them
|
|
1689
|
+
const serializedOptions = JSON.stringify(hostOptions).replace(
|
|
1690
|
+
/</g,
|
|
1691
|
+
'\\u003c',
|
|
1692
|
+
);
|
|
1693
|
+
await page.addInitScript(
|
|
1694
|
+
`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
|
|
1695
|
+
);
|
|
1696
|
+
|
|
1697
|
+
// Navigate to runner page
|
|
1698
|
+
await page.goto(`http://localhost:${port}/runner.html`, {
|
|
1699
|
+
waitUntil: 'load',
|
|
1700
|
+
});
|
|
1701
|
+
|
|
1702
|
+
// Wait for collection to complete with timeout
|
|
1703
|
+
const timeoutMs = 30000;
|
|
1704
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
1705
|
+
const timeoutPromise = new Promise<void>((resolve) => {
|
|
1706
|
+
timeoutId = setTimeout(() => {
|
|
1707
|
+
if (!collectCompleted) {
|
|
1708
|
+
logger.warn(
|
|
1709
|
+
color.yellow(
|
|
1710
|
+
`[List] Browser test collection timed out after ${timeoutMs}ms`,
|
|
1711
|
+
),
|
|
1712
|
+
);
|
|
1713
|
+
}
|
|
1714
|
+
resolve();
|
|
1715
|
+
}, timeoutMs);
|
|
1716
|
+
});
|
|
1717
|
+
|
|
1718
|
+
await Promise.race([collectPromise, timeoutPromise]);
|
|
1719
|
+
|
|
1720
|
+
// Clear timeout to prevent Node.js from waiting for it
|
|
1721
|
+
if (timeoutId) {
|
|
1722
|
+
clearTimeout(timeoutId);
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// Cleanup
|
|
1726
|
+
const cleanup = async () => {
|
|
1727
|
+
try {
|
|
1728
|
+
await page.close();
|
|
1729
|
+
await browserContext.close();
|
|
1730
|
+
} catch {
|
|
1731
|
+
// ignore
|
|
1732
|
+
}
|
|
1733
|
+
await destroyBrowserRuntime(runtime);
|
|
1734
|
+
};
|
|
1735
|
+
|
|
1736
|
+
if (fatalError) {
|
|
1737
|
+
await cleanup();
|
|
1738
|
+
// Return error in the result format instead of throwing
|
|
1739
|
+
const errorResult: ListCommandResult = {
|
|
1740
|
+
testPath: '',
|
|
1741
|
+
project: '',
|
|
1742
|
+
tests: [],
|
|
1743
|
+
errors: [
|
|
1744
|
+
{
|
|
1745
|
+
name: 'BrowserCollectError',
|
|
1746
|
+
message: (fatalError as Error).message,
|
|
1747
|
+
stack: (fatalError as Error).stack,
|
|
1748
|
+
} as FormattedError,
|
|
1749
|
+
],
|
|
1750
|
+
};
|
|
1751
|
+
return {
|
|
1752
|
+
list: [errorResult],
|
|
1753
|
+
close: async () => {},
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
return {
|
|
1758
|
+
list: collectResults,
|
|
1759
|
+
close: cleanup,
|
|
1760
|
+
};
|
|
1761
|
+
};
|