@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.
@@ -0,0 +1,628 @@
1
+ import {
2
+ type ManifestProjectConfig,
3
+ type ManifestTestContext,
4
+ projectSetupLoaders,
5
+ // Multi-project APIs
6
+ projects,
7
+ projectTestContexts,
8
+ } from '@rstest/browser-manifest';
9
+ import type {
10
+ RunnerHooks,
11
+ RuntimeConfig,
12
+ WorkerState,
13
+ } from '@rstest/core/browser-runtime';
14
+ import {
15
+ createRstestRuntime,
16
+ globalApis,
17
+ setRealTimers,
18
+ } from '@rstest/core/browser-runtime';
19
+ import { normalize } from 'pathe';
20
+ import type {
21
+ BrowserClientMessage,
22
+ BrowserHostConfig,
23
+ BrowserProjectRuntime,
24
+ } from '../protocol';
25
+ import { BrowserSnapshotEnvironment } from './snapshot';
26
+ import {
27
+ findNewScriptUrl,
28
+ getScriptUrls,
29
+ preloadRunnerSourceMap,
30
+ preloadTestFileSourceMap,
31
+ } from './sourceMapSupport';
32
+
33
+ declare global {
34
+ interface Window {
35
+ __RSTEST_BROWSER_OPTIONS__?: BrowserHostConfig;
36
+ __rstest_dispatch__?: (message: BrowserClientMessage) => void;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Debug logger for browser client.
42
+ * Only logs when debug mode is enabled (DEBUG=rstest on server side).
43
+ */
44
+ const debugLog = (...args: unknown[]): void => {
45
+ if (window.__RSTEST_BROWSER_OPTIONS__?.debug) {
46
+ console.log(...args);
47
+ }
48
+ };
49
+
50
+ type GlobalWithProcess = typeof globalThis & {
51
+ global?: typeof globalThis;
52
+ process?: NodeJS.Process;
53
+ };
54
+
55
+ const REGEXP_FLAG_PREFIX = 'RSTEST_REGEXP:';
56
+
57
+ const unwrapRegex = (value: string): string | RegExp => {
58
+ if (value.startsWith(REGEXP_FLAG_PREFIX)) {
59
+ const raw = value.slice(REGEXP_FLAG_PREFIX.length);
60
+ const match = raw.match(/^\/(.+)\/([gimuy]*)$/);
61
+ if (match) {
62
+ const [, pattern, flags] = match;
63
+ return new RegExp(pattern!, flags);
64
+ }
65
+ }
66
+ return value;
67
+ };
68
+
69
+ const restoreRuntimeConfig = (
70
+ config: BrowserProjectRuntime['runtimeConfig'],
71
+ ): RuntimeConfig => {
72
+ const { testNamePattern } = config as RuntimeConfig;
73
+ return {
74
+ ...config,
75
+ testNamePattern:
76
+ typeof testNamePattern === 'string'
77
+ ? unwrapRegex(testNamePattern)
78
+ : testNamePattern,
79
+ };
80
+ };
81
+
82
+ const ensureProcessEnv = (env: RuntimeConfig['env'] | undefined): void => {
83
+ const globalRef = globalThis as GlobalWithProcess;
84
+ if (!globalRef.global) {
85
+ globalRef.global = globalRef;
86
+ }
87
+
88
+ if (!globalRef.process) {
89
+ const processShim: Partial<NodeJS.Process> & {
90
+ env: Record<string, string | undefined>;
91
+ } = {
92
+ env: {},
93
+ argv: [],
94
+ version: 'browser',
95
+ cwd: () => '/',
96
+ platform: 'linux',
97
+ nextTick: (cb: (...args: unknown[]) => void, ...args: unknown[]) =>
98
+ queueMicrotask(() => cb(...args)),
99
+ };
100
+
101
+ globalRef.process = processShim as unknown as NodeJS.Process;
102
+ }
103
+
104
+ globalRef.process.env ??= {};
105
+
106
+ if (env) {
107
+ for (const [key, value] of Object.entries(env)) {
108
+ if (value === undefined) {
109
+ delete globalRef.process.env[key];
110
+ } else {
111
+ globalRef.process.env[key] = value;
112
+ }
113
+ }
114
+ }
115
+ };
116
+
117
+ /**
118
+ * Format an argument for console output.
119
+ */
120
+ const formatArg = (arg: unknown): string => {
121
+ if (arg === null) return 'null';
122
+ if (arg === undefined) return 'undefined';
123
+ if (typeof arg === 'string') return arg;
124
+ if (typeof arg === 'number' || typeof arg === 'boolean') return String(arg);
125
+ if (arg instanceof Error) {
126
+ return arg.stack || `${arg.name}: ${arg.message}`;
127
+ }
128
+ try {
129
+ return JSON.stringify(arg, null, 2);
130
+ } catch {
131
+ return String(arg);
132
+ }
133
+ };
134
+
135
+ /**
136
+ * Intercept console methods and forward to host via send().
137
+ * Returns a restore function to revert console to original.
138
+ */
139
+ const interceptConsole = (
140
+ testPath: string,
141
+ printConsoleTrace: boolean,
142
+ disableConsoleIntercept: boolean,
143
+ ): (() => void) => {
144
+ if (disableConsoleIntercept) {
145
+ return () => {};
146
+ }
147
+
148
+ const originalConsole = {
149
+ log: console.log.bind(console),
150
+ warn: console.warn.bind(console),
151
+ error: console.error.bind(console),
152
+ info: console.info.bind(console),
153
+ debug: console.debug.bind(console),
154
+ };
155
+
156
+ const getConsoleTrace = (): string | undefined => {
157
+ if (!printConsoleTrace) return undefined;
158
+ const stack = new Error('STACK_TRACE').stack;
159
+ // Skip: Error, getConsoleTrace, createConsoleInterceptor wrapper, console.log call
160
+ return stack?.split('\n').slice(4).join('\n');
161
+ };
162
+
163
+ const createConsoleInterceptor = (
164
+ level: 'log' | 'warn' | 'error' | 'info' | 'debug',
165
+ ) => {
166
+ return (...args: unknown[]) => {
167
+ // Call original for browser DevTools
168
+ originalConsole[level](...args);
169
+
170
+ // Format message
171
+ const content = args.map(formatArg).join(' ');
172
+
173
+ // Send to host
174
+ send({
175
+ type: 'log',
176
+ payload: {
177
+ level,
178
+ content,
179
+ testPath,
180
+ type: level === 'error' || level === 'warn' ? 'stderr' : 'stdout',
181
+ trace: getConsoleTrace(),
182
+ },
183
+ });
184
+ };
185
+ };
186
+
187
+ console.log = createConsoleInterceptor('log');
188
+ console.warn = createConsoleInterceptor('warn');
189
+ console.error = createConsoleInterceptor('error');
190
+ console.info = createConsoleInterceptor('info');
191
+ console.debug = createConsoleInterceptor('debug');
192
+
193
+ return () => {
194
+ console.log = originalConsole.log;
195
+ console.warn = originalConsole.warn;
196
+ console.error = originalConsole.error;
197
+ console.info = originalConsole.info;
198
+ console.debug = originalConsole.debug;
199
+ };
200
+ };
201
+
202
+ const send = (message: BrowserClientMessage): void => {
203
+ // If in iframe, send to parent window (container) which will forward to host via RPC
204
+ if (window.parent !== window) {
205
+ window.parent.postMessage(
206
+ { type: '__rstest_dispatch__', payload: message },
207
+ '*',
208
+ );
209
+ return;
210
+ }
211
+ // Fallback: direct call if running outside iframe (not typical)
212
+ // Note: This binding may not exist if not using Playwright
213
+ window.__rstest_dispatch__?.(message);
214
+ };
215
+
216
+ /** Timeout for waiting for browser config from container (30 seconds) */
217
+ const CONFIG_WAIT_TIMEOUT_MS = 30_000;
218
+
219
+ /**
220
+ * Wait for configuration from container if running in iframe.
221
+ * This is a prerequisite for test execution - without config, tests cannot run.
222
+ */
223
+ const waitForConfig = (): Promise<void> => {
224
+ // If not in iframe or already has config, resolve immediately
225
+ if (window.parent === window || window.__RSTEST_BROWSER_OPTIONS__) {
226
+ return Promise.resolve();
227
+ }
228
+
229
+ return new Promise((resolve, reject) => {
230
+ const handleMessage = (event: MessageEvent) => {
231
+ if (event.data?.type === 'RSTEST_CONFIG') {
232
+ window.__RSTEST_BROWSER_OPTIONS__ = event.data.payload;
233
+ debugLog(
234
+ '[Runner] Received config from container:',
235
+ event.data.payload,
236
+ );
237
+ window.removeEventListener('message', handleMessage);
238
+ resolve();
239
+ }
240
+ };
241
+
242
+ window.addEventListener('message', handleMessage);
243
+
244
+ setTimeout(() => {
245
+ window.removeEventListener('message', handleMessage);
246
+ reject(
247
+ new Error(
248
+ `[Rstest] Failed to receive browser config within ${CONFIG_WAIT_TIMEOUT_MS / 1000}s. ` +
249
+ 'This may indicate a connection issue between the runner iframe and container.',
250
+ ),
251
+ );
252
+ }, CONFIG_WAIT_TIMEOUT_MS);
253
+ });
254
+ };
255
+
256
+ /**
257
+ * Convert absolute path to context key (relative path)
258
+ * e.g., '/project/src/foo.test.ts' -> './src/foo.test.ts'
259
+ * 'D:/project/src/foo.test.ts' -> './src/foo.test.ts'
260
+ *
261
+ * Uses pathe's normalize to handle cross-platform path separators.
262
+ */
263
+ const toContextKey = (absolutePath: string, projectRoot: string): string => {
264
+ // Normalize both paths to use forward slashes for cross-platform compatibility
265
+ const normalizedAbsolute = normalize(absolutePath);
266
+ const normalizedRoot = normalize(projectRoot);
267
+
268
+ let relative = normalizedAbsolute;
269
+ if (normalizedAbsolute.startsWith(normalizedRoot)) {
270
+ relative = normalizedAbsolute.slice(normalizedRoot.length);
271
+ }
272
+ return relative.startsWith('/') ? `.${relative}` : `./${relative}`;
273
+ };
274
+
275
+ /**
276
+ * Convert context key to absolute path
277
+ * e.g., './src/foo.test.ts' -> '/project/src/foo.test.ts'
278
+ */
279
+ const toAbsolutePath = (key: string, projectRoot: string): string => {
280
+ // key format: ./src/foo.test.ts
281
+ // Ensure no double slashes by removing trailing slash from projectRoot
282
+ const normalizedRoot = normalize(projectRoot).replace(/\/$/, '');
283
+ return normalizedRoot + key.slice(1);
284
+ };
285
+
286
+ /**
287
+ * Find the project that contains the given test file.
288
+ * Matches by checking if the testFile path starts with the project root.
289
+ *
290
+ * Uses pathe's normalize to handle cross-platform path separators.
291
+ */
292
+ const findProjectForTestFile = (
293
+ testFile: string,
294
+ allProjects: ManifestProjectConfig[],
295
+ ): ManifestProjectConfig | undefined => {
296
+ // Normalize the test file path for cross-platform compatibility
297
+ const normalizedTestFile = normalize(testFile);
298
+
299
+ // Sort projects by root path length (longest first) for most specific match
300
+ const sorted = [...allProjects].sort(
301
+ (a, b) => b.projectRoot.length - a.projectRoot.length,
302
+ );
303
+
304
+ for (const proj of sorted) {
305
+ // projectRoot should already be normalized, but normalize again for safety
306
+ const normalizedRoot = normalize(proj.projectRoot);
307
+ if (normalizedTestFile.startsWith(normalizedRoot)) {
308
+ return proj;
309
+ }
310
+ }
311
+
312
+ // Fallback to first project
313
+ return allProjects[0];
314
+ };
315
+
316
+ const run = async () => {
317
+ // Wait for configuration if in iframe
318
+ await waitForConfig();
319
+ let options = window.__RSTEST_BROWSER_OPTIONS__;
320
+
321
+ // Support reading testFile and testNamePattern from URL parameters
322
+ const urlParams = new URLSearchParams(window.location.search);
323
+ const urlTestFile = urlParams.get('testFile');
324
+ const urlTestNamePattern = urlParams.get('testNamePattern');
325
+
326
+ if (urlTestFile && options) {
327
+ // Override testFile from URL parameter
328
+ options = {
329
+ ...options,
330
+ testFile: urlTestFile,
331
+ };
332
+ }
333
+
334
+ // Override testNamePattern from URL parameter if provided
335
+ if (urlTestNamePattern && options) {
336
+ options = {
337
+ ...options,
338
+ projects: options.projects.map((project) => ({
339
+ ...project,
340
+ runtimeConfig: {
341
+ ...project.runtimeConfig,
342
+ testNamePattern: urlTestNamePattern,
343
+ },
344
+ })),
345
+ };
346
+ }
347
+
348
+ if (!options) {
349
+ send({
350
+ type: 'fatal',
351
+ payload: {
352
+ message: 'Browser test runtime is not configured.',
353
+ },
354
+ });
355
+ window.__RSTEST_DONE__ = true;
356
+ return;
357
+ }
358
+
359
+ send({ type: 'ready' });
360
+
361
+ setRealTimers();
362
+
363
+ // Preload runner.js sourcemap for inline snapshot support.
364
+ // The snapshot code runs in runner.js, so we need its sourcemap
365
+ // to map stack traces back to original source files.
366
+ await preloadRunnerSourceMap();
367
+
368
+ // Find the project for this test file
369
+ const targetTestFile = options.testFile;
370
+ const currentProject = targetTestFile
371
+ ? findProjectForTestFile(
372
+ targetTestFile,
373
+ projects as ManifestProjectConfig[],
374
+ )
375
+ : (projects as ManifestProjectConfig[])[0];
376
+
377
+ if (!currentProject) {
378
+ send({
379
+ type: 'fatal',
380
+ payload: {
381
+ message: 'No project found for test file',
382
+ },
383
+ });
384
+ window.__RSTEST_DONE__ = true;
385
+ return;
386
+ }
387
+
388
+ // Find the runtime config for this project
389
+ const projectRuntime = options.projects.find(
390
+ (p) => p.name === currentProject.name,
391
+ );
392
+ if (!projectRuntime) {
393
+ send({
394
+ type: 'fatal',
395
+ payload: {
396
+ message: `Project ${currentProject.name} not found in runtime options`,
397
+ },
398
+ });
399
+ window.__RSTEST_DONE__ = true;
400
+ return;
401
+ }
402
+
403
+ const runtimeConfig = restoreRuntimeConfig(projectRuntime.runtimeConfig);
404
+ ensureProcessEnv(runtimeConfig.env);
405
+
406
+ // Get this project's setup loaders and test context
407
+ const currentSetupLoaders =
408
+ (projectSetupLoaders as Record<string, Array<() => Promise<unknown>>>)[
409
+ currentProject.name
410
+ ] || [];
411
+ const currentTestContext = (
412
+ projectTestContexts as Record<string, ManifestTestContext>
413
+ )[currentProject.name];
414
+
415
+ if (!currentTestContext) {
416
+ send({
417
+ type: 'fatal',
418
+ payload: {
419
+ message: `Test context not found for project ${currentProject.name}`,
420
+ },
421
+ });
422
+ window.__RSTEST_DONE__ = true;
423
+ return;
424
+ }
425
+
426
+ // 1. Load setup files for this project
427
+ for (const loadSetup of currentSetupLoaders) {
428
+ await loadSetup();
429
+ }
430
+
431
+ // 2. Determine which test files to run
432
+ let testKeysToRun: string[];
433
+
434
+ if (targetTestFile) {
435
+ // Single file mode: convert absolute path to context key
436
+ const key = toContextKey(targetTestFile, currentProject.projectRoot);
437
+ testKeysToRun = [key];
438
+ } else {
439
+ // Full run mode: get all test keys from context
440
+ testKeysToRun = currentTestContext.getTestKeys();
441
+ }
442
+
443
+ // Check execution mode
444
+ const executionMode = options.mode || 'run';
445
+
446
+ // Collect mode: only gather test metadata without running
447
+ if (executionMode === 'collect') {
448
+ for (const key of testKeysToRun) {
449
+ const testPath = toAbsolutePath(key, currentProject.projectRoot);
450
+
451
+ const workerState: WorkerState = {
452
+ project: projectRuntime.name,
453
+ projectRoot: projectRuntime.projectRoot,
454
+ rootPath: options.rootPath,
455
+ runtimeConfig,
456
+ taskId: 0,
457
+ outputModule: false,
458
+ environment: 'browser',
459
+ testPath,
460
+ distPath: testPath,
461
+ snapshotOptions: {
462
+ updateSnapshot: options.snapshot.updateSnapshot,
463
+ snapshotEnvironment: new BrowserSnapshotEnvironment(),
464
+ snapshotFormat: runtimeConfig.snapshotFormat,
465
+ },
466
+ };
467
+
468
+ const runtime = await createRstestRuntime(workerState);
469
+
470
+ // Register global APIs if globals config is enabled
471
+ if (runtimeConfig.globals) {
472
+ for (const apiKey of globalApis) {
473
+ (globalThis as any)[apiKey] = (runtime.api as any)[apiKey];
474
+ }
475
+ }
476
+
477
+ try {
478
+ // Load the test file dynamically (registers tests without running)
479
+ await currentTestContext.loadTest(key);
480
+
481
+ // Collect tests metadata
482
+ const tests = await runtime.runner.collectTests();
483
+
484
+ send({
485
+ type: 'collect-result',
486
+ payload: {
487
+ testPath,
488
+ project: projectRuntime.name,
489
+ tests,
490
+ },
491
+ });
492
+ } catch (_error) {
493
+ const error =
494
+ _error instanceof Error ? _error : new Error(String(_error));
495
+ send({
496
+ type: 'fatal',
497
+ payload: {
498
+ message: error.message,
499
+ stack: error.stack,
500
+ },
501
+ });
502
+ window.__RSTEST_DONE__ = true;
503
+ return;
504
+ }
505
+ }
506
+
507
+ send({ type: 'collect-complete' });
508
+ window.__RSTEST_DONE__ = true;
509
+ return;
510
+ }
511
+
512
+ // 3. Run tests for each file
513
+ for (const key of testKeysToRun) {
514
+ const testPath = toAbsolutePath(key, currentProject.projectRoot);
515
+
516
+ // Intercept console methods to forward logs to host
517
+ const restoreConsole = interceptConsole(
518
+ testPath,
519
+ runtimeConfig.printConsoleTrace ?? false,
520
+ runtimeConfig.disableConsoleIntercept ?? false,
521
+ );
522
+
523
+ const workerState: WorkerState = {
524
+ project: projectRuntime.name,
525
+ projectRoot: projectRuntime.projectRoot,
526
+ rootPath: options.rootPath,
527
+ runtimeConfig,
528
+ taskId: 0,
529
+ outputModule: false,
530
+ environment: 'browser',
531
+ testPath,
532
+ distPath: testPath,
533
+ snapshotOptions: {
534
+ updateSnapshot: options.snapshot.updateSnapshot,
535
+ snapshotEnvironment: new BrowserSnapshotEnvironment(),
536
+ snapshotFormat: runtimeConfig.snapshotFormat,
537
+ },
538
+ };
539
+
540
+ const runtime = await createRstestRuntime(workerState);
541
+
542
+ // Register global APIs if globals config is enabled
543
+ if (runtimeConfig.globals) {
544
+ for (const apiKey of globalApis) {
545
+ (globalThis as any)[apiKey] = (runtime.api as any)[apiKey];
546
+ }
547
+ }
548
+
549
+ let failedTestsCount = 0;
550
+
551
+ const runnerHooks: RunnerHooks = {
552
+ onTestCaseResult: async (result) => {
553
+ if (result.status === 'fail') {
554
+ failedTestsCount++;
555
+ }
556
+ send({
557
+ type: 'case-result',
558
+ payload: result,
559
+ });
560
+ },
561
+ getCountOfFailedTests: async () => failedTestsCount,
562
+ };
563
+
564
+ send({
565
+ type: 'file-start',
566
+ payload: {
567
+ testPath,
568
+ projectName: projectRuntime.name,
569
+ },
570
+ });
571
+
572
+ try {
573
+ // Record script URLs before loading the test file
574
+ const beforeScripts = getScriptUrls();
575
+
576
+ // Load the test file dynamically using this project's context
577
+ await currentTestContext.loadTest(key);
578
+
579
+ // Find the newly loaded chunk and preload its source map (for inline snapshots)
580
+ const afterScripts = getScriptUrls();
581
+ const chunkUrl = findNewScriptUrl(beforeScripts, afterScripts);
582
+ if (chunkUrl) {
583
+ await preloadTestFileSourceMap(chunkUrl);
584
+ }
585
+
586
+ const result = await runtime.runner.runTests(
587
+ testPath,
588
+ runnerHooks,
589
+ runtime.api,
590
+ );
591
+
592
+ send({
593
+ type: 'file-complete',
594
+ payload: result,
595
+ });
596
+ } catch (_error) {
597
+ const error =
598
+ _error instanceof Error ? _error : new Error(String(_error));
599
+ send({
600
+ type: 'fatal',
601
+ payload: {
602
+ message: error.message,
603
+ stack: error.stack,
604
+ },
605
+ });
606
+ window.__RSTEST_DONE__ = true;
607
+ return;
608
+ } finally {
609
+ // Restore original console methods
610
+ restoreConsole();
611
+ }
612
+ }
613
+
614
+ send({ type: 'complete' });
615
+ window.__RSTEST_DONE__ = true;
616
+ };
617
+
618
+ void run().catch((error) => {
619
+ const err = error instanceof Error ? error : new Error(String(error));
620
+ send({
621
+ type: 'fatal',
622
+ payload: {
623
+ message: err.message,
624
+ stack: err.stack,
625
+ },
626
+ });
627
+ window.__RSTEST_DONE__ = true;
628
+ });
@@ -0,0 +1,91 @@
1
+ export type FakeTimerInstallOpts = Record<string, unknown>;
2
+
3
+ export type FakeTimerWithContext = {
4
+ timers: Record<string, unknown>;
5
+ };
6
+
7
+ export type InstalledClock = {
8
+ now: number;
9
+ reset: () => void;
10
+ uninstall: () => void;
11
+ runAll: () => void;
12
+ runAllAsync: () => Promise<void>;
13
+ runToLast: () => void;
14
+ runToLastAsync: () => Promise<void>;
15
+ tick: (ms: number) => void;
16
+ tickAsync: (ms: number) => Promise<void>;
17
+ next: () => void;
18
+ nextAsync: () => Promise<void>;
19
+ runToFrame: () => void;
20
+ runMicrotasks: () => void;
21
+ setSystemTime: (now?: number | Date) => void;
22
+ countTimers: () => number;
23
+ };
24
+
25
+ const createClock = (): InstalledClock => {
26
+ const clock: InstalledClock = {
27
+ now: Date.now(),
28
+ reset: () => {
29
+ clock.now = Date.now();
30
+ },
31
+ uninstall: () => {
32
+ /* noop */
33
+ },
34
+ runAll: () => {
35
+ /* noop */
36
+ },
37
+ runAllAsync: async () => {
38
+ /* noop */
39
+ },
40
+ runToLast: () => {
41
+ /* noop */
42
+ },
43
+ runToLastAsync: async () => {
44
+ /* noop */
45
+ },
46
+ tick: (ms: number) => {
47
+ clock.now += ms;
48
+ },
49
+ tickAsync: async (ms: number) => {
50
+ clock.now += ms;
51
+ },
52
+ next: () => {
53
+ /* noop */
54
+ },
55
+ nextAsync: async () => {
56
+ /* noop */
57
+ },
58
+ runToFrame: () => {
59
+ /* noop */
60
+ },
61
+ runMicrotasks: () => {
62
+ /* noop */
63
+ },
64
+ setSystemTime: (value?: number | Date) => {
65
+ if (typeof value === 'number') {
66
+ clock.now = value;
67
+ return;
68
+ }
69
+ if (value instanceof Date) {
70
+ clock.now = value.valueOf();
71
+ return;
72
+ }
73
+ clock.now = Date.now();
74
+ },
75
+ countTimers: () => 0,
76
+ };
77
+
78
+ return clock;
79
+ };
80
+
81
+ export const withGlobal = (_global: typeof globalThis) => {
82
+ const clock = createClock();
83
+
84
+ return {
85
+ timers: {},
86
+ install: (_config: FakeTimerInstallOpts = {}): InstalledClock => {
87
+ clock.now = Date.now();
88
+ return clock;
89
+ },
90
+ };
91
+ };