@rstest/browser 0.8.5 → 0.9.1
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/LICENSE-APACHE-2.0 +202 -0
- package/NOTICE +11 -0
- package/dist/361.js +8 -0
- package/dist/augmentExpect.d.ts +73 -0
- package/dist/browser-container/container-static/css/index.5c72297783.css +1 -0
- package/dist/browser-container/container-static/js/{565.226c9ef5.js → 101.36a8ccdf84.js} +4024 -3856
- package/dist/browser-container/container-static/js/101.36a8ccdf84.js.LICENSE.txt +1 -0
- package/dist/browser-container/container-static/js/{index.c1d17467.js → index.28d833de0b.js} +732 -675
- package/dist/browser-container/container-static/js/{lib-react.97ee79b0.js → lib-react.dcf2a5e57a.js} +10 -10
- package/dist/browser-container/container-static/js/lib-react.dcf2a5e57a.js.LICENSE.txt +1 -0
- package/dist/browser-container/index.html +1 -1
- package/dist/browser.d.ts +2 -0
- package/dist/browser.js +583 -0
- package/dist/browserRpcRegistry.d.ts +18 -0
- package/dist/client/api.d.ts +3 -0
- package/dist/client/browserRpc.d.ts +2 -0
- package/dist/client/dispatchTransport.d.ts +11 -0
- package/dist/client/entry.d.ts +1 -5
- package/dist/client/locator.d.ts +125 -0
- package/dist/client/snapshot.d.ts +0 -6
- package/dist/concurrency.d.ts +12 -0
- package/dist/dispatchCapabilities.d.ts +34 -0
- package/dist/dispatchRouter.d.ts +20 -0
- package/dist/headedSerialTaskQueue.d.ts +8 -0
- package/dist/headlessLatestRerunScheduler.d.ts +19 -0
- package/dist/headlessTransport.d.ts +12 -0
- package/dist/hostController.d.ts +16 -0
- package/dist/index.js +1790 -296
- package/dist/protocol.d.ts +44 -33
- package/dist/providers/index.d.ts +79 -0
- package/dist/providers/playwright/compileLocator.d.ts +3 -0
- package/dist/providers/playwright/dispatchBrowserRpc.d.ts +13 -0
- package/dist/providers/playwright/expectUtils.d.ts +24 -0
- package/dist/providers/playwright/implementation.d.ts +2 -0
- package/dist/providers/playwright/index.d.ts +1 -0
- package/dist/providers/playwright/runtime.d.ts +5 -0
- package/dist/providers/playwright/textMatcher.d.ts +8 -0
- package/dist/rpcProtocol.d.ts +145 -0
- package/dist/runSession.d.ts +33 -0
- package/dist/sessionRegistry.d.ts +34 -0
- package/dist/sourceMap/sourceMapLoader.d.ts +14 -0
- package/dist/watchCliShortcuts.d.ts +6 -0
- package/dist/watchRerunPlanner.d.ts +21 -0
- package/package.json +17 -12
- package/src/AGENTS.md +128 -0
- package/src/augmentExpect.ts +62 -0
- package/src/browser.ts +3 -0
- package/src/browserRpcRegistry.ts +57 -0
- package/src/client/AGENTS.md +82 -0
- package/src/client/api.ts +213 -0
- package/src/client/browserRpc.ts +86 -0
- package/src/client/dispatchTransport.ts +178 -0
- package/src/client/entry.ts +96 -33
- package/src/client/locator.ts +452 -0
- package/src/client/snapshot.ts +32 -97
- package/src/client/sourceMapSupport.ts +26 -37
- package/src/concurrency.ts +62 -0
- package/src/dispatchCapabilities.ts +162 -0
- package/src/dispatchRouter.ts +82 -0
- package/src/env.d.ts +8 -1
- package/src/headedSerialTaskQueue.ts +19 -0
- package/src/headlessLatestRerunScheduler.ts +76 -0
- package/src/headlessTransport.ts +28 -0
- package/src/hostController.ts +1538 -384
- package/src/protocol.ts +66 -31
- package/src/providers/index.ts +103 -0
- package/src/providers/playwright/compileLocator.ts +130 -0
- package/src/providers/playwright/dispatchBrowserRpc.ts +372 -0
- package/src/providers/playwright/expectUtils.ts +57 -0
- package/src/providers/playwright/implementation.ts +33 -0
- package/src/providers/playwright/index.ts +1 -0
- package/src/providers/playwright/runtime.ts +32 -0
- package/src/providers/playwright/textMatcher.ts +10 -0
- package/src/rpcProtocol.ts +220 -0
- package/src/runSession.ts +110 -0
- package/src/sessionRegistry.ts +89 -0
- package/src/sourceMap/sourceMapLoader.ts +96 -0
- package/src/watchCliShortcuts.ts +77 -0
- package/src/watchRerunPlanner.ts +77 -0
- package/dist/browser-container/container-static/css/index.5a71c757.css +0 -1
- package/dist/browser-container/container-static/js/565.226c9ef5.js.LICENSE.txt +0 -1
- package/dist/browser-container/container-static/js/lib-react.97ee79b0.js.LICENSE.txt +0 -1
- package/dist/browser-container/container-static/js/scheduler.5accca0c.js +0 -407
- package/dist/browser-container/scheduler.html +0 -19
package/src/hostController.ts
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
|
|
|
3
3
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
4
4
|
import type { AddressInfo } from 'node:net';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import type { Rspack } from '@rstest/core';
|
|
6
7
|
import {
|
|
7
8
|
type BrowserTestRunOptions,
|
|
8
9
|
type BrowserTestRunResult,
|
|
@@ -30,14 +31,59 @@ import { type BirpcReturn, createBirpc } from 'birpc';
|
|
|
30
31
|
import openEditor from 'open-editor';
|
|
31
32
|
import { basename, dirname, join, normalize, relative, resolve } from 'pathe';
|
|
32
33
|
import * as picomatch from 'picomatch';
|
|
33
|
-
import type { BrowserContext, ConsoleMessage, Page } from 'playwright';
|
|
34
34
|
import sirv from 'sirv';
|
|
35
35
|
import { type WebSocket, WebSocketServer } from 'ws';
|
|
36
|
+
import { getHeadlessConcurrency } from './concurrency';
|
|
37
|
+
import {
|
|
38
|
+
createHostDispatchRouter,
|
|
39
|
+
type HostDispatchRouterOptions,
|
|
40
|
+
} from './dispatchCapabilities';
|
|
41
|
+
import { createHeadedSerialTaskQueue } from './headedSerialTaskQueue';
|
|
42
|
+
import { createHeadlessLatestRerunScheduler } from './headlessLatestRerunScheduler';
|
|
43
|
+
import { attachHeadlessRunnerTransport } from './headlessTransport';
|
|
36
44
|
import type {
|
|
45
|
+
BrowserClientMessage,
|
|
46
|
+
BrowserDispatchHandler,
|
|
47
|
+
BrowserDispatchRequest,
|
|
48
|
+
BrowserDispatchResponse,
|
|
37
49
|
BrowserHostConfig,
|
|
38
50
|
BrowserProjectRuntime,
|
|
51
|
+
BrowserRpcRequest,
|
|
52
|
+
BrowserViewport,
|
|
53
|
+
SnapshotRpcRequest,
|
|
39
54
|
TestFileInfo,
|
|
40
55
|
} from './protocol';
|
|
56
|
+
import {
|
|
57
|
+
DISPATCH_MESSAGE_TYPE,
|
|
58
|
+
DISPATCH_NAMESPACE_RUNNER,
|
|
59
|
+
validateBrowserRpcRequest,
|
|
60
|
+
} from './protocol';
|
|
61
|
+
import {
|
|
62
|
+
type BrowserProvider,
|
|
63
|
+
type BrowserProviderBrowser,
|
|
64
|
+
type BrowserProviderContext,
|
|
65
|
+
type BrowserProviderImplementation,
|
|
66
|
+
type BrowserProviderPage,
|
|
67
|
+
getBrowserProviderImplementation,
|
|
68
|
+
} from './providers';
|
|
69
|
+
import {
|
|
70
|
+
createRunSession,
|
|
71
|
+
type RunSession,
|
|
72
|
+
RunSessionLifecycle,
|
|
73
|
+
} from './runSession';
|
|
74
|
+
import { RunnerSessionRegistry } from './sessionRegistry';
|
|
75
|
+
import {
|
|
76
|
+
loadSourceMapWithCache,
|
|
77
|
+
normalizeJavaScriptUrl,
|
|
78
|
+
type SourceMapPayload,
|
|
79
|
+
} from './sourceMap/sourceMapLoader';
|
|
80
|
+
import { resolveBrowserViewportPreset } from './viewportPresets';
|
|
81
|
+
import {
|
|
82
|
+
isBrowserWatchCliShortcutsEnabled,
|
|
83
|
+
logBrowserWatchReadyMessage,
|
|
84
|
+
setupBrowserWatchCliShortcuts,
|
|
85
|
+
} from './watchCliShortcuts';
|
|
86
|
+
import { collectWatchTestFiles, planWatchRerun } from './watchRerunPlanner';
|
|
41
87
|
|
|
42
88
|
const { createRsbuild, rspack } = rsbuild;
|
|
43
89
|
type RsbuildDevServer = rsbuild.RsbuildDevServer;
|
|
@@ -66,16 +112,22 @@ type VirtualModulesPluginInstance = InstanceType<
|
|
|
66
112
|
(typeof rspack.experiments)['VirtualModulesPlugin']
|
|
67
113
|
>;
|
|
68
114
|
|
|
69
|
-
type PlaywrightModule = typeof import('playwright');
|
|
70
|
-
type BrowserType = PlaywrightModule['chromium'];
|
|
71
|
-
type BrowserInstance = Awaited<ReturnType<BrowserType['launch']>>;
|
|
72
|
-
|
|
73
115
|
type BrowserProjectEntries = {
|
|
74
116
|
project: ProjectContext;
|
|
75
117
|
setupFiles: string[];
|
|
76
118
|
testFiles: string[];
|
|
77
119
|
};
|
|
78
120
|
|
|
121
|
+
type BrowserProviderProject = {
|
|
122
|
+
rootPath: string;
|
|
123
|
+
provider: BrowserProvider;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
type BrowserLaunchOptions = Pick<
|
|
127
|
+
ProjectContext['normalizedConfig']['browser'],
|
|
128
|
+
'provider' | 'browser' | 'headless' | 'port' | 'strictPort'
|
|
129
|
+
>;
|
|
130
|
+
|
|
79
131
|
/** Payload for test file start event */
|
|
80
132
|
type TestFileStartPayload = {
|
|
81
133
|
testPath: string;
|
|
@@ -97,21 +149,30 @@ type FatalPayload = {
|
|
|
97
149
|
stack?: string;
|
|
98
150
|
};
|
|
99
151
|
|
|
152
|
+
type ReporterHookArg<THook extends keyof Reporter> = Parameters<
|
|
153
|
+
NonNullable<Reporter[THook]>
|
|
154
|
+
>[0];
|
|
155
|
+
|
|
156
|
+
type TestFileReadyPayload = ReporterHookArg<'onTestFileReady'>;
|
|
157
|
+
type TestSuiteStartPayload = ReporterHookArg<'onTestSuiteStart'>;
|
|
158
|
+
type TestSuiteResultPayload = ReporterHookArg<'onTestSuiteResult'>;
|
|
159
|
+
type TestCaseStartPayload = ReporterHookArg<'onTestCaseStart'>;
|
|
160
|
+
|
|
100
161
|
/** RPC methods exposed by the host (server) to the container (client) */
|
|
101
162
|
type HostRpcMethods = {
|
|
102
163
|
rerunTest: (testFile: string, testNamePattern?: string) => Promise<void>;
|
|
103
164
|
getTestFiles: () => Promise<TestFileInfo[]>;
|
|
165
|
+
onRunnerFramesReady: (testFiles: string[]) => Promise<void>;
|
|
104
166
|
// Test result callbacks from container
|
|
105
167
|
onTestFileStart: (payload: TestFileStartPayload) => Promise<void>;
|
|
106
168
|
onTestCaseResult: (payload: TestResult) => Promise<void>;
|
|
107
169
|
onTestFileComplete: (payload: TestFileResult) => Promise<void>;
|
|
108
170
|
onLog: (payload: LogPayload) => Promise<void>;
|
|
109
171
|
onFatal: (payload: FatalPayload) => Promise<void>;
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
removeSnapshotFile: (filepath: string) => Promise<void>;
|
|
172
|
+
// Generic dispatch endpoint used by runner RPC requests.
|
|
173
|
+
dispatch: (
|
|
174
|
+
request: BrowserDispatchRequest,
|
|
175
|
+
) => Promise<BrowserDispatchResponse>;
|
|
115
176
|
};
|
|
116
177
|
|
|
117
178
|
/** RPC methods exposed by the container (client) to the host (server) */
|
|
@@ -237,15 +298,17 @@ class ContainerRpcManager {
|
|
|
237
298
|
type BrowserRuntime = {
|
|
238
299
|
rsbuildInstance: RsbuildInstance;
|
|
239
300
|
devServer: RsbuildDevServer;
|
|
240
|
-
browser:
|
|
301
|
+
browser: BrowserProviderBrowser;
|
|
241
302
|
port: number;
|
|
242
303
|
wsPort: number;
|
|
243
304
|
manifestPath: string;
|
|
244
305
|
tempDir: string;
|
|
245
306
|
manifestPlugin: VirtualModulesPluginInstance;
|
|
246
|
-
containerPage?:
|
|
247
|
-
containerContext?:
|
|
307
|
+
containerPage?: BrowserProviderPage;
|
|
308
|
+
containerContext?: BrowserProviderContext;
|
|
248
309
|
setContainerOptions: (options: BrowserHostConfig) => void;
|
|
310
|
+
// Reserved extension seam for host-side dispatch capabilities.
|
|
311
|
+
dispatchHandlers: Map<string, BrowserDispatchHandler>;
|
|
249
312
|
wss: WebSocketServer;
|
|
250
313
|
rpcManager?: ContainerRpcManager;
|
|
251
314
|
};
|
|
@@ -259,6 +322,8 @@ type WatchContext = {
|
|
|
259
322
|
lastTestFiles: TestFileInfo[];
|
|
260
323
|
hooksEnabled: boolean;
|
|
261
324
|
cleanupRegistered: boolean;
|
|
325
|
+
cleanupPromise: Promise<void> | null;
|
|
326
|
+
closeCliShortcuts: (() => void) | null;
|
|
262
327
|
chunkHashes: Map<string, string>;
|
|
263
328
|
affectedTestFiles: string[];
|
|
264
329
|
};
|
|
@@ -268,6 +333,8 @@ const watchContext: WatchContext = {
|
|
|
268
333
|
lastTestFiles: [],
|
|
269
334
|
hooksEnabled: false,
|
|
270
335
|
cleanupRegistered: false,
|
|
336
|
+
cleanupPromise: null,
|
|
337
|
+
closeCliShortcuts: null,
|
|
271
338
|
chunkHashes: new Map(),
|
|
272
339
|
affectedTestFiles: [],
|
|
273
340
|
};
|
|
@@ -276,12 +343,128 @@ const watchContext: WatchContext = {
|
|
|
276
343
|
// Utility Functions
|
|
277
344
|
// ============================================================================
|
|
278
345
|
|
|
346
|
+
const resolveViewport = (
|
|
347
|
+
viewport: BrowserViewport | undefined,
|
|
348
|
+
): { width: number; height: number } | null => {
|
|
349
|
+
if (!viewport) {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (typeof viewport === 'string') {
|
|
354
|
+
return resolveBrowserViewportPreset(viewport);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (
|
|
358
|
+
typeof viewport.width === 'number' &&
|
|
359
|
+
Number.isFinite(viewport.width) &&
|
|
360
|
+
viewport.width > 0 &&
|
|
361
|
+
typeof viewport.height === 'number' &&
|
|
362
|
+
Number.isFinite(viewport.height) &&
|
|
363
|
+
viewport.height > 0
|
|
364
|
+
) {
|
|
365
|
+
return {
|
|
366
|
+
width: viewport.width,
|
|
367
|
+
height: viewport.height,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return null;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const mapViewportByProject = (
|
|
375
|
+
projects: BrowserProjectRuntime[],
|
|
376
|
+
): Map<string, { width: number; height: number }> => {
|
|
377
|
+
const map = new Map<string, { width: number; height: number }>();
|
|
378
|
+
for (const project of projects) {
|
|
379
|
+
const viewport = resolveViewport(project.viewport);
|
|
380
|
+
if (viewport) {
|
|
381
|
+
map.set(project.name, viewport);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return map;
|
|
385
|
+
};
|
|
386
|
+
|
|
279
387
|
const ensureProcessExitCode = (code: number): void => {
|
|
280
388
|
if (process.exitCode === undefined || process.exitCode === 0) {
|
|
281
389
|
process.exitCode = code;
|
|
282
390
|
}
|
|
283
391
|
};
|
|
284
392
|
|
|
393
|
+
const castArray = <T>(arr?: T | T[]): T[] => {
|
|
394
|
+
if (arr === undefined) {
|
|
395
|
+
return [];
|
|
396
|
+
}
|
|
397
|
+
return Array.isArray(arr) ? arr : [arr];
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const applyDefaultWatchOptions = (
|
|
401
|
+
rspackConfig: Rspack.Configuration,
|
|
402
|
+
isWatchMode: boolean,
|
|
403
|
+
) => {
|
|
404
|
+
rspackConfig.watchOptions ??= {};
|
|
405
|
+
|
|
406
|
+
if (!isWatchMode) {
|
|
407
|
+
rspackConfig.watchOptions.ignored = '**/**';
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
rspackConfig.watchOptions.ignored = castArray(
|
|
412
|
+
rspackConfig.watchOptions.ignored || [],
|
|
413
|
+
) as string[];
|
|
414
|
+
|
|
415
|
+
if (rspackConfig.watchOptions.ignored.length === 0) {
|
|
416
|
+
rspackConfig.watchOptions.ignored.push('**/.git', '**/node_modules');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
rspackConfig.output?.path &&
|
|
420
|
+
rspackConfig.watchOptions.ignored.push(rspackConfig.output.path);
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
type LazyCompilationModule = {
|
|
424
|
+
nameForCondition?: () => string | null | undefined;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
type BrowserLazyCompilationConfig = {
|
|
428
|
+
imports: true;
|
|
429
|
+
entries: false;
|
|
430
|
+
test?: (module: LazyCompilationModule) => boolean;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
export const createBrowserLazyCompilationConfig = (
|
|
434
|
+
setupFiles: string[],
|
|
435
|
+
): BrowserLazyCompilationConfig => {
|
|
436
|
+
const eagerSetupFiles = new Set(
|
|
437
|
+
setupFiles.map((filePath) => normalize(filePath)),
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
if (eagerSetupFiles.size === 0) {
|
|
441
|
+
return {
|
|
442
|
+
imports: true,
|
|
443
|
+
entries: false,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
imports: true,
|
|
449
|
+
entries: false,
|
|
450
|
+
test(module: LazyCompilationModule) {
|
|
451
|
+
const filePath = module.nameForCondition?.();
|
|
452
|
+
return !filePath || !eagerSetupFiles.has(normalize(filePath));
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
export const createBrowserRsbuildDevConfig = (isWatchMode: boolean) => {
|
|
458
|
+
return {
|
|
459
|
+
// Disable HMR in non-watch mode (tests run once and exit).
|
|
460
|
+
// Aligns with node mode behavior (packages/core/src/core/rsbuild.ts).
|
|
461
|
+
hmr: isWatchMode,
|
|
462
|
+
client: {
|
|
463
|
+
logLevel: 'error' as const,
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
};
|
|
467
|
+
|
|
285
468
|
/**
|
|
286
469
|
* Convert a single glob pattern to RegExp using picomatch
|
|
287
470
|
* Based on Storybook's implementation
|
|
@@ -533,6 +716,69 @@ const getBrowserProjects = (context: Rstest): ProjectContext[] => {
|
|
|
533
716
|
);
|
|
534
717
|
};
|
|
535
718
|
|
|
719
|
+
const getBrowserLaunchOptions = (
|
|
720
|
+
project: ProjectContext,
|
|
721
|
+
): BrowserLaunchOptions => ({
|
|
722
|
+
provider: project.normalizedConfig.browser.provider,
|
|
723
|
+
browser: project.normalizedConfig.browser.browser,
|
|
724
|
+
headless: project.normalizedConfig.browser.headless,
|
|
725
|
+
port: project.normalizedConfig.browser.port,
|
|
726
|
+
strictPort: project.normalizedConfig.browser.strictPort,
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
const ensureConsistentBrowserLaunchOptions = (
|
|
730
|
+
projects: ProjectContext[],
|
|
731
|
+
): BrowserLaunchOptions => {
|
|
732
|
+
if (projects.length === 0) {
|
|
733
|
+
throw new Error('No browser-enabled projects found.');
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const firstProject = projects[0]!;
|
|
737
|
+
const firstOptions = getBrowserLaunchOptions(firstProject);
|
|
738
|
+
|
|
739
|
+
for (const project of projects.slice(1)) {
|
|
740
|
+
const options = getBrowserLaunchOptions(project);
|
|
741
|
+
if (
|
|
742
|
+
options.provider !== firstOptions.provider ||
|
|
743
|
+
options.browser !== firstOptions.browser ||
|
|
744
|
+
options.headless !== firstOptions.headless ||
|
|
745
|
+
options.port !== firstOptions.port ||
|
|
746
|
+
options.strictPort !== firstOptions.strictPort
|
|
747
|
+
) {
|
|
748
|
+
throw new Error(
|
|
749
|
+
`Browser launch config mismatch between projects "${firstProject.name}" and "${project.name}". ` +
|
|
750
|
+
'All browser-enabled projects in one run must share provider/browser/headless/port/strictPort.',
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
return firstOptions;
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
const resolveProviderForTestPath = ({
|
|
759
|
+
testPath,
|
|
760
|
+
browserProjects,
|
|
761
|
+
}: {
|
|
762
|
+
testPath: string;
|
|
763
|
+
browserProjects: BrowserProviderProject[];
|
|
764
|
+
}): BrowserProvider => {
|
|
765
|
+
const normalizedTestPath = normalize(testPath);
|
|
766
|
+
const sortedProjects = [...browserProjects].sort(
|
|
767
|
+
(a, b) => b.rootPath.length - a.rootPath.length,
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
for (const project of sortedProjects) {
|
|
771
|
+
if (normalizedTestPath.startsWith(project.rootPath)) {
|
|
772
|
+
return project.provider;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
throw new Error(
|
|
777
|
+
`Cannot resolve browser provider for test path: ${JSON.stringify(testPath)}. ` +
|
|
778
|
+
`Known project roots: ${JSON.stringify(sortedProjects.map((p) => p.rootPath))}`,
|
|
779
|
+
);
|
|
780
|
+
};
|
|
781
|
+
|
|
536
782
|
const collectProjectEntries = async (
|
|
537
783
|
context: Rstest,
|
|
538
784
|
): Promise<BrowserProjectEntries[]> => {
|
|
@@ -729,21 +975,6 @@ const htmlTemplate = `<!DOCTYPE html>
|
|
|
729
975
|
</html>
|
|
730
976
|
`;
|
|
731
977
|
|
|
732
|
-
const fallbackSchedulerHtmlTemplate = `<!DOCTYPE html>
|
|
733
|
-
<html lang="en">
|
|
734
|
-
<head>
|
|
735
|
-
<meta charset="UTF-8" />
|
|
736
|
-
<title>Rstest Browser Scheduler</title>
|
|
737
|
-
<script>
|
|
738
|
-
window.__RSTEST_BROWSER_OPTIONS__ = ${OPTIONS_PLACEHOLDER};
|
|
739
|
-
</script>
|
|
740
|
-
</head>
|
|
741
|
-
<body>
|
|
742
|
-
<script type="module" src="/container-static/js/scheduler.js"></script>
|
|
743
|
-
</body>
|
|
744
|
-
</html>
|
|
745
|
-
`;
|
|
746
|
-
|
|
747
978
|
// Workaround for noisy "removed ..." logs caused by VirtualModulesPlugin.
|
|
748
979
|
// Rsbuild suppresses the removed-file log if all removed paths include "virtual":
|
|
749
980
|
// https://github.com/web-infra-dev/rsbuild/blob/1258fa9dba5c321a4629b591a6dadbd2e26c6963/packages/core/src/createCompiler.ts#L73-L76
|
|
@@ -776,27 +1007,39 @@ const destroyBrowserRuntime = async (
|
|
|
776
1007
|
.catch(() => {});
|
|
777
1008
|
};
|
|
778
1009
|
|
|
779
|
-
const
|
|
780
|
-
if (watchContext.
|
|
781
|
-
return;
|
|
1010
|
+
const cleanupWatchRuntime = (): Promise<void> => {
|
|
1011
|
+
if (watchContext.cleanupPromise) {
|
|
1012
|
+
return watchContext.cleanupPromise;
|
|
782
1013
|
}
|
|
783
1014
|
|
|
784
|
-
|
|
1015
|
+
watchContext.cleanupPromise = (async () => {
|
|
1016
|
+
watchContext.closeCliShortcuts?.();
|
|
1017
|
+
watchContext.closeCliShortcuts = null;
|
|
1018
|
+
|
|
785
1019
|
if (!watchContext.runtime) {
|
|
786
1020
|
return;
|
|
787
1021
|
}
|
|
1022
|
+
|
|
788
1023
|
await destroyBrowserRuntime(watchContext.runtime);
|
|
789
1024
|
watchContext.runtime = null;
|
|
790
|
-
};
|
|
1025
|
+
})();
|
|
1026
|
+
|
|
1027
|
+
return watchContext.cleanupPromise;
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
const registerWatchCleanup = (): void => {
|
|
1031
|
+
if (watchContext.cleanupRegistered) {
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
791
1034
|
|
|
792
|
-
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
1035
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGTSTP'] as const) {
|
|
793
1036
|
process.once(signal, () => {
|
|
794
|
-
void
|
|
1037
|
+
void cleanupWatchRuntime();
|
|
795
1038
|
});
|
|
796
1039
|
}
|
|
797
1040
|
|
|
798
1041
|
process.once('exit', () => {
|
|
799
|
-
void
|
|
1042
|
+
void cleanupWatchRuntime();
|
|
800
1043
|
});
|
|
801
1044
|
|
|
802
1045
|
watchContext.cleanupRegistered = true;
|
|
@@ -831,15 +1074,11 @@ const createBrowserRuntime = async ({
|
|
|
831
1074
|
const containerHtmlTemplate = containerDistPath
|
|
832
1075
|
? await fs.readFile(join(containerDistPath, 'index.html'), 'utf-8')
|
|
833
1076
|
: null;
|
|
834
|
-
const schedulerHtmlTemplate = containerDistPath
|
|
835
|
-
? await fs
|
|
836
|
-
.readFile(join(containerDistPath, 'scheduler.html'), 'utf-8')
|
|
837
|
-
.catch(() => null)
|
|
838
|
-
: null;
|
|
839
1077
|
|
|
840
1078
|
let injectedContainerHtml: string | null = null;
|
|
841
|
-
let injectedSchedulerHtml: string | null = null;
|
|
842
1079
|
let serializedOptions = 'null';
|
|
1080
|
+
// Reserved extension seam for future browser-side capabilities.
|
|
1081
|
+
const dispatchHandlers = new Map<string, BrowserDispatchHandler>();
|
|
843
1082
|
|
|
844
1083
|
const setContainerOptions = (options: BrowserHostConfig): void => {
|
|
845
1084
|
serializedOptions = serializeForInlineScript(options);
|
|
@@ -849,18 +1088,17 @@ const createBrowserRuntime = async ({
|
|
|
849
1088
|
serializedOptions,
|
|
850
1089
|
);
|
|
851
1090
|
}
|
|
852
|
-
injectedSchedulerHtml = (
|
|
853
|
-
schedulerHtmlTemplate || fallbackSchedulerHtmlTemplate
|
|
854
|
-
).replace(OPTIONS_PLACEHOLDER, serializedOptions);
|
|
855
1091
|
};
|
|
856
1092
|
|
|
857
|
-
// Get user Rsbuild config from the first browser project
|
|
858
1093
|
const browserProjects = getBrowserProjects(context);
|
|
859
|
-
const
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
const
|
|
863
|
-
|
|
1094
|
+
const projectByEnvironmentName = new Map(
|
|
1095
|
+
browserProjects.map((project) => [project.environmentName, project]),
|
|
1096
|
+
);
|
|
1097
|
+
const userPlugins = browserProjects.flatMap(
|
|
1098
|
+
(project) => project.normalizedConfig.plugins || [],
|
|
1099
|
+
);
|
|
1100
|
+
const browserLaunchOptions =
|
|
1101
|
+
ensureConsistentBrowserLaunchOptions(browserProjects);
|
|
864
1102
|
|
|
865
1103
|
// Rstest internal aliases that must not be overridden by user config
|
|
866
1104
|
const browserRuntimePath = fileURLToPath(
|
|
@@ -871,6 +1109,8 @@ const createBrowserRuntime = async ({
|
|
|
871
1109
|
'@rstest/browser-manifest': manifestPath,
|
|
872
1110
|
// User test code: import { describe, it } from '@rstest/core'
|
|
873
1111
|
'@rstest/core': resolveBrowserFile('client/public.ts'),
|
|
1112
|
+
// User test code: import { page } from '@rstest/browser'
|
|
1113
|
+
'@rstest/browser': resolveBrowserFile('browser.ts'),
|
|
874
1114
|
// Browser runtime APIs for entry.ts and public.ts
|
|
875
1115
|
// Uses dist file with extractSourceMap to preserve sourcemap chain for inline snapshots
|
|
876
1116
|
'@rstest/core/browser-runtime': browserRuntimePath,
|
|
@@ -885,16 +1125,14 @@ const createBrowserRuntime = async ({
|
|
|
885
1125
|
plugins: userPlugins,
|
|
886
1126
|
server: {
|
|
887
1127
|
printUrls: false,
|
|
888
|
-
port:
|
|
889
|
-
strictPort:
|
|
890
|
-
},
|
|
891
|
-
dev: {
|
|
892
|
-
client: {
|
|
893
|
-
logLevel: 'error',
|
|
894
|
-
},
|
|
1128
|
+
port: browserLaunchOptions.port ?? 4000,
|
|
1129
|
+
strictPort: browserLaunchOptions.strictPort,
|
|
895
1130
|
},
|
|
1131
|
+
dev: createBrowserRsbuildDevConfig(isWatchMode),
|
|
896
1132
|
environments: {
|
|
897
|
-
|
|
1133
|
+
...Object.fromEntries(
|
|
1134
|
+
browserProjects.map((project) => [project.environmentName, {}]),
|
|
1135
|
+
),
|
|
898
1136
|
},
|
|
899
1137
|
},
|
|
900
1138
|
});
|
|
@@ -904,13 +1142,45 @@ const createBrowserRuntime = async ({
|
|
|
904
1142
|
{
|
|
905
1143
|
name: 'rstest:browser-user-config',
|
|
906
1144
|
setup(api) {
|
|
1145
|
+
// Internal extension entry: register host dispatch handlers without
|
|
1146
|
+
// coupling scheduling to individual capability implementations.
|
|
1147
|
+
(api as { expose?: (name: string, value: unknown) => void }).expose?.(
|
|
1148
|
+
'rstest:browser',
|
|
1149
|
+
{
|
|
1150
|
+
registerDispatchHandler: (
|
|
1151
|
+
namespace: string,
|
|
1152
|
+
handler: BrowserDispatchHandler,
|
|
1153
|
+
) => {
|
|
1154
|
+
dispatchHandlers.set(namespace, handler);
|
|
1155
|
+
},
|
|
1156
|
+
},
|
|
1157
|
+
);
|
|
1158
|
+
|
|
907
1159
|
api.modifyEnvironmentConfig({
|
|
908
|
-
handler: (config, { mergeEnvironmentConfig }) => {
|
|
1160
|
+
handler: (config, { mergeEnvironmentConfig, name }) => {
|
|
1161
|
+
const project = projectByEnvironmentName.get(name);
|
|
1162
|
+
if (!project) {
|
|
1163
|
+
return config;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const userRsbuildConfig = project.normalizedConfig;
|
|
1167
|
+
const setupFiles = Object.values(
|
|
1168
|
+
getSetupFiles(
|
|
1169
|
+
project.normalizedConfig.setupFiles,
|
|
1170
|
+
project.rootPath,
|
|
1171
|
+
),
|
|
1172
|
+
);
|
|
909
1173
|
// Merge order: current config -> userConfig -> rstest required config (highest priority)
|
|
910
1174
|
const merged = mergeEnvironmentConfig(config, userRsbuildConfig, {
|
|
911
1175
|
resolve: {
|
|
912
1176
|
alias: rstestInternalAliases,
|
|
913
1177
|
},
|
|
1178
|
+
source: {
|
|
1179
|
+
define: {
|
|
1180
|
+
'process.env': 'globalThis[Symbol.for("rstest.env")]',
|
|
1181
|
+
'import.meta.env': 'globalThis[Symbol.for("rstest.env")]',
|
|
1182
|
+
},
|
|
1183
|
+
},
|
|
914
1184
|
output: {
|
|
915
1185
|
target: 'web',
|
|
916
1186
|
// Enable source map for inline snapshot support
|
|
@@ -921,13 +1191,13 @@ const createBrowserRuntime = async ({
|
|
|
921
1191
|
tools: {
|
|
922
1192
|
rspack: (rspackConfig) => {
|
|
923
1193
|
rspackConfig.mode = 'development';
|
|
924
|
-
rspackConfig.lazyCompilation =
|
|
925
|
-
|
|
926
|
-
entries: false,
|
|
927
|
-
};
|
|
1194
|
+
rspackConfig.lazyCompilation =
|
|
1195
|
+
createBrowserLazyCompilationConfig(setupFiles);
|
|
928
1196
|
rspackConfig.plugins = rspackConfig.plugins || [];
|
|
929
1197
|
rspackConfig.plugins.push(virtualManifestPlugin);
|
|
930
1198
|
|
|
1199
|
+
applyDefaultWatchOptions(rspackConfig, isWatchMode);
|
|
1200
|
+
|
|
931
1201
|
// Extract and merge sourcemaps from pre-built @rstest/core files
|
|
932
1202
|
// This preserves the sourcemap chain for inline snapshot support
|
|
933
1203
|
// See: https://rspack.dev/config/module-rules#rulesextractsourcemap
|
|
@@ -984,8 +1254,8 @@ const createBrowserRuntime = async ({
|
|
|
984
1254
|
if (stats) {
|
|
985
1255
|
const projectEntries = await collectProjectEntries(context);
|
|
986
1256
|
const entryTestFiles = new Set<string>(
|
|
987
|
-
projectEntries.
|
|
988
|
-
|
|
1257
|
+
collectWatchTestFiles(projectEntries).map(
|
|
1258
|
+
(file) => file.testPath,
|
|
989
1259
|
),
|
|
990
1260
|
);
|
|
991
1261
|
|
|
@@ -1015,7 +1285,9 @@ const createBrowserRuntime = async ({
|
|
|
1015
1285
|
}
|
|
1016
1286
|
|
|
1017
1287
|
// Register coverage plugin for browser mode
|
|
1018
|
-
const coverage =
|
|
1288
|
+
const coverage = browserProjects.find(
|
|
1289
|
+
(project) => project.normalizedConfig.coverage?.enabled,
|
|
1290
|
+
)?.normalizedConfig.coverage;
|
|
1019
1291
|
if (coverage?.enabled && context.command !== 'list') {
|
|
1020
1292
|
const { pluginCoverage } = await loadCoverageProvider(
|
|
1021
1293
|
coverage,
|
|
@@ -1109,82 +1381,72 @@ const createBrowserRuntime = async ({
|
|
|
1109
1381
|
}
|
|
1110
1382
|
};
|
|
1111
1383
|
|
|
1112
|
-
devServer.middlewares.use(
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
}
|
|
1117
|
-
const url = new URL(req.url, 'http://localhost');
|
|
1118
|
-
if (url.pathname === '/__open-in-editor') {
|
|
1119
|
-
const file = url.searchParams.get('file');
|
|
1120
|
-
if (!file) {
|
|
1121
|
-
res.statusCode = 400;
|
|
1122
|
-
res.end('Missing file');
|
|
1384
|
+
devServer.middlewares.use(
|
|
1385
|
+
async (req: IncomingMessage, res: ServerResponse, next: () => void) => {
|
|
1386
|
+
if (!req.url) {
|
|
1387
|
+
next();
|
|
1123
1388
|
return;
|
|
1124
1389
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1390
|
+
const url = new URL(req.url, 'http://localhost');
|
|
1391
|
+
if (url.pathname === '/__open-in-editor') {
|
|
1392
|
+
const file = url.searchParams.get('file');
|
|
1393
|
+
if (!file) {
|
|
1394
|
+
res.statusCode = 400;
|
|
1395
|
+
res.end('Missing file');
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
try {
|
|
1399
|
+
await openEditor([{ file }]);
|
|
1400
|
+
res.statusCode = 204;
|
|
1401
|
+
res.end();
|
|
1402
|
+
} catch (error) {
|
|
1403
|
+
logger.debug(`[Browser UI] Failed to open editor: ${String(error)}`);
|
|
1404
|
+
res.statusCode = 500;
|
|
1405
|
+
res.end('Failed to open editor');
|
|
1406
|
+
}
|
|
1138
1407
|
return;
|
|
1139
1408
|
}
|
|
1409
|
+
if (url.pathname === '/') {
|
|
1410
|
+
if (await respondWithDevServerHtml(url, res)) {
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1140
1413
|
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
injectedSchedulerHtml ||
|
|
1145
|
-
(schedulerHtmlTemplate || fallbackSchedulerHtmlTemplate).replace(
|
|
1146
|
-
OPTIONS_PLACEHOLDER,
|
|
1147
|
-
'null',
|
|
1148
|
-
),
|
|
1149
|
-
);
|
|
1150
|
-
return;
|
|
1151
|
-
}
|
|
1414
|
+
const html =
|
|
1415
|
+
injectedContainerHtml ||
|
|
1416
|
+
containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
|
|
1152
1417
|
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1418
|
+
if (html) {
|
|
1419
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1420
|
+
res.end(html);
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1156
1423
|
|
|
1157
|
-
|
|
1158
|
-
res.
|
|
1159
|
-
res.end(html);
|
|
1424
|
+
res.statusCode = 502;
|
|
1425
|
+
res.end('Container UI is not available.');
|
|
1160
1426
|
return;
|
|
1161
1427
|
}
|
|
1428
|
+
if (url.pathname.startsWith('/container-static/')) {
|
|
1429
|
+
if (await proxyDevServerAsset(req, res)) {
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1162
1432
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1433
|
+
if (serveContainer) {
|
|
1434
|
+
serveContainer(req, res, next);
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
res.statusCode = 502;
|
|
1439
|
+
res.end('Container assets are not available.');
|
|
1169
1440
|
return;
|
|
1170
1441
|
}
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1442
|
+
if (url.pathname === '/runner.html') {
|
|
1443
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1444
|
+
res.end(htmlTemplate);
|
|
1174
1445
|
return;
|
|
1175
1446
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
return;
|
|
1180
|
-
}
|
|
1181
|
-
if (url.pathname === '/runner.html') {
|
|
1182
|
-
res.setHeader('Content-Type', 'text/html');
|
|
1183
|
-
res.end(htmlTemplate);
|
|
1184
|
-
return;
|
|
1185
|
-
}
|
|
1186
|
-
next();
|
|
1187
|
-
});
|
|
1447
|
+
next();
|
|
1448
|
+
},
|
|
1449
|
+
);
|
|
1188
1450
|
|
|
1189
1451
|
const { port } = await devServer.listen();
|
|
1190
1452
|
|
|
@@ -1199,49 +1461,33 @@ const createBrowserRuntime = async ({
|
|
|
1199
1461
|
const wsPort = (wss.address() as AddressInfo).port;
|
|
1200
1462
|
logger.debug(`[Browser UI] WebSocket server started on port ${wsPort}`);
|
|
1201
1463
|
|
|
1202
|
-
|
|
1203
|
-
const browserName = browserConfig.browser;
|
|
1464
|
+
const browserName = browserLaunchOptions.browser ?? 'chromium';
|
|
1204
1465
|
try {
|
|
1205
|
-
const
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
let browser: BrowserInstance;
|
|
1214
|
-
try {
|
|
1215
|
-
browser = await browserLauncher.launch({
|
|
1216
|
-
headless: forceHeadless ?? browserConfig.headless,
|
|
1217
|
-
// Chromium-specific args (ignored by other browsers)
|
|
1218
|
-
args:
|
|
1219
|
-
browserName === 'chromium'
|
|
1220
|
-
? [
|
|
1221
|
-
'--disable-popup-blocking',
|
|
1222
|
-
'--no-first-run',
|
|
1223
|
-
'--no-default-browser-check',
|
|
1224
|
-
]
|
|
1225
|
-
: undefined,
|
|
1466
|
+
const providerImplementation = getBrowserProviderImplementation(
|
|
1467
|
+
browserLaunchOptions.provider,
|
|
1468
|
+
);
|
|
1469
|
+
const runtime = await providerImplementation.launchRuntime({
|
|
1470
|
+
browserName,
|
|
1471
|
+
headless: forceHeadless ?? browserLaunchOptions.headless,
|
|
1226
1472
|
});
|
|
1473
|
+
return {
|
|
1474
|
+
rsbuildInstance,
|
|
1475
|
+
devServer,
|
|
1476
|
+
browser: runtime.browser,
|
|
1477
|
+
port,
|
|
1478
|
+
wsPort,
|
|
1479
|
+
manifestPath,
|
|
1480
|
+
tempDir,
|
|
1481
|
+
manifestPlugin: virtualManifestPlugin,
|
|
1482
|
+
setContainerOptions,
|
|
1483
|
+
dispatchHandlers,
|
|
1484
|
+
wss,
|
|
1485
|
+
};
|
|
1227
1486
|
} catch (_error) {
|
|
1228
1487
|
wss.close();
|
|
1229
1488
|
await devServer.close();
|
|
1230
1489
|
throw _error;
|
|
1231
1490
|
}
|
|
1232
|
-
|
|
1233
|
-
return {
|
|
1234
|
-
rsbuildInstance,
|
|
1235
|
-
devServer,
|
|
1236
|
-
browser,
|
|
1237
|
-
port,
|
|
1238
|
-
wsPort,
|
|
1239
|
-
manifestPath,
|
|
1240
|
-
tempDir,
|
|
1241
|
-
manifestPlugin: virtualManifestPlugin,
|
|
1242
|
-
setContainerOptions,
|
|
1243
|
-
wss,
|
|
1244
|
-
};
|
|
1245
1491
|
};
|
|
1246
1492
|
|
|
1247
1493
|
async function resolveProjectEntries(
|
|
@@ -1281,24 +1527,71 @@ export const runBrowserController = async (
|
|
|
1281
1527
|
const { skipOnTestRunEnd = false } = options ?? {};
|
|
1282
1528
|
const buildStart = Date.now();
|
|
1283
1529
|
const browserProjects = getBrowserProjects(context);
|
|
1284
|
-
const
|
|
1530
|
+
const useHeadlessDirect = browserProjects.every(
|
|
1285
1531
|
(project) => project.normalizedConfig.browser.headless,
|
|
1286
1532
|
);
|
|
1287
1533
|
|
|
1534
|
+
const browserSourceMapCache = new Map<string, SourceMapPayload | null>();
|
|
1535
|
+
|
|
1536
|
+
const isHttpLikeFile = (file: string): boolean => /^https?:\/\//.test(file);
|
|
1537
|
+
|
|
1538
|
+
const resolveBrowserSourcemap = async (sourcePath: string) => {
|
|
1539
|
+
if (!isHttpLikeFile(sourcePath)) {
|
|
1540
|
+
return {
|
|
1541
|
+
handled: false,
|
|
1542
|
+
sourcemap: null,
|
|
1543
|
+
};
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
const normalizedUrl = normalizeJavaScriptUrl(sourcePath);
|
|
1547
|
+
if (!normalizedUrl) {
|
|
1548
|
+
return {
|
|
1549
|
+
handled: true,
|
|
1550
|
+
sourcemap: null,
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
if (browserSourceMapCache.has(normalizedUrl)) {
|
|
1555
|
+
return {
|
|
1556
|
+
handled: true,
|
|
1557
|
+
sourcemap: browserSourceMapCache.get(normalizedUrl) ?? null,
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
return {
|
|
1562
|
+
handled: true,
|
|
1563
|
+
sourcemap: await loadSourceMapWithCache({
|
|
1564
|
+
jsUrl: normalizedUrl,
|
|
1565
|
+
cache: browserSourceMapCache,
|
|
1566
|
+
}),
|
|
1567
|
+
};
|
|
1568
|
+
};
|
|
1569
|
+
|
|
1570
|
+
const getBrowserSourcemap = async (
|
|
1571
|
+
sourcePath: string,
|
|
1572
|
+
): Promise<SourceMapPayload | null> => {
|
|
1573
|
+
const result = await resolveBrowserSourcemap(sourcePath);
|
|
1574
|
+
return result.handled ? result.sourcemap : null;
|
|
1575
|
+
};
|
|
1576
|
+
|
|
1288
1577
|
/**
|
|
1289
1578
|
* Build an error BrowserTestRunResult and call onTestRunEnd if needed.
|
|
1290
1579
|
* Used for early-exit error paths to ensure errors reach the summary report.
|
|
1291
1580
|
*/
|
|
1292
1581
|
const buildErrorResult = async (
|
|
1293
1582
|
error: Error,
|
|
1583
|
+
close?: () => Promise<void>,
|
|
1294
1584
|
): Promise<BrowserTestRunResult> => {
|
|
1295
1585
|
const elapsed = Math.max(0, Date.now() - buildStart);
|
|
1296
|
-
const errorResult
|
|
1586
|
+
const errorResult = {
|
|
1297
1587
|
results: [],
|
|
1298
1588
|
testResults: [],
|
|
1299
1589
|
duration: { totalTime: elapsed, buildTime: elapsed, testTime: 0 },
|
|
1300
1590
|
hasFailure: true,
|
|
1301
1591
|
unhandledErrors: [error],
|
|
1592
|
+
getSourcemap: getBrowserSourcemap,
|
|
1593
|
+
resolveSourcemap: resolveBrowserSourcemap,
|
|
1594
|
+
close,
|
|
1302
1595
|
};
|
|
1303
1596
|
|
|
1304
1597
|
if (!skipOnTestRunEnd) {
|
|
@@ -1308,7 +1601,7 @@ export const runBrowserController = async (
|
|
|
1308
1601
|
testResults: [],
|
|
1309
1602
|
duration: errorResult.duration,
|
|
1310
1603
|
snapshotSummary: context.snapshotManager.summary,
|
|
1311
|
-
getSourcemap:
|
|
1604
|
+
getSourcemap: getBrowserSourcemap,
|
|
1312
1605
|
unhandledErrors: errorResult.unhandledErrors,
|
|
1313
1606
|
});
|
|
1314
1607
|
}
|
|
@@ -1326,32 +1619,94 @@ export const runBrowserController = async (
|
|
|
1326
1619
|
cleanup?: () => Promise<void>,
|
|
1327
1620
|
): Promise<BrowserTestRunResult> => {
|
|
1328
1621
|
ensureProcessExitCode(1);
|
|
1329
|
-
|
|
1330
|
-
|
|
1622
|
+
|
|
1623
|
+
const normalizedError = toError(error);
|
|
1624
|
+
|
|
1625
|
+
if (cleanup && skipOnTestRunEnd) {
|
|
1626
|
+
return buildErrorResult(normalizedError, cleanup);
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
try {
|
|
1630
|
+
return await buildErrorResult(normalizedError);
|
|
1631
|
+
} finally {
|
|
1632
|
+
await cleanup?.();
|
|
1633
|
+
}
|
|
1634
|
+
};
|
|
1635
|
+
|
|
1636
|
+
const collectDeletedTestPaths = (
|
|
1637
|
+
previous: TestFileInfo[],
|
|
1638
|
+
current: TestFileInfo[],
|
|
1639
|
+
): string[] => {
|
|
1640
|
+
const currentPathSet = new Set(current.map((file) => file.testPath));
|
|
1641
|
+
return previous
|
|
1642
|
+
.map((file) => file.testPath)
|
|
1643
|
+
.filter((testPath) => !currentPathSet.has(testPath));
|
|
1644
|
+
};
|
|
1645
|
+
|
|
1646
|
+
const notifyTestRunStart = async (): Promise<void> => {
|
|
1647
|
+
if (skipOnTestRunEnd) {
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
for (const reporter of context.reporters) {
|
|
1652
|
+
await reporter.onTestRunStart?.();
|
|
1653
|
+
}
|
|
1654
|
+
};
|
|
1655
|
+
|
|
1656
|
+
const notifyTestRunEnd = async ({
|
|
1657
|
+
duration,
|
|
1658
|
+
unhandledErrors,
|
|
1659
|
+
filterRerunTestPaths,
|
|
1660
|
+
}: {
|
|
1661
|
+
duration: {
|
|
1662
|
+
totalTime: number;
|
|
1663
|
+
buildTime: number;
|
|
1664
|
+
testTime: number;
|
|
1665
|
+
};
|
|
1666
|
+
unhandledErrors?: Error[];
|
|
1667
|
+
filterRerunTestPaths?: string[];
|
|
1668
|
+
}): Promise<void> => {
|
|
1669
|
+
if (skipOnTestRunEnd) {
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
for (const reporter of context.reporters) {
|
|
1674
|
+
await reporter.onTestRunEnd?.({
|
|
1675
|
+
results: context.reporterResults.results,
|
|
1676
|
+
testResults: context.reporterResults.testResults,
|
|
1677
|
+
duration,
|
|
1678
|
+
snapshotSummary: context.snapshotManager.summary,
|
|
1679
|
+
getSourcemap: getBrowserSourcemap,
|
|
1680
|
+
unhandledErrors,
|
|
1681
|
+
filterRerunTestPaths,
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1331
1684
|
};
|
|
1332
1685
|
|
|
1333
1686
|
const containerDevServerEnv = process.env.RSTEST_CONTAINER_DEV_SERVER;
|
|
1334
1687
|
let containerDevServer: string | undefined;
|
|
1335
1688
|
let containerDistPath: string | undefined;
|
|
1336
1689
|
|
|
1337
|
-
if (
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1690
|
+
if (!useHeadlessDirect) {
|
|
1691
|
+
if (containerDevServerEnv) {
|
|
1692
|
+
try {
|
|
1693
|
+
containerDevServer = new URL(containerDevServerEnv).toString();
|
|
1694
|
+
logger.debug(
|
|
1695
|
+
`[Browser UI] Using dev server for container: ${containerDevServer}`,
|
|
1696
|
+
);
|
|
1697
|
+
} catch (error) {
|
|
1698
|
+
const originalError = toError(error);
|
|
1699
|
+
originalError.message = `Invalid RSTEST_CONTAINER_DEV_SERVER value: ${originalError.message}`;
|
|
1700
|
+
return failWithError(originalError);
|
|
1701
|
+
}
|
|
1347
1702
|
}
|
|
1348
|
-
}
|
|
1349
1703
|
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1704
|
+
if (!containerDevServer) {
|
|
1705
|
+
try {
|
|
1706
|
+
containerDistPath = resolveContainerDist();
|
|
1707
|
+
} catch (error) {
|
|
1708
|
+
return failWithError(error);
|
|
1709
|
+
}
|
|
1355
1710
|
}
|
|
1356
1711
|
}
|
|
1357
1712
|
|
|
@@ -1381,7 +1736,10 @@ export const runBrowserController = async (
|
|
|
1381
1736
|
return;
|
|
1382
1737
|
}
|
|
1383
1738
|
|
|
1739
|
+
await notifyTestRunStart();
|
|
1740
|
+
|
|
1384
1741
|
const isWatchMode = context.command === 'watch';
|
|
1742
|
+
const enableCliShortcuts = isWatchMode && isBrowserWatchCliShortcutsEnabled();
|
|
1385
1743
|
const tempDir =
|
|
1386
1744
|
isWatchMode && watchContext.runtime
|
|
1387
1745
|
? watchContext.runtime.tempDir
|
|
@@ -1402,12 +1760,7 @@ export const runBrowserController = async (
|
|
|
1402
1760
|
|
|
1403
1761
|
// Track initial test files for watch mode
|
|
1404
1762
|
if (isWatchMode) {
|
|
1405
|
-
watchContext.lastTestFiles = projectEntries
|
|
1406
|
-
entry.testFiles.map((testPath) => ({
|
|
1407
|
-
testPath,
|
|
1408
|
-
projectName: entry.project.name,
|
|
1409
|
-
})),
|
|
1410
|
-
);
|
|
1763
|
+
watchContext.lastTestFiles = collectWatchTestFiles(projectEntries);
|
|
1411
1764
|
}
|
|
1412
1765
|
|
|
1413
1766
|
let runtime = isWatchMode ? watchContext.runtime : null;
|
|
@@ -1440,6 +1793,12 @@ export const runBrowserController = async (
|
|
|
1440
1793
|
if (isWatchMode) {
|
|
1441
1794
|
watchContext.runtime = runtime;
|
|
1442
1795
|
registerWatchCleanup();
|
|
1796
|
+
|
|
1797
|
+
if (enableCliShortcuts && !watchContext.closeCliShortcuts) {
|
|
1798
|
+
watchContext.closeCliShortcuts = await setupBrowserWatchCliShortcuts({
|
|
1799
|
+
close: cleanupWatchRuntime,
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1443
1802
|
}
|
|
1444
1803
|
}
|
|
1445
1804
|
|
|
@@ -1484,23 +1843,827 @@ export const runBrowserController = async (
|
|
|
1484
1843
|
rpcTimeout: maxTestTimeoutForRpc,
|
|
1485
1844
|
};
|
|
1486
1845
|
|
|
1487
|
-
|
|
1846
|
+
const browserProviderProjects: BrowserProviderProject[] = browserProjects.map(
|
|
1847
|
+
(project) => ({
|
|
1848
|
+
rootPath: normalize(project.rootPath),
|
|
1849
|
+
provider: project.normalizedConfig.browser.provider,
|
|
1850
|
+
}),
|
|
1851
|
+
);
|
|
1852
|
+
const implementationByProvider = new Map<
|
|
1853
|
+
BrowserProvider,
|
|
1854
|
+
BrowserProviderImplementation
|
|
1855
|
+
>();
|
|
1856
|
+
for (const browserProject of browserProviderProjects) {
|
|
1857
|
+
if (!implementationByProvider.has(browserProject.provider)) {
|
|
1858
|
+
implementationByProvider.set(
|
|
1859
|
+
browserProject.provider,
|
|
1860
|
+
getBrowserProviderImplementation(browserProject.provider),
|
|
1861
|
+
);
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1488
1864
|
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1865
|
+
let activeContainerPage: BrowserProviderPage | null = null;
|
|
1866
|
+
let getHeadlessRunnerPageBySessionId:
|
|
1867
|
+
| ((sessionId: string) => BrowserProviderPage | undefined)
|
|
1868
|
+
| undefined;
|
|
1869
|
+
|
|
1870
|
+
const dispatchBrowserRpcRequest = async ({
|
|
1871
|
+
request,
|
|
1872
|
+
target,
|
|
1873
|
+
}: {
|
|
1874
|
+
request: BrowserRpcRequest;
|
|
1875
|
+
target?: BrowserDispatchRequest['target'];
|
|
1876
|
+
}): Promise<unknown> => {
|
|
1877
|
+
const timeoutFallbackMs = maxTestTimeoutForRpc;
|
|
1878
|
+
const provider = resolveProviderForTestPath({
|
|
1879
|
+
testPath: request.testPath,
|
|
1880
|
+
browserProjects: browserProviderProjects,
|
|
1881
|
+
});
|
|
1882
|
+
const implementation = implementationByProvider.get(provider);
|
|
1883
|
+
if (!implementation) {
|
|
1884
|
+
throw new Error(`Browser provider implementation not found: ${provider}`);
|
|
1885
|
+
}
|
|
1494
1886
|
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1887
|
+
const runnerPage = target?.sessionId
|
|
1888
|
+
? getHeadlessRunnerPageBySessionId?.(target.sessionId)
|
|
1889
|
+
: undefined;
|
|
1890
|
+
|
|
1891
|
+
if (target?.sessionId && !runnerPage) {
|
|
1892
|
+
throw new Error(
|
|
1893
|
+
`Runner page session not found for browser dispatch: ${target.sessionId}`,
|
|
1894
|
+
);
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
if (!runnerPage && !activeContainerPage) {
|
|
1898
|
+
throw new Error('Browser container page is not initialized');
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
try {
|
|
1902
|
+
return await implementation.dispatchRpc({
|
|
1903
|
+
containerPage: runnerPage
|
|
1904
|
+
? undefined
|
|
1905
|
+
: (activeContainerPage ?? undefined),
|
|
1906
|
+
runnerPage,
|
|
1907
|
+
request,
|
|
1908
|
+
timeoutFallbackMs,
|
|
1909
|
+
});
|
|
1910
|
+
} catch (error) {
|
|
1911
|
+
// birpc serializes thrown Errors as `{}` over JSON; throw a string instead.
|
|
1912
|
+
if (error instanceof Error) {
|
|
1913
|
+
throw error.message;
|
|
1914
|
+
}
|
|
1915
|
+
throw String(error);
|
|
1916
|
+
}
|
|
1917
|
+
};
|
|
1918
|
+
|
|
1919
|
+
runtime.dispatchHandlers.set('browser', async (dispatchRequest) => {
|
|
1920
|
+
const request = validateBrowserRpcRequest(dispatchRequest.args);
|
|
1921
|
+
return dispatchBrowserRpcRequest({
|
|
1922
|
+
request,
|
|
1923
|
+
target: dispatchRequest.target,
|
|
1924
|
+
});
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
runtime.setContainerOptions(hostOptions);
|
|
1928
|
+
|
|
1929
|
+
// Track test results from browser runners
|
|
1930
|
+
const reporterResults: TestFileResult[] = [];
|
|
1931
|
+
const caseResults: TestResult[] = [];
|
|
1932
|
+
let fatalError: Error | null = null;
|
|
1933
|
+
|
|
1934
|
+
const snapshotRpcMethods = {
|
|
1935
|
+
async resolveSnapshotPath(testPath: string): Promise<string> {
|
|
1936
|
+
const snapExtension = '.snap';
|
|
1937
|
+
const resolver =
|
|
1938
|
+
context.normalizedConfig.resolveSnapshotPath ||
|
|
1939
|
+
(() =>
|
|
1940
|
+
join(
|
|
1941
|
+
dirname(testPath),
|
|
1942
|
+
'__snapshots__',
|
|
1943
|
+
`${basename(testPath)}${snapExtension}`,
|
|
1944
|
+
));
|
|
1945
|
+
return resolver(testPath, snapExtension);
|
|
1946
|
+
},
|
|
1947
|
+
async readSnapshotFile(filepath: string): Promise<string | null> {
|
|
1948
|
+
try {
|
|
1949
|
+
return await fs.readFile(filepath, 'utf-8');
|
|
1950
|
+
} catch {
|
|
1951
|
+
return null;
|
|
1952
|
+
}
|
|
1953
|
+
},
|
|
1954
|
+
async saveSnapshotFile(filepath: string, content: string): Promise<void> {
|
|
1955
|
+
const dir = dirname(filepath);
|
|
1956
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1957
|
+
await fs.writeFile(filepath, content, 'utf-8');
|
|
1958
|
+
},
|
|
1959
|
+
async removeSnapshotFile(filepath: string): Promise<void> {
|
|
1960
|
+
try {
|
|
1961
|
+
await fs.unlink(filepath);
|
|
1962
|
+
} catch {
|
|
1963
|
+
// ignore if file doesn't exist
|
|
1964
|
+
}
|
|
1965
|
+
},
|
|
1966
|
+
};
|
|
1967
|
+
|
|
1968
|
+
const handleTestFileStart = async (
|
|
1969
|
+
payload: TestFileStartPayload,
|
|
1970
|
+
): Promise<void> => {
|
|
1971
|
+
await Promise.all(
|
|
1972
|
+
context.reporters.map((reporter) =>
|
|
1973
|
+
(reporter as Reporter).onTestFileStart?.({
|
|
1974
|
+
testPath: payload.testPath,
|
|
1975
|
+
tests: [],
|
|
1976
|
+
}),
|
|
1977
|
+
),
|
|
1978
|
+
);
|
|
1979
|
+
};
|
|
1980
|
+
|
|
1981
|
+
const handleTestFileReady = async (
|
|
1982
|
+
payload: TestFileReadyPayload,
|
|
1983
|
+
): Promise<void> => {
|
|
1984
|
+
await Promise.all(
|
|
1985
|
+
context.reporters.map((reporter) =>
|
|
1986
|
+
(reporter as Reporter).onTestFileReady?.(payload),
|
|
1987
|
+
),
|
|
1988
|
+
);
|
|
1989
|
+
};
|
|
1990
|
+
|
|
1991
|
+
const handleTestSuiteStart = async (
|
|
1992
|
+
payload: TestSuiteStartPayload,
|
|
1993
|
+
): Promise<void> => {
|
|
1994
|
+
await Promise.all(
|
|
1995
|
+
context.reporters.map((reporter) =>
|
|
1996
|
+
(reporter as Reporter).onTestSuiteStart?.(payload),
|
|
1997
|
+
),
|
|
1998
|
+
);
|
|
1999
|
+
};
|
|
2000
|
+
|
|
2001
|
+
const handleTestSuiteResult = async (
|
|
2002
|
+
payload: TestSuiteResultPayload,
|
|
2003
|
+
): Promise<void> => {
|
|
2004
|
+
await Promise.all(
|
|
2005
|
+
context.reporters.map((reporter) =>
|
|
2006
|
+
(reporter as Reporter).onTestSuiteResult?.(payload),
|
|
2007
|
+
),
|
|
2008
|
+
);
|
|
2009
|
+
};
|
|
2010
|
+
|
|
2011
|
+
const handleTestCaseStart = async (
|
|
2012
|
+
payload: TestCaseStartPayload,
|
|
2013
|
+
): Promise<void> => {
|
|
2014
|
+
await Promise.all(
|
|
2015
|
+
context.reporters.map((reporter) =>
|
|
2016
|
+
(reporter as Reporter).onTestCaseStart?.(payload),
|
|
2017
|
+
),
|
|
2018
|
+
);
|
|
2019
|
+
};
|
|
2020
|
+
|
|
2021
|
+
const handleTestCaseResult = async (payload: TestResult): Promise<void> => {
|
|
2022
|
+
caseResults.push(payload);
|
|
2023
|
+
await Promise.all(
|
|
2024
|
+
context.reporters.map((reporter) =>
|
|
2025
|
+
(reporter as Reporter).onTestCaseResult?.(payload),
|
|
2026
|
+
),
|
|
2027
|
+
);
|
|
2028
|
+
};
|
|
2029
|
+
|
|
2030
|
+
const handleTestFileComplete = async (
|
|
2031
|
+
payload: TestFileResult,
|
|
2032
|
+
): Promise<void> => {
|
|
2033
|
+
reporterResults.push(payload);
|
|
2034
|
+
context.updateReporterResultState([payload], payload.results);
|
|
2035
|
+
if (payload.snapshotResult) {
|
|
2036
|
+
context.snapshotManager.add(payload.snapshotResult);
|
|
2037
|
+
}
|
|
2038
|
+
await Promise.all(
|
|
2039
|
+
context.reporters.map((reporter) =>
|
|
2040
|
+
(reporter as Reporter).onTestFileResult?.(payload),
|
|
2041
|
+
),
|
|
2042
|
+
);
|
|
2043
|
+
if (payload.status === 'fail') {
|
|
2044
|
+
ensureProcessExitCode(1);
|
|
2045
|
+
}
|
|
2046
|
+
};
|
|
2047
|
+
|
|
2048
|
+
const handleLog = async (payload: LogPayload): Promise<void> => {
|
|
2049
|
+
const log: UserConsoleLog = {
|
|
2050
|
+
content: payload.content,
|
|
2051
|
+
name: payload.level,
|
|
2052
|
+
testPath: payload.testPath,
|
|
2053
|
+
type: payload.type,
|
|
2054
|
+
trace: payload.trace,
|
|
2055
|
+
};
|
|
2056
|
+
const shouldLog =
|
|
2057
|
+
context.normalizedConfig.onConsoleLog?.(log.content) ?? true;
|
|
2058
|
+
if (shouldLog) {
|
|
2059
|
+
await Promise.all(
|
|
2060
|
+
context.reporters.map((reporter) =>
|
|
2061
|
+
(reporter as Reporter).onUserConsoleLog?.(log),
|
|
2062
|
+
),
|
|
2063
|
+
);
|
|
2064
|
+
}
|
|
2065
|
+
};
|
|
2066
|
+
|
|
2067
|
+
const handleFatal = async (payload: FatalPayload): Promise<void> => {
|
|
2068
|
+
const error = new Error(payload.message);
|
|
2069
|
+
error.stack = payload.stack;
|
|
2070
|
+
fatalError = error;
|
|
2071
|
+
ensureProcessExitCode(1);
|
|
2072
|
+
};
|
|
2073
|
+
|
|
2074
|
+
const runSnapshotRpc = async (
|
|
2075
|
+
request: SnapshotRpcRequest,
|
|
2076
|
+
): Promise<unknown> => {
|
|
2077
|
+
switch (request.method) {
|
|
2078
|
+
case 'resolveSnapshotPath':
|
|
2079
|
+
return snapshotRpcMethods.resolveSnapshotPath(request.args.testPath);
|
|
2080
|
+
case 'readSnapshotFile':
|
|
2081
|
+
return snapshotRpcMethods.readSnapshotFile(request.args.filepath);
|
|
2082
|
+
case 'saveSnapshotFile':
|
|
2083
|
+
return snapshotRpcMethods.saveSnapshotFile(
|
|
2084
|
+
request.args.filepath,
|
|
2085
|
+
request.args.content,
|
|
2086
|
+
);
|
|
2087
|
+
case 'removeSnapshotFile':
|
|
2088
|
+
return snapshotRpcMethods.removeSnapshotFile(request.args.filepath);
|
|
2089
|
+
default:
|
|
2090
|
+
return undefined;
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
|
|
2094
|
+
const createDispatchRouter = (options?: HostDispatchRouterOptions) => {
|
|
2095
|
+
return createHostDispatchRouter({
|
|
2096
|
+
routerOptions: options,
|
|
2097
|
+
runnerCallbacks: {
|
|
2098
|
+
onTestFileStart: handleTestFileStart,
|
|
2099
|
+
onTestFileReady: handleTestFileReady,
|
|
2100
|
+
onTestSuiteStart: handleTestSuiteStart,
|
|
2101
|
+
onTestSuiteResult: handleTestSuiteResult,
|
|
2102
|
+
onTestCaseStart: handleTestCaseStart,
|
|
2103
|
+
onTestCaseResult: handleTestCaseResult,
|
|
2104
|
+
onTestFileComplete: handleTestFileComplete,
|
|
2105
|
+
onLog: handleLog,
|
|
2106
|
+
onFatal: handleFatal,
|
|
2107
|
+
},
|
|
2108
|
+
runSnapshotRpc,
|
|
2109
|
+
extensionHandlers: runtime.dispatchHandlers,
|
|
2110
|
+
onDuplicateNamespace: (namespace) => {
|
|
2111
|
+
logger.debug(
|
|
2112
|
+
`[Dispatch] Skip registering dispatch namespace "${namespace}" because it is already reserved`,
|
|
2113
|
+
);
|
|
2114
|
+
},
|
|
2115
|
+
});
|
|
2116
|
+
};
|
|
2117
|
+
|
|
2118
|
+
if (useHeadlessDirect) {
|
|
2119
|
+
// Session-based scheduling path: lifecycle + session index + dispatch routing.
|
|
2120
|
+
type ActiveHeadlessRun = RunSession & {
|
|
2121
|
+
contexts: Set<BrowserProviderContext>;
|
|
2122
|
+
};
|
|
2123
|
+
|
|
2124
|
+
const viewportByProject = mapViewportByProject(projectRuntimeConfigs);
|
|
2125
|
+
const runLifecycle = new RunSessionLifecycle<ActiveHeadlessRun>();
|
|
2126
|
+
const sessionRegistry = new RunnerSessionRegistry();
|
|
2127
|
+
getHeadlessRunnerPageBySessionId = (sessionId) => {
|
|
2128
|
+
return sessionRegistry.getById(sessionId)?.page;
|
|
2129
|
+
};
|
|
2130
|
+
let dispatchRequestCounter = 0;
|
|
2131
|
+
|
|
2132
|
+
const nextDispatchRequestId = (namespace: string): string => {
|
|
2133
|
+
return `${namespace}-${++dispatchRequestCounter}`;
|
|
2134
|
+
};
|
|
2135
|
+
|
|
2136
|
+
const closeContextSafely = async (
|
|
2137
|
+
browserContext: BrowserProviderContext,
|
|
2138
|
+
): Promise<void> => {
|
|
2139
|
+
try {
|
|
2140
|
+
await browserContext.close();
|
|
2141
|
+
} catch {
|
|
2142
|
+
// ignore
|
|
2143
|
+
}
|
|
2144
|
+
};
|
|
2145
|
+
|
|
2146
|
+
const cancelRun = async (
|
|
2147
|
+
run: ActiveHeadlessRun,
|
|
2148
|
+
waitForDone = true,
|
|
2149
|
+
): Promise<void> => {
|
|
2150
|
+
await runLifecycle.cancel(run, {
|
|
2151
|
+
waitForDone,
|
|
2152
|
+
onCancel: async (session) => {
|
|
2153
|
+
await Promise.all(
|
|
2154
|
+
Array.from(session.contexts).map((browserContext) =>
|
|
2155
|
+
closeContextSafely(browserContext),
|
|
2156
|
+
),
|
|
2157
|
+
);
|
|
2158
|
+
},
|
|
2159
|
+
});
|
|
2160
|
+
};
|
|
2161
|
+
|
|
2162
|
+
const dispatchRouter = createDispatchRouter({
|
|
2163
|
+
isRunTokenStale: (runToken) => runLifecycle.isTokenStale(runToken),
|
|
2164
|
+
onStale: (request) => {
|
|
2165
|
+
if (request.namespace === DISPATCH_NAMESPACE_RUNNER) {
|
|
2166
|
+
logger.debug(
|
|
2167
|
+
`[Headless] Dropped stale message "${request.method}" for ${request.target?.testFile ?? 'unknown'}`,
|
|
2168
|
+
);
|
|
2169
|
+
}
|
|
2170
|
+
},
|
|
2171
|
+
});
|
|
2172
|
+
|
|
2173
|
+
const dispatchRunnerMessage = async (
|
|
2174
|
+
run: ActiveHeadlessRun,
|
|
2175
|
+
file: TestFileInfo,
|
|
2176
|
+
sessionId: string,
|
|
2177
|
+
message: BrowserClientMessage,
|
|
2178
|
+
): Promise<void> => {
|
|
2179
|
+
const response = await dispatchRouter.dispatch({
|
|
2180
|
+
requestId: nextDispatchRequestId('runner'),
|
|
2181
|
+
runToken: run.token,
|
|
2182
|
+
namespace: DISPATCH_NAMESPACE_RUNNER,
|
|
2183
|
+
method: message.type,
|
|
2184
|
+
args: 'payload' in message ? message.payload : undefined,
|
|
2185
|
+
target: {
|
|
2186
|
+
sessionId,
|
|
2187
|
+
testFile: file.testPath,
|
|
2188
|
+
projectName: file.projectName,
|
|
2189
|
+
},
|
|
2190
|
+
});
|
|
2191
|
+
|
|
2192
|
+
if (response.stale) {
|
|
2193
|
+
return;
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
if (response.error) {
|
|
2197
|
+
throw new Error(response.error);
|
|
2198
|
+
}
|
|
2199
|
+
};
|
|
2200
|
+
|
|
2201
|
+
const runSingleFile = async (
|
|
2202
|
+
run: ActiveHeadlessRun,
|
|
2203
|
+
file: TestFileInfo,
|
|
2204
|
+
): Promise<void> => {
|
|
2205
|
+
if (run.cancelled || runLifecycle.isTokenStale(run.token)) {
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
const viewport = viewportByProject.get(file.projectName);
|
|
2210
|
+
const browserContext = await browser.newContext({
|
|
2211
|
+
viewport: viewport ?? null,
|
|
2212
|
+
});
|
|
2213
|
+
run.contexts.add(browserContext);
|
|
2214
|
+
|
|
2215
|
+
let page: BrowserProviderPage | null = null;
|
|
2216
|
+
let sessionId: string | null = null;
|
|
2217
|
+
let settled = false;
|
|
2218
|
+
let resolveDone: (() => void) | null = null;
|
|
2219
|
+
|
|
2220
|
+
const markDone = (): void => {
|
|
2221
|
+
if (!settled) {
|
|
2222
|
+
settled = true;
|
|
2223
|
+
resolveDone?.();
|
|
2224
|
+
}
|
|
2225
|
+
};
|
|
2226
|
+
|
|
2227
|
+
const donePromise = new Promise<void>((resolve) => {
|
|
2228
|
+
resolveDone = resolve;
|
|
2229
|
+
});
|
|
2230
|
+
|
|
2231
|
+
const projectRuntime = projectRuntimeConfigs.find(
|
|
2232
|
+
(project) => project.name === file.projectName,
|
|
2233
|
+
);
|
|
2234
|
+
const perFileTimeoutMs =
|
|
2235
|
+
(projectRuntime?.runtimeConfig.testTimeout ?? maxTestTimeoutForRpc) +
|
|
2236
|
+
30_000;
|
|
2237
|
+
|
|
2238
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
2239
|
+
|
|
2240
|
+
try {
|
|
2241
|
+
page = await browserContext.newPage();
|
|
2242
|
+
|
|
2243
|
+
const session = sessionRegistry.register({
|
|
2244
|
+
testFile: file.testPath,
|
|
2245
|
+
projectName: file.projectName,
|
|
2246
|
+
runToken: run.token,
|
|
2247
|
+
mode: 'headless-page',
|
|
2248
|
+
context: browserContext,
|
|
2249
|
+
page,
|
|
2250
|
+
});
|
|
2251
|
+
sessionId = session.id;
|
|
2252
|
+
|
|
2253
|
+
await attachHeadlessRunnerTransport(page, {
|
|
2254
|
+
onDispatchMessage: async (message) => {
|
|
2255
|
+
try {
|
|
2256
|
+
await dispatchRunnerMessage(run, file, session.id, message);
|
|
2257
|
+
if (
|
|
2258
|
+
message.type === 'file-complete' ||
|
|
2259
|
+
message.type === 'complete'
|
|
2260
|
+
) {
|
|
2261
|
+
markDone();
|
|
2262
|
+
} else if (message.type === 'fatal') {
|
|
2263
|
+
markDone();
|
|
2264
|
+
await cancelRun(run, false);
|
|
2265
|
+
}
|
|
2266
|
+
} catch (error) {
|
|
2267
|
+
const formatted = toError(error);
|
|
2268
|
+
await handleFatal({
|
|
2269
|
+
message: formatted.message,
|
|
2270
|
+
stack: formatted.stack,
|
|
2271
|
+
});
|
|
2272
|
+
markDone();
|
|
2273
|
+
await cancelRun(run, false);
|
|
2274
|
+
}
|
|
2275
|
+
},
|
|
2276
|
+
onDispatchRpc: async (request) => {
|
|
2277
|
+
return dispatchRouter.dispatch({
|
|
2278
|
+
...request,
|
|
2279
|
+
runToken: run.token,
|
|
2280
|
+
target: {
|
|
2281
|
+
sessionId: session.id,
|
|
2282
|
+
testFile: file.testPath,
|
|
2283
|
+
projectName: file.projectName,
|
|
2284
|
+
...request.target,
|
|
2285
|
+
},
|
|
2286
|
+
});
|
|
2287
|
+
},
|
|
2288
|
+
});
|
|
2289
|
+
|
|
2290
|
+
const inlineOptions: BrowserHostConfig = {
|
|
2291
|
+
...hostOptions,
|
|
2292
|
+
testFile: file.testPath,
|
|
2293
|
+
runId: `${run.token}:${session.id}`,
|
|
2294
|
+
};
|
|
2295
|
+
const serializedOptions = serializeForInlineScript(inlineOptions);
|
|
2296
|
+
await page.addInitScript(
|
|
2297
|
+
`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
|
|
2298
|
+
);
|
|
2299
|
+
|
|
2300
|
+
await page.goto(`http://localhost:${port}/runner.html`, {
|
|
2301
|
+
waitUntil: 'load',
|
|
2302
|
+
});
|
|
2303
|
+
|
|
2304
|
+
const timeoutPromise = new Promise<'timeout'>((resolve) => {
|
|
2305
|
+
timeoutId = setTimeout(() => resolve('timeout'), perFileTimeoutMs);
|
|
2306
|
+
});
|
|
2307
|
+
|
|
2308
|
+
const state = await Promise.race([
|
|
2309
|
+
donePromise.then(() => 'done' as const),
|
|
2310
|
+
timeoutPromise,
|
|
2311
|
+
run.cancelSignal.then(() => 'cancelled' as const),
|
|
2312
|
+
]);
|
|
2313
|
+
|
|
2314
|
+
if (state === 'cancelled') {
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
if (
|
|
2319
|
+
state === 'timeout' &&
|
|
2320
|
+
runLifecycle.isTokenActive(run.token) &&
|
|
2321
|
+
!run.cancelled
|
|
2322
|
+
) {
|
|
2323
|
+
await handleFatal({
|
|
2324
|
+
message: `Test execution timeout after ${perFileTimeoutMs / 1000}s for ${file.testPath}.`,
|
|
2325
|
+
});
|
|
2326
|
+
await cancelRun(run, false);
|
|
2327
|
+
}
|
|
2328
|
+
} catch (error) {
|
|
2329
|
+
if (runLifecycle.isTokenActive(run.token) && !run.cancelled) {
|
|
2330
|
+
const formatted = toError(error);
|
|
2331
|
+
await handleFatal({
|
|
2332
|
+
message: formatted.message,
|
|
2333
|
+
stack: formatted.stack,
|
|
2334
|
+
});
|
|
2335
|
+
await cancelRun(run, false);
|
|
2336
|
+
}
|
|
2337
|
+
} finally {
|
|
2338
|
+
if (timeoutId) {
|
|
2339
|
+
clearTimeout(timeoutId);
|
|
2340
|
+
}
|
|
2341
|
+
if (page) {
|
|
2342
|
+
try {
|
|
2343
|
+
await page.close();
|
|
2344
|
+
} catch {
|
|
2345
|
+
// ignore
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
if (sessionId) {
|
|
2349
|
+
sessionRegistry.deleteById(sessionId);
|
|
2350
|
+
}
|
|
2351
|
+
run.contexts.delete(browserContext);
|
|
2352
|
+
await closeContextSafely(browserContext);
|
|
2353
|
+
}
|
|
2354
|
+
};
|
|
2355
|
+
|
|
2356
|
+
const runFilesWithPool = async (files: TestFileInfo[]): Promise<void> => {
|
|
2357
|
+
if (files.length === 0) {
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
const previous = runLifecycle.activeSession;
|
|
2362
|
+
if (previous) {
|
|
2363
|
+
await cancelRun(previous);
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
const run = runLifecycle.createSession((token) => ({
|
|
2367
|
+
...createRunSession(token),
|
|
2368
|
+
contexts: new Set<BrowserProviderContext>(),
|
|
2369
|
+
}));
|
|
2370
|
+
|
|
2371
|
+
const queue = [...files];
|
|
2372
|
+
const concurrency = getHeadlessConcurrency(context, queue.length);
|
|
2373
|
+
|
|
2374
|
+
const worker = async (): Promise<void> => {
|
|
2375
|
+
while (
|
|
2376
|
+
queue.length > 0 &&
|
|
2377
|
+
!run.cancelled &&
|
|
2378
|
+
runLifecycle.isTokenActive(run.token)
|
|
2379
|
+
) {
|
|
2380
|
+
const next = queue.shift();
|
|
2381
|
+
if (!next) {
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
await runSingleFile(run, next);
|
|
2385
|
+
}
|
|
2386
|
+
};
|
|
2387
|
+
|
|
2388
|
+
run.done = Promise.all(
|
|
2389
|
+
Array.from(
|
|
2390
|
+
{ length: Math.min(queue.length, Math.max(concurrency, 1)) },
|
|
2391
|
+
() => worker(),
|
|
2392
|
+
),
|
|
2393
|
+
).then(() => {});
|
|
2394
|
+
|
|
2395
|
+
await run.done;
|
|
2396
|
+
runLifecycle.clearIfActive(run);
|
|
2397
|
+
};
|
|
2398
|
+
|
|
2399
|
+
const latestRerunScheduler = createHeadlessLatestRerunScheduler<
|
|
2400
|
+
TestFileInfo,
|
|
2401
|
+
ActiveHeadlessRun
|
|
2402
|
+
>({
|
|
2403
|
+
getActiveRun: () => runLifecycle.activeSession,
|
|
2404
|
+
isRunCancelled: (run) => run.cancelled,
|
|
2405
|
+
invalidateActiveRun: () => {
|
|
2406
|
+
runLifecycle.invalidateActiveToken();
|
|
2407
|
+
},
|
|
2408
|
+
interruptActiveRun: async (run) => {
|
|
2409
|
+
await cancelRun(run, false);
|
|
2410
|
+
},
|
|
2411
|
+
runFiles: async (files) => {
|
|
2412
|
+
await notifyTestRunStart();
|
|
2413
|
+
|
|
2414
|
+
const rerunStartTime = Date.now();
|
|
2415
|
+
const fatalErrorBeforeRun = fatalError;
|
|
2416
|
+
let rerunError: Error | undefined;
|
|
2417
|
+
|
|
2418
|
+
try {
|
|
2419
|
+
await runFilesWithPool(files);
|
|
2420
|
+
} catch (error) {
|
|
2421
|
+
rerunError = toError(error);
|
|
2422
|
+
throw error;
|
|
2423
|
+
} finally {
|
|
2424
|
+
const testTime = Math.max(0, Date.now() - rerunStartTime);
|
|
2425
|
+
const rerunFatalError =
|
|
2426
|
+
fatalError && fatalError !== fatalErrorBeforeRun
|
|
2427
|
+
? fatalError
|
|
2428
|
+
: undefined;
|
|
2429
|
+
await notifyTestRunEnd({
|
|
2430
|
+
duration: {
|
|
2431
|
+
totalTime: testTime,
|
|
2432
|
+
buildTime: 0,
|
|
2433
|
+
testTime,
|
|
2434
|
+
},
|
|
2435
|
+
filterRerunTestPaths: files.map((file) => file.testPath),
|
|
2436
|
+
unhandledErrors: rerunError
|
|
2437
|
+
? [rerunError]
|
|
2438
|
+
: rerunFatalError
|
|
2439
|
+
? [rerunFatalError]
|
|
2440
|
+
: undefined,
|
|
2441
|
+
});
|
|
2442
|
+
logBrowserWatchReadyMessage(enableCliShortcuts);
|
|
2443
|
+
}
|
|
2444
|
+
},
|
|
2445
|
+
onError: async (error) => {
|
|
2446
|
+
const formatted = toError(error);
|
|
2447
|
+
await handleFatal({
|
|
2448
|
+
message: formatted.message,
|
|
2449
|
+
stack: formatted.stack,
|
|
2450
|
+
});
|
|
2451
|
+
},
|
|
2452
|
+
onInterrupt: (run) => {
|
|
2453
|
+
logger.debug(
|
|
2454
|
+
`[Headless] Interrupting active run token ${run.token} before scheduling latest rerun`,
|
|
2455
|
+
);
|
|
2456
|
+
},
|
|
2457
|
+
});
|
|
2458
|
+
|
|
2459
|
+
const testStart = Date.now();
|
|
2460
|
+
await runFilesWithPool(allTestFiles);
|
|
2461
|
+
const testTime = Date.now() - testStart;
|
|
2462
|
+
|
|
2463
|
+
if (isWatchMode) {
|
|
2464
|
+
triggerRerun = async () => {
|
|
2465
|
+
const newProjectEntries = await collectProjectEntries(context);
|
|
2466
|
+
const rerunPlan = planWatchRerun({
|
|
2467
|
+
projectEntries: newProjectEntries,
|
|
2468
|
+
previousTestFiles: watchContext.lastTestFiles,
|
|
2469
|
+
affectedTestFiles: watchContext.affectedTestFiles,
|
|
2470
|
+
});
|
|
2471
|
+
watchContext.affectedTestFiles = [];
|
|
2472
|
+
|
|
2473
|
+
if (rerunPlan.filesChanged) {
|
|
2474
|
+
const deletedTestPaths = collectDeletedTestPaths(
|
|
2475
|
+
watchContext.lastTestFiles,
|
|
2476
|
+
rerunPlan.currentTestFiles,
|
|
2477
|
+
);
|
|
2478
|
+
if (deletedTestPaths.length > 0) {
|
|
2479
|
+
context.updateReporterResultState([], [], deletedTestPaths);
|
|
2480
|
+
}
|
|
2481
|
+
watchContext.lastTestFiles = rerunPlan.currentTestFiles;
|
|
2482
|
+
if (rerunPlan.currentTestFiles.length === 0) {
|
|
2483
|
+
await latestRerunScheduler.enqueueLatest([]);
|
|
2484
|
+
logger.log(
|
|
2485
|
+
color.cyan('No browser test files remain after update.\n'),
|
|
2486
|
+
);
|
|
2487
|
+
logBrowserWatchReadyMessage(enableCliShortcuts);
|
|
2488
|
+
return;
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
logger.log(
|
|
2492
|
+
color.cyan(
|
|
2493
|
+
`Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`,
|
|
2494
|
+
),
|
|
2495
|
+
);
|
|
2496
|
+
void latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
|
|
2497
|
+
return;
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
if (rerunPlan.affectedTestFiles.length === 0) {
|
|
2501
|
+
logger.log(
|
|
2502
|
+
color.cyan(
|
|
2503
|
+
'No affected browser test files detected, skipping re-run.\n',
|
|
2504
|
+
),
|
|
2505
|
+
);
|
|
2506
|
+
logBrowserWatchReadyMessage(enableCliShortcuts);
|
|
2507
|
+
return;
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
logger.log(
|
|
2511
|
+
color.cyan(
|
|
2512
|
+
`Re-running ${rerunPlan.affectedTestFiles.length} affected test file(s)...\n`,
|
|
2513
|
+
),
|
|
2514
|
+
);
|
|
2515
|
+
void latestRerunScheduler.enqueueLatest(rerunPlan.affectedTestFiles);
|
|
2516
|
+
};
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
const closeHeadlessRuntime = !isWatchMode
|
|
2520
|
+
? async () => {
|
|
2521
|
+
sessionRegistry.clear();
|
|
2522
|
+
await destroyBrowserRuntime(runtime);
|
|
2523
|
+
}
|
|
2524
|
+
: undefined;
|
|
2525
|
+
|
|
2526
|
+
if (fatalError) {
|
|
2527
|
+
return failWithError(fatalError, closeHeadlessRuntime);
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
const duration = {
|
|
2531
|
+
totalTime: buildTime + testTime,
|
|
2532
|
+
buildTime,
|
|
2533
|
+
testTime,
|
|
2534
|
+
};
|
|
2535
|
+
|
|
2536
|
+
context.updateReporterResultState(reporterResults, caseResults);
|
|
2537
|
+
|
|
2538
|
+
const isFailure = reporterResults.some(
|
|
2539
|
+
(result: TestFileResult) => result.status === 'fail',
|
|
2540
|
+
);
|
|
2541
|
+
if (isFailure) {
|
|
2542
|
+
ensureProcessExitCode(1);
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
const result = {
|
|
2546
|
+
results: reporterResults,
|
|
2547
|
+
testResults: caseResults,
|
|
2548
|
+
duration,
|
|
2549
|
+
hasFailure: isFailure,
|
|
2550
|
+
getSourcemap: getBrowserSourcemap,
|
|
2551
|
+
resolveSourcemap: resolveBrowserSourcemap,
|
|
2552
|
+
close: skipOnTestRunEnd ? closeHeadlessRuntime : undefined,
|
|
2553
|
+
};
|
|
2554
|
+
|
|
2555
|
+
if (!skipOnTestRunEnd) {
|
|
2556
|
+
try {
|
|
2557
|
+
await notifyTestRunEnd({ duration });
|
|
2558
|
+
} finally {
|
|
2559
|
+
await closeHeadlessRuntime?.();
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
if (isWatchMode && triggerRerun) {
|
|
2564
|
+
watchContext.hooksEnabled = true;
|
|
2565
|
+
logBrowserWatchReadyMessage(enableCliShortcuts);
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
return result;
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
let currentTestFiles = allTestFiles;
|
|
2572
|
+
const RUNNER_FRAMES_READY_TIMEOUT_MS = 30_000;
|
|
2573
|
+
let currentRunnerFramesSignature: string | null = null;
|
|
2574
|
+
const runnerFramesWaiters = new Map<string, Set<() => void>>();
|
|
2575
|
+
|
|
2576
|
+
const createTestFilesSignature = (testFiles: readonly string[]): string => {
|
|
2577
|
+
return JSON.stringify(testFiles.map((testFile) => normalize(testFile)));
|
|
2578
|
+
};
|
|
2579
|
+
|
|
2580
|
+
const markRunnerFramesReady = (testFiles: string[]): void => {
|
|
2581
|
+
const signature = createTestFilesSignature(testFiles);
|
|
2582
|
+
currentRunnerFramesSignature = signature;
|
|
2583
|
+
const waiters = runnerFramesWaiters.get(signature);
|
|
2584
|
+
if (!waiters) {
|
|
2585
|
+
return;
|
|
2586
|
+
}
|
|
2587
|
+
runnerFramesWaiters.delete(signature);
|
|
2588
|
+
for (const waiter of waiters) {
|
|
2589
|
+
waiter();
|
|
2590
|
+
}
|
|
2591
|
+
};
|
|
2592
|
+
|
|
2593
|
+
const waitForRunnerFramesReady = async (
|
|
2594
|
+
testFiles: readonly string[],
|
|
2595
|
+
): Promise<void> => {
|
|
2596
|
+
const signature = createTestFilesSignature(testFiles);
|
|
2597
|
+
if (currentRunnerFramesSignature === signature) {
|
|
2598
|
+
return;
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
await new Promise<void>((resolve, reject) => {
|
|
2602
|
+
const waiters =
|
|
2603
|
+
runnerFramesWaiters.get(signature) ?? new Set<() => void>();
|
|
2604
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
2605
|
+
|
|
2606
|
+
const cleanup = () => {
|
|
2607
|
+
const currentWaiters = runnerFramesWaiters.get(signature);
|
|
2608
|
+
if (!currentWaiters) {
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
currentWaiters.delete(onReady);
|
|
2612
|
+
if (currentWaiters.size === 0) {
|
|
2613
|
+
runnerFramesWaiters.delete(signature);
|
|
2614
|
+
}
|
|
2615
|
+
};
|
|
2616
|
+
|
|
2617
|
+
const onReady = () => {
|
|
2618
|
+
if (timeoutId) {
|
|
2619
|
+
clearTimeout(timeoutId);
|
|
2620
|
+
}
|
|
2621
|
+
cleanup();
|
|
2622
|
+
resolve();
|
|
2623
|
+
};
|
|
2624
|
+
|
|
2625
|
+
timeoutId = setTimeout(() => {
|
|
2626
|
+
cleanup();
|
|
2627
|
+
reject(
|
|
2628
|
+
new Error(
|
|
2629
|
+
`Timed out waiting for headed runner frames to be ready for ${testFiles.length} file(s).`,
|
|
2630
|
+
),
|
|
2631
|
+
);
|
|
2632
|
+
}, RUNNER_FRAMES_READY_TIMEOUT_MS);
|
|
2633
|
+
|
|
2634
|
+
waiters.add(onReady);
|
|
2635
|
+
runnerFramesWaiters.set(signature, waiters);
|
|
2636
|
+
|
|
2637
|
+
if (currentRunnerFramesSignature === signature) {
|
|
2638
|
+
onReady();
|
|
2639
|
+
}
|
|
2640
|
+
});
|
|
2641
|
+
};
|
|
2642
|
+
|
|
2643
|
+
const getTestFileInfo = (testFile: string): TestFileInfo => {
|
|
2644
|
+
const normalizedTestFile = normalize(testFile);
|
|
2645
|
+
const fileInfo = currentTestFiles.find(
|
|
2646
|
+
(file) => file.testPath === normalizedTestFile,
|
|
2647
|
+
);
|
|
2648
|
+
if (!fileInfo) {
|
|
2649
|
+
throw new Error(`Unknown browser test file: ${JSON.stringify(testFile)}`);
|
|
2650
|
+
}
|
|
2651
|
+
return fileInfo;
|
|
2652
|
+
};
|
|
2653
|
+
|
|
2654
|
+
const getHeadedPerFileTimeoutMs = (file: TestFileInfo): number => {
|
|
2655
|
+
const projectRuntime = projectRuntimeConfigs.find(
|
|
2656
|
+
(project) => project.name === file.projectName,
|
|
2657
|
+
);
|
|
2658
|
+
return (
|
|
2659
|
+
(projectRuntime?.runtimeConfig.testTimeout ?? maxTestTimeoutForRpc) +
|
|
2660
|
+
30_000
|
|
2661
|
+
);
|
|
2662
|
+
};
|
|
1500
2663
|
|
|
1501
2664
|
// Open a container page for user to view (reuse in watch mode)
|
|
1502
|
-
let containerContext:
|
|
1503
|
-
let containerPage:
|
|
2665
|
+
let containerContext: BrowserProviderContext;
|
|
2666
|
+
let containerPage: BrowserProviderPage;
|
|
1504
2667
|
let isNewPage = false;
|
|
1505
2668
|
|
|
1506
2669
|
if (isWatchMode && runtime.containerPage && runtime.containerContext) {
|
|
@@ -1515,11 +2678,11 @@ export const runBrowserController = async (
|
|
|
1515
2678
|
containerPage = await containerContext.newPage();
|
|
1516
2679
|
|
|
1517
2680
|
// Prevent popup windows from being created
|
|
1518
|
-
containerPage.on('popup', async (popup:
|
|
2681
|
+
containerPage.on('popup', async (popup: BrowserProviderPage) => {
|
|
1519
2682
|
await popup.close().catch(() => {});
|
|
1520
2683
|
});
|
|
1521
2684
|
|
|
1522
|
-
containerContext.on('page', async (page:
|
|
2685
|
+
containerContext.on('page', async (page: BrowserProviderPage) => {
|
|
1523
2686
|
if (page !== containerPage) {
|
|
1524
2687
|
await page.close().catch(() => {});
|
|
1525
2688
|
}
|
|
@@ -1531,18 +2694,52 @@ export const runBrowserController = async (
|
|
|
1531
2694
|
}
|
|
1532
2695
|
|
|
1533
2696
|
// Forward browser console to terminal
|
|
1534
|
-
containerPage.on('console', (msg
|
|
2697
|
+
containerPage.on('console', (msg) => {
|
|
1535
2698
|
const text = msg.text();
|
|
1536
|
-
if (
|
|
1537
|
-
text.startsWith('[Container]') ||
|
|
1538
|
-
text.startsWith('[Runner]') ||
|
|
1539
|
-
text.startsWith('[Scheduler]')
|
|
1540
|
-
) {
|
|
2699
|
+
if (text.startsWith('[Container]') || text.startsWith('[Runner]')) {
|
|
1541
2700
|
logger.log(color.gray(`[Browser Console] ${text}`));
|
|
1542
2701
|
}
|
|
1543
2702
|
});
|
|
1544
2703
|
}
|
|
1545
2704
|
|
|
2705
|
+
activeContainerPage = containerPage;
|
|
2706
|
+
|
|
2707
|
+
const dispatchRouter = createDispatchRouter();
|
|
2708
|
+
const headedReloadQueue = createHeadedSerialTaskQueue();
|
|
2709
|
+
let enqueueHeadedReload = async (
|
|
2710
|
+
_file: TestFileInfo,
|
|
2711
|
+
_testNamePattern?: string,
|
|
2712
|
+
): Promise<void> => {
|
|
2713
|
+
throw new Error('Headed reload queue is not initialized');
|
|
2714
|
+
};
|
|
2715
|
+
|
|
2716
|
+
const reloadTestFileWithTimeout = async (
|
|
2717
|
+
file: TestFileInfo,
|
|
2718
|
+
testNamePattern?: string,
|
|
2719
|
+
): Promise<void> => {
|
|
2720
|
+
const timeoutMs = getHeadedPerFileTimeoutMs(file);
|
|
2721
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
2722
|
+
|
|
2723
|
+
try {
|
|
2724
|
+
await Promise.race([
|
|
2725
|
+
rpcManager.reloadTestFile(file.testPath, testNamePattern),
|
|
2726
|
+
new Promise<never>((_, reject) => {
|
|
2727
|
+
timeoutId = setTimeout(() => {
|
|
2728
|
+
reject(
|
|
2729
|
+
new Error(
|
|
2730
|
+
`Headed test execution timeout after ${timeoutMs / 1000}s for ${file.testPath}.`,
|
|
2731
|
+
),
|
|
2732
|
+
);
|
|
2733
|
+
}, timeoutMs);
|
|
2734
|
+
}),
|
|
2735
|
+
]);
|
|
2736
|
+
} finally {
|
|
2737
|
+
if (timeoutId) {
|
|
2738
|
+
clearTimeout(timeoutId);
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
};
|
|
2742
|
+
|
|
1546
2743
|
// Create RPC methods that can access test state variables
|
|
1547
2744
|
const createRpcMethods = (): HostRpcMethods => ({
|
|
1548
2745
|
async rerunTest(testFile: string, testNamePattern?: string) {
|
|
@@ -1554,105 +2751,32 @@ export const runBrowserController = async (
|
|
|
1554
2751
|
`\nRe-running test: ${displayPath}${testNamePattern ? ` (pattern: ${testNamePattern})` : ''}\n`,
|
|
1555
2752
|
),
|
|
1556
2753
|
);
|
|
1557
|
-
await
|
|
2754
|
+
await enqueueHeadedReload(getTestFileInfo(testFile), testNamePattern);
|
|
1558
2755
|
},
|
|
1559
2756
|
async getTestFiles() {
|
|
1560
|
-
return
|
|
2757
|
+
return currentTestFiles;
|
|
2758
|
+
},
|
|
2759
|
+
async onRunnerFramesReady(testFiles: string[]) {
|
|
2760
|
+
markRunnerFramesReady(testFiles);
|
|
1561
2761
|
},
|
|
1562
2762
|
async onTestFileStart(payload: TestFileStartPayload) {
|
|
1563
|
-
await
|
|
1564
|
-
context.reporters.map((reporter) =>
|
|
1565
|
-
(reporter as Reporter).onTestFileStart?.({
|
|
1566
|
-
testPath: payload.testPath,
|
|
1567
|
-
tests: [],
|
|
1568
|
-
}),
|
|
1569
|
-
),
|
|
1570
|
-
);
|
|
2763
|
+
await handleTestFileStart(payload);
|
|
1571
2764
|
},
|
|
1572
2765
|
async onTestCaseResult(payload: TestResult) {
|
|
1573
|
-
|
|
1574
|
-
await Promise.all(
|
|
1575
|
-
context.reporters.map((reporter) =>
|
|
1576
|
-
(reporter as Reporter).onTestCaseResult?.(payload),
|
|
1577
|
-
),
|
|
1578
|
-
);
|
|
2766
|
+
await handleTestCaseResult(payload);
|
|
1579
2767
|
},
|
|
1580
2768
|
async onTestFileComplete(payload: TestFileResult) {
|
|
1581
|
-
|
|
1582
|
-
if (payload.snapshotResult) {
|
|
1583
|
-
context.snapshotManager.add(payload.snapshotResult);
|
|
1584
|
-
}
|
|
1585
|
-
await Promise.all(
|
|
1586
|
-
context.reporters.map((reporter) =>
|
|
1587
|
-
(reporter as Reporter).onTestFileResult?.(payload),
|
|
1588
|
-
),
|
|
1589
|
-
);
|
|
1590
|
-
|
|
1591
|
-
completedTests++;
|
|
1592
|
-
if (completedTests >= allTestFiles.length && resolveAllTests) {
|
|
1593
|
-
resolveAllTests();
|
|
1594
|
-
}
|
|
2769
|
+
await handleTestFileComplete(payload);
|
|
1595
2770
|
},
|
|
1596
2771
|
async onLog(payload: LogPayload) {
|
|
1597
|
-
|
|
1598
|
-
content: payload.content,
|
|
1599
|
-
name: payload.level,
|
|
1600
|
-
testPath: payload.testPath,
|
|
1601
|
-
type: payload.type,
|
|
1602
|
-
trace: payload.trace,
|
|
1603
|
-
};
|
|
1604
|
-
|
|
1605
|
-
// Check onConsoleLog filter
|
|
1606
|
-
const shouldLog =
|
|
1607
|
-
context.normalizedConfig.onConsoleLog?.(log.content) ?? true;
|
|
1608
|
-
|
|
1609
|
-
if (shouldLog) {
|
|
1610
|
-
await Promise.all(
|
|
1611
|
-
context.reporters.map((reporter) =>
|
|
1612
|
-
(reporter as Reporter).onUserConsoleLog?.(log),
|
|
1613
|
-
),
|
|
1614
|
-
);
|
|
1615
|
-
}
|
|
2772
|
+
await handleLog(payload);
|
|
1616
2773
|
},
|
|
1617
2774
|
async onFatal(payload: FatalPayload) {
|
|
1618
|
-
|
|
1619
|
-
fatalError.stack = payload.stack;
|
|
1620
|
-
if (resolveAllTests) {
|
|
1621
|
-
resolveAllTests();
|
|
1622
|
-
}
|
|
1623
|
-
},
|
|
1624
|
-
// Snapshot file operations
|
|
1625
|
-
async resolveSnapshotPath(testPath: string) {
|
|
1626
|
-
const snapExtension = '.snap';
|
|
1627
|
-
const resolver =
|
|
1628
|
-
context.normalizedConfig.resolveSnapshotPath ||
|
|
1629
|
-
// test/index.ts -> test/__snapshots__/index.ts.snap
|
|
1630
|
-
(() =>
|
|
1631
|
-
join(
|
|
1632
|
-
dirname(testPath),
|
|
1633
|
-
'__snapshots__',
|
|
1634
|
-
`${basename(testPath)}${snapExtension}`,
|
|
1635
|
-
));
|
|
1636
|
-
return resolver(testPath, snapExtension);
|
|
1637
|
-
},
|
|
1638
|
-
async readSnapshotFile(filepath: string) {
|
|
1639
|
-
try {
|
|
1640
|
-
return await fs.readFile(filepath, 'utf-8');
|
|
1641
|
-
} catch {
|
|
1642
|
-
return null;
|
|
1643
|
-
}
|
|
2775
|
+
await handleFatal(payload);
|
|
1644
2776
|
},
|
|
1645
|
-
async
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
await fs.writeFile(filepath, content, 'utf-8');
|
|
1649
|
-
},
|
|
1650
|
-
async removeSnapshotFile(filepath: string) {
|
|
1651
|
-
try {
|
|
1652
|
-
await fs.unlink(filepath);
|
|
1653
|
-
} catch {
|
|
1654
|
-
// ignore if file doesn't exist
|
|
1655
|
-
}
|
|
2777
|
+
async dispatch(request: BrowserDispatchRequest) {
|
|
2778
|
+
// Headed/container path now shares the same dispatch contract as headless.
|
|
2779
|
+
return dispatchRouter.dispatch(request);
|
|
1656
2780
|
},
|
|
1657
2781
|
});
|
|
1658
2782
|
|
|
@@ -1678,13 +2802,7 @@ export const runBrowserController = async (
|
|
|
1678
2802
|
|
|
1679
2803
|
// Only navigate on first creation
|
|
1680
2804
|
if (isNewPage) {
|
|
1681
|
-
const pagePath =
|
|
1682
|
-
if (useSchedulerPage) {
|
|
1683
|
-
const serializedOptions = serializeForInlineScript(hostOptions);
|
|
1684
|
-
await containerPage.addInitScript(
|
|
1685
|
-
`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
|
|
1686
|
-
);
|
|
1687
|
-
}
|
|
2805
|
+
const pagePath = '/';
|
|
1688
2806
|
await containerPage.goto(`http://localhost:${port}${pagePath}`, {
|
|
1689
2807
|
waitUntil: 'load',
|
|
1690
2808
|
});
|
|
@@ -1696,31 +2814,33 @@ export const runBrowserController = async (
|
|
|
1696
2814
|
);
|
|
1697
2815
|
}
|
|
1698
2816
|
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
color.yellow(
|
|
1711
|
-
`\nTest execution timeout after ${totalTimeoutMs / 1000}s. ` +
|
|
1712
|
-
`Completed: ${completedTests}/${allTestFiles.length}\n`,
|
|
1713
|
-
),
|
|
1714
|
-
);
|
|
1715
|
-
resolve();
|
|
1716
|
-
}, totalTimeoutMs);
|
|
1717
|
-
});
|
|
2817
|
+
enqueueHeadedReload = async (
|
|
2818
|
+
file: TestFileInfo,
|
|
2819
|
+
testNamePattern?: string,
|
|
2820
|
+
): Promise<void> => {
|
|
2821
|
+
return headedReloadQueue.enqueue(async () => {
|
|
2822
|
+
if (fatalError) {
|
|
2823
|
+
return;
|
|
2824
|
+
}
|
|
2825
|
+
await reloadTestFileWithTimeout(file, testNamePattern);
|
|
2826
|
+
});
|
|
2827
|
+
};
|
|
1718
2828
|
|
|
1719
2829
|
const testStart = Date.now();
|
|
1720
|
-
|
|
2830
|
+
try {
|
|
2831
|
+
await waitForRunnerFramesReady(
|
|
2832
|
+
currentTestFiles.map((file) => file.testPath),
|
|
2833
|
+
);
|
|
1721
2834
|
|
|
1722
|
-
|
|
1723
|
-
|
|
2835
|
+
for (const file of currentTestFiles) {
|
|
2836
|
+
await enqueueHeadedReload(file);
|
|
2837
|
+
if (fatalError) {
|
|
2838
|
+
break;
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
} catch (error) {
|
|
2842
|
+
fatalError = fatalError ?? toError(error);
|
|
2843
|
+
ensureProcessExitCode(1);
|
|
1724
2844
|
}
|
|
1725
2845
|
|
|
1726
2846
|
const testTime = Date.now() - testStart;
|
|
@@ -1729,63 +2849,96 @@ export const runBrowserController = async (
|
|
|
1729
2849
|
if (isWatchMode) {
|
|
1730
2850
|
triggerRerun = async () => {
|
|
1731
2851
|
const newProjectEntries = await collectProjectEntries(context);
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
})),
|
|
1739
|
-
);
|
|
2852
|
+
const rerunPlan = planWatchRerun({
|
|
2853
|
+
projectEntries: newProjectEntries,
|
|
2854
|
+
previousTestFiles: watchContext.lastTestFiles,
|
|
2855
|
+
affectedTestFiles: watchContext.affectedTestFiles,
|
|
2856
|
+
});
|
|
2857
|
+
watchContext.affectedTestFiles = [];
|
|
1740
2858
|
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
2859
|
+
if (rerunPlan.filesChanged) {
|
|
2860
|
+
const deletedTestPaths = collectDeletedTestPaths(
|
|
2861
|
+
watchContext.lastTestFiles,
|
|
2862
|
+
rerunPlan.currentTestFiles,
|
|
1745
2863
|
);
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
watchContext.lastTestFiles = currentTestFiles;
|
|
2864
|
+
if (deletedTestPaths.length > 0) {
|
|
2865
|
+
context.updateReporterResultState([], [], deletedTestPaths);
|
|
2866
|
+
}
|
|
2867
|
+
watchContext.lastTestFiles = rerunPlan.currentTestFiles;
|
|
2868
|
+
currentTestFiles = rerunPlan.currentTestFiles;
|
|
1752
2869
|
await rpcManager.notifyTestFileUpdate(currentTestFiles);
|
|
2870
|
+
await waitForRunnerFramesReady(
|
|
2871
|
+
currentTestFiles.map((file) => file.testPath),
|
|
2872
|
+
);
|
|
1753
2873
|
}
|
|
1754
2874
|
|
|
1755
|
-
|
|
1756
|
-
watchContext.affectedTestFiles = [];
|
|
1757
|
-
|
|
1758
|
-
if (affectedFiles.length > 0) {
|
|
2875
|
+
if (rerunPlan.normalizedAffectedTestFiles.length > 0) {
|
|
1759
2876
|
logger.log(
|
|
1760
2877
|
color.cyan(
|
|
1761
|
-
`Re-running ${
|
|
2878
|
+
`Re-running ${rerunPlan.normalizedAffectedTestFiles.length} affected test file(s)...\n`,
|
|
1762
2879
|
),
|
|
1763
2880
|
);
|
|
1764
|
-
|
|
1765
|
-
|
|
2881
|
+
await notifyTestRunStart();
|
|
2882
|
+
|
|
2883
|
+
const rerunStartTime = Date.now();
|
|
2884
|
+
const fatalErrorBeforeRun = fatalError;
|
|
2885
|
+
let rerunError: Error | undefined;
|
|
2886
|
+
|
|
2887
|
+
try {
|
|
2888
|
+
for (const testFile of rerunPlan.normalizedAffectedTestFiles) {
|
|
2889
|
+
await enqueueHeadedReload(getTestFileInfo(testFile));
|
|
2890
|
+
}
|
|
2891
|
+
} catch (error) {
|
|
2892
|
+
rerunError = toError(error);
|
|
2893
|
+
throw error;
|
|
2894
|
+
} finally {
|
|
2895
|
+
const testTime = Math.max(0, Date.now() - rerunStartTime);
|
|
2896
|
+
const rerunFatalError =
|
|
2897
|
+
fatalError && fatalError !== fatalErrorBeforeRun
|
|
2898
|
+
? fatalError
|
|
2899
|
+
: undefined;
|
|
2900
|
+
await notifyTestRunEnd({
|
|
2901
|
+
duration: {
|
|
2902
|
+
totalTime: testTime,
|
|
2903
|
+
buildTime: 0,
|
|
2904
|
+
testTime,
|
|
2905
|
+
},
|
|
2906
|
+
filterRerunTestPaths: rerunPlan.normalizedAffectedTestFiles,
|
|
2907
|
+
unhandledErrors: rerunError
|
|
2908
|
+
? [rerunError]
|
|
2909
|
+
: rerunFatalError
|
|
2910
|
+
? [rerunFatalError]
|
|
2911
|
+
: undefined,
|
|
2912
|
+
});
|
|
2913
|
+
logBrowserWatchReadyMessage(enableCliShortcuts);
|
|
1766
2914
|
}
|
|
1767
|
-
} else if (!filesChanged) {
|
|
2915
|
+
} else if (!rerunPlan.filesChanged) {
|
|
1768
2916
|
logger.log(color.cyan('Tests will be re-executed automatically\n'));
|
|
2917
|
+
logBrowserWatchReadyMessage(enableCliShortcuts);
|
|
2918
|
+
} else {
|
|
2919
|
+
logBrowserWatchReadyMessage(enableCliShortcuts);
|
|
1769
2920
|
}
|
|
1770
2921
|
};
|
|
1771
2922
|
}
|
|
1772
2923
|
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
2924
|
+
const closeContainerRuntime = !isWatchMode
|
|
2925
|
+
? async () => {
|
|
2926
|
+
try {
|
|
2927
|
+
await containerPage.close();
|
|
2928
|
+
} catch {
|
|
2929
|
+
// ignore
|
|
2930
|
+
}
|
|
2931
|
+
try {
|
|
2932
|
+
await containerContext.close();
|
|
2933
|
+
} catch {
|
|
2934
|
+
// ignore
|
|
2935
|
+
}
|
|
2936
|
+
await destroyBrowserRuntime(runtime);
|
|
2937
|
+
}
|
|
2938
|
+
: undefined;
|
|
1786
2939
|
|
|
1787
2940
|
if (fatalError) {
|
|
1788
|
-
return failWithError(fatalError);
|
|
2941
|
+
return failWithError(fatalError, closeContainerRuntime);
|
|
1789
2942
|
}
|
|
1790
2943
|
|
|
1791
2944
|
const duration = {
|
|
@@ -1803,32 +2956,28 @@ export const runBrowserController = async (
|
|
|
1803
2956
|
ensureProcessExitCode(1);
|
|
1804
2957
|
}
|
|
1805
2958
|
|
|
1806
|
-
const result
|
|
2959
|
+
const result = {
|
|
1807
2960
|
results: reporterResults,
|
|
1808
2961
|
testResults: caseResults,
|
|
1809
2962
|
duration,
|
|
1810
2963
|
hasFailure: isFailure,
|
|
2964
|
+
getSourcemap: getBrowserSourcemap,
|
|
2965
|
+
resolveSourcemap: resolveBrowserSourcemap,
|
|
2966
|
+
close: skipOnTestRunEnd ? closeContainerRuntime : undefined,
|
|
1811
2967
|
};
|
|
1812
2968
|
|
|
1813
|
-
// Only call onTestRunEnd if not skipped (for unified reporter output)
|
|
1814
2969
|
if (!skipOnTestRunEnd) {
|
|
1815
|
-
|
|
1816
|
-
await
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
duration,
|
|
1820
|
-
snapshotSummary: context.snapshotManager.summary,
|
|
1821
|
-
getSourcemap: async () => null,
|
|
1822
|
-
});
|
|
2970
|
+
try {
|
|
2971
|
+
await notifyTestRunEnd({ duration });
|
|
2972
|
+
} finally {
|
|
2973
|
+
await closeContainerRuntime?.();
|
|
1823
2974
|
}
|
|
1824
2975
|
}
|
|
1825
2976
|
|
|
1826
2977
|
// Enable watch hooks AFTER initial test run to avoid duplicate runs
|
|
1827
2978
|
if (isWatchMode && triggerRerun) {
|
|
1828
2979
|
watchContext.hooksEnabled = true;
|
|
1829
|
-
|
|
1830
|
-
color.cyan('\nWatch mode enabled - will re-run tests on file changes\n'),
|
|
1831
|
-
);
|
|
2980
|
+
logBrowserWatchReadyMessage(enableCliShortcuts);
|
|
1832
2981
|
}
|
|
1833
2982
|
|
|
1834
2983
|
return result;
|
|
@@ -1887,6 +3036,7 @@ export const listBrowserTests = async (
|
|
|
1887
3036
|
manifestPath,
|
|
1888
3037
|
entries: projectEntries,
|
|
1889
3038
|
});
|
|
3039
|
+
const browserProjects = getBrowserProjects(context);
|
|
1890
3040
|
|
|
1891
3041
|
// Create a simplified browser runtime for collect mode
|
|
1892
3042
|
let runtime: BrowserRuntime;
|
|
@@ -1902,9 +3052,14 @@ export const listBrowserTests = async (
|
|
|
1902
3052
|
forceHeadless: true, // Always use headless for list command
|
|
1903
3053
|
});
|
|
1904
3054
|
} catch (error) {
|
|
3055
|
+
const providers = [
|
|
3056
|
+
...new Set(
|
|
3057
|
+
browserProjects.map((p) => p.normalizedConfig.browser.provider),
|
|
3058
|
+
),
|
|
3059
|
+
];
|
|
1905
3060
|
logger.error(
|
|
1906
3061
|
color.red(
|
|
1907
|
-
|
|
3062
|
+
`Failed to initialize browser provider runtime (${providers.join(', ')}).`,
|
|
1908
3063
|
),
|
|
1909
3064
|
error,
|
|
1910
3065
|
);
|
|
@@ -1915,7 +3070,6 @@ export const listBrowserTests = async (
|
|
|
1915
3070
|
|
|
1916
3071
|
// Get browser projects for runtime config
|
|
1917
3072
|
// Normalize projectRoot to posix format for cross-platform compatibility
|
|
1918
|
-
const browserProjects = getBrowserProjects(context);
|
|
1919
3073
|
const projectRuntimeConfigs: BrowserProjectRuntime[] = browserProjects.map(
|
|
1920
3074
|
(project: ProjectContext) => ({
|
|
1921
3075
|
name: project.name,
|
|
@@ -1961,7 +3115,7 @@ export const listBrowserTests = async (
|
|
|
1961
3115
|
|
|
1962
3116
|
// Expose dispatch function for browser client to send messages
|
|
1963
3117
|
await page.exposeFunction(
|
|
1964
|
-
|
|
3118
|
+
DISPATCH_MESSAGE_TYPE,
|
|
1965
3119
|
(message: { type: string; payload?: unknown }) => {
|
|
1966
3120
|
switch (message.type) {
|
|
1967
3121
|
case 'collect-result': {
|