@rstest/browser 0.8.5 → 0.9.0
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.0687a8142a.js} +742 -692
- 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/headlessLatestRerunScheduler.d.ts +19 -0
- package/dist/headlessTransport.d.ts +12 -0
- package/dist/index.js +1580 -258
- 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/watchRerunPlanner.d.ts +21 -0
- package/package.json +15 -10
- 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/headlessLatestRerunScheduler.ts +76 -0
- package/src/headlessTransport.ts +28 -0
- package/src/hostController.ts +1292 -367
- 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/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
|
@@ -30,14 +30,53 @@ import { type BirpcReturn, createBirpc } from 'birpc';
|
|
|
30
30
|
import openEditor from 'open-editor';
|
|
31
31
|
import { basename, dirname, join, normalize, relative, resolve } from 'pathe';
|
|
32
32
|
import * as picomatch from 'picomatch';
|
|
33
|
-
import type { BrowserContext, ConsoleMessage, Page } from 'playwright';
|
|
34
33
|
import sirv from 'sirv';
|
|
35
34
|
import { type WebSocket, WebSocketServer } from 'ws';
|
|
35
|
+
import { getHeadlessConcurrency } from './concurrency';
|
|
36
|
+
import {
|
|
37
|
+
createHostDispatchRouter,
|
|
38
|
+
type HostDispatchRouterOptions,
|
|
39
|
+
} from './dispatchCapabilities';
|
|
40
|
+
import { createHeadlessLatestRerunScheduler } from './headlessLatestRerunScheduler';
|
|
41
|
+
import { attachHeadlessRunnerTransport } from './headlessTransport';
|
|
36
42
|
import type {
|
|
43
|
+
BrowserClientMessage,
|
|
44
|
+
BrowserDispatchHandler,
|
|
45
|
+
BrowserDispatchRequest,
|
|
46
|
+
BrowserDispatchResponse,
|
|
37
47
|
BrowserHostConfig,
|
|
38
48
|
BrowserProjectRuntime,
|
|
49
|
+
BrowserRpcRequest,
|
|
50
|
+
BrowserViewport,
|
|
51
|
+
SnapshotRpcRequest,
|
|
39
52
|
TestFileInfo,
|
|
40
53
|
} from './protocol';
|
|
54
|
+
import {
|
|
55
|
+
DISPATCH_MESSAGE_TYPE,
|
|
56
|
+
DISPATCH_NAMESPACE_RUNNER,
|
|
57
|
+
validateBrowserRpcRequest,
|
|
58
|
+
} from './protocol';
|
|
59
|
+
import {
|
|
60
|
+
type BrowserProvider,
|
|
61
|
+
type BrowserProviderBrowser,
|
|
62
|
+
type BrowserProviderContext,
|
|
63
|
+
type BrowserProviderImplementation,
|
|
64
|
+
type BrowserProviderPage,
|
|
65
|
+
getBrowserProviderImplementation,
|
|
66
|
+
} from './providers';
|
|
67
|
+
import {
|
|
68
|
+
createRunSession,
|
|
69
|
+
type RunSession,
|
|
70
|
+
RunSessionLifecycle,
|
|
71
|
+
} from './runSession';
|
|
72
|
+
import { RunnerSessionRegistry } from './sessionRegistry';
|
|
73
|
+
import {
|
|
74
|
+
loadSourceMapWithCache,
|
|
75
|
+
normalizeJavaScriptUrl,
|
|
76
|
+
type SourceMapPayload,
|
|
77
|
+
} from './sourceMap/sourceMapLoader';
|
|
78
|
+
import { resolveBrowserViewportPreset } from './viewportPresets';
|
|
79
|
+
import { collectWatchTestFiles, planWatchRerun } from './watchRerunPlanner';
|
|
41
80
|
|
|
42
81
|
const { createRsbuild, rspack } = rsbuild;
|
|
43
82
|
type RsbuildDevServer = rsbuild.RsbuildDevServer;
|
|
@@ -66,16 +105,22 @@ type VirtualModulesPluginInstance = InstanceType<
|
|
|
66
105
|
(typeof rspack.experiments)['VirtualModulesPlugin']
|
|
67
106
|
>;
|
|
68
107
|
|
|
69
|
-
type PlaywrightModule = typeof import('playwright');
|
|
70
|
-
type BrowserType = PlaywrightModule['chromium'];
|
|
71
|
-
type BrowserInstance = Awaited<ReturnType<BrowserType['launch']>>;
|
|
72
|
-
|
|
73
108
|
type BrowserProjectEntries = {
|
|
74
109
|
project: ProjectContext;
|
|
75
110
|
setupFiles: string[];
|
|
76
111
|
testFiles: string[];
|
|
77
112
|
};
|
|
78
113
|
|
|
114
|
+
type BrowserProviderProject = {
|
|
115
|
+
rootPath: string;
|
|
116
|
+
provider: BrowserProvider;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
type BrowserLaunchOptions = Pick<
|
|
120
|
+
ProjectContext['normalizedConfig']['browser'],
|
|
121
|
+
'provider' | 'browser' | 'headless' | 'port' | 'strictPort'
|
|
122
|
+
>;
|
|
123
|
+
|
|
79
124
|
/** Payload for test file start event */
|
|
80
125
|
type TestFileStartPayload = {
|
|
81
126
|
testPath: string;
|
|
@@ -97,6 +142,15 @@ type FatalPayload = {
|
|
|
97
142
|
stack?: string;
|
|
98
143
|
};
|
|
99
144
|
|
|
145
|
+
type ReporterHookArg<THook extends keyof Reporter> = Parameters<
|
|
146
|
+
NonNullable<Reporter[THook]>
|
|
147
|
+
>[0];
|
|
148
|
+
|
|
149
|
+
type TestFileReadyPayload = ReporterHookArg<'onTestFileReady'>;
|
|
150
|
+
type TestSuiteStartPayload = ReporterHookArg<'onTestSuiteStart'>;
|
|
151
|
+
type TestSuiteResultPayload = ReporterHookArg<'onTestSuiteResult'>;
|
|
152
|
+
type TestCaseStartPayload = ReporterHookArg<'onTestCaseStart'>;
|
|
153
|
+
|
|
100
154
|
/** RPC methods exposed by the host (server) to the container (client) */
|
|
101
155
|
type HostRpcMethods = {
|
|
102
156
|
rerunTest: (testFile: string, testNamePattern?: string) => Promise<void>;
|
|
@@ -107,11 +161,10 @@ type HostRpcMethods = {
|
|
|
107
161
|
onTestFileComplete: (payload: TestFileResult) => Promise<void>;
|
|
108
162
|
onLog: (payload: LogPayload) => Promise<void>;
|
|
109
163
|
onFatal: (payload: FatalPayload) => Promise<void>;
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
removeSnapshotFile: (filepath: string) => Promise<void>;
|
|
164
|
+
// Generic dispatch endpoint used by runner RPC requests.
|
|
165
|
+
dispatch: (
|
|
166
|
+
request: BrowserDispatchRequest,
|
|
167
|
+
) => Promise<BrowserDispatchResponse>;
|
|
115
168
|
};
|
|
116
169
|
|
|
117
170
|
/** RPC methods exposed by the container (client) to the host (server) */
|
|
@@ -237,15 +290,17 @@ class ContainerRpcManager {
|
|
|
237
290
|
type BrowserRuntime = {
|
|
238
291
|
rsbuildInstance: RsbuildInstance;
|
|
239
292
|
devServer: RsbuildDevServer;
|
|
240
|
-
browser:
|
|
293
|
+
browser: BrowserProviderBrowser;
|
|
241
294
|
port: number;
|
|
242
295
|
wsPort: number;
|
|
243
296
|
manifestPath: string;
|
|
244
297
|
tempDir: string;
|
|
245
298
|
manifestPlugin: VirtualModulesPluginInstance;
|
|
246
|
-
containerPage?:
|
|
247
|
-
containerContext?:
|
|
299
|
+
containerPage?: BrowserProviderPage;
|
|
300
|
+
containerContext?: BrowserProviderContext;
|
|
248
301
|
setContainerOptions: (options: BrowserHostConfig) => void;
|
|
302
|
+
// Reserved extension seam for host-side dispatch capabilities.
|
|
303
|
+
dispatchHandlers: Map<string, BrowserDispatchHandler>;
|
|
249
304
|
wss: WebSocketServer;
|
|
250
305
|
rpcManager?: ContainerRpcManager;
|
|
251
306
|
};
|
|
@@ -276,6 +331,47 @@ const watchContext: WatchContext = {
|
|
|
276
331
|
// Utility Functions
|
|
277
332
|
// ============================================================================
|
|
278
333
|
|
|
334
|
+
const resolveViewport = (
|
|
335
|
+
viewport: BrowserViewport | undefined,
|
|
336
|
+
): { width: number; height: number } | null => {
|
|
337
|
+
if (!viewport) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (typeof viewport === 'string') {
|
|
342
|
+
return resolveBrowserViewportPreset(viewport);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (
|
|
346
|
+
typeof viewport.width === 'number' &&
|
|
347
|
+
Number.isFinite(viewport.width) &&
|
|
348
|
+
viewport.width > 0 &&
|
|
349
|
+
typeof viewport.height === 'number' &&
|
|
350
|
+
Number.isFinite(viewport.height) &&
|
|
351
|
+
viewport.height > 0
|
|
352
|
+
) {
|
|
353
|
+
return {
|
|
354
|
+
width: viewport.width,
|
|
355
|
+
height: viewport.height,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return null;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const mapViewportByProject = (
|
|
363
|
+
projects: BrowserProjectRuntime[],
|
|
364
|
+
): Map<string, { width: number; height: number }> => {
|
|
365
|
+
const map = new Map<string, { width: number; height: number }>();
|
|
366
|
+
for (const project of projects) {
|
|
367
|
+
const viewport = resolveViewport(project.viewport);
|
|
368
|
+
if (viewport) {
|
|
369
|
+
map.set(project.name, viewport);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return map;
|
|
373
|
+
};
|
|
374
|
+
|
|
279
375
|
const ensureProcessExitCode = (code: number): void => {
|
|
280
376
|
if (process.exitCode === undefined || process.exitCode === 0) {
|
|
281
377
|
process.exitCode = code;
|
|
@@ -533,6 +629,69 @@ const getBrowserProjects = (context: Rstest): ProjectContext[] => {
|
|
|
533
629
|
);
|
|
534
630
|
};
|
|
535
631
|
|
|
632
|
+
const getBrowserLaunchOptions = (
|
|
633
|
+
project: ProjectContext,
|
|
634
|
+
): BrowserLaunchOptions => ({
|
|
635
|
+
provider: project.normalizedConfig.browser.provider,
|
|
636
|
+
browser: project.normalizedConfig.browser.browser,
|
|
637
|
+
headless: project.normalizedConfig.browser.headless,
|
|
638
|
+
port: project.normalizedConfig.browser.port,
|
|
639
|
+
strictPort: project.normalizedConfig.browser.strictPort,
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
const ensureConsistentBrowserLaunchOptions = (
|
|
643
|
+
projects: ProjectContext[],
|
|
644
|
+
): BrowserLaunchOptions => {
|
|
645
|
+
if (projects.length === 0) {
|
|
646
|
+
throw new Error('No browser-enabled projects found.');
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const firstProject = projects[0]!;
|
|
650
|
+
const firstOptions = getBrowserLaunchOptions(firstProject);
|
|
651
|
+
|
|
652
|
+
for (const project of projects.slice(1)) {
|
|
653
|
+
const options = getBrowserLaunchOptions(project);
|
|
654
|
+
if (
|
|
655
|
+
options.provider !== firstOptions.provider ||
|
|
656
|
+
options.browser !== firstOptions.browser ||
|
|
657
|
+
options.headless !== firstOptions.headless ||
|
|
658
|
+
options.port !== firstOptions.port ||
|
|
659
|
+
options.strictPort !== firstOptions.strictPort
|
|
660
|
+
) {
|
|
661
|
+
throw new Error(
|
|
662
|
+
`Browser launch config mismatch between projects "${firstProject.name}" and "${project.name}". ` +
|
|
663
|
+
'All browser-enabled projects in one run must share provider/browser/headless/port/strictPort.',
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return firstOptions;
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
const resolveProviderForTestPath = ({
|
|
672
|
+
testPath,
|
|
673
|
+
browserProjects,
|
|
674
|
+
}: {
|
|
675
|
+
testPath: string;
|
|
676
|
+
browserProjects: BrowserProviderProject[];
|
|
677
|
+
}): BrowserProvider => {
|
|
678
|
+
const normalizedTestPath = normalize(testPath);
|
|
679
|
+
const sortedProjects = [...browserProjects].sort(
|
|
680
|
+
(a, b) => b.rootPath.length - a.rootPath.length,
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
for (const project of sortedProjects) {
|
|
684
|
+
if (normalizedTestPath.startsWith(project.rootPath)) {
|
|
685
|
+
return project.provider;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
throw new Error(
|
|
690
|
+
`Cannot resolve browser provider for test path: ${JSON.stringify(testPath)}. ` +
|
|
691
|
+
`Known project roots: ${JSON.stringify(sortedProjects.map((p) => p.rootPath))}`,
|
|
692
|
+
);
|
|
693
|
+
};
|
|
694
|
+
|
|
536
695
|
const collectProjectEntries = async (
|
|
537
696
|
context: Rstest,
|
|
538
697
|
): Promise<BrowserProjectEntries[]> => {
|
|
@@ -729,21 +888,6 @@ const htmlTemplate = `<!DOCTYPE html>
|
|
|
729
888
|
</html>
|
|
730
889
|
`;
|
|
731
890
|
|
|
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
891
|
// Workaround for noisy "removed ..." logs caused by VirtualModulesPlugin.
|
|
748
892
|
// Rsbuild suppresses the removed-file log if all removed paths include "virtual":
|
|
749
893
|
// https://github.com/web-infra-dev/rsbuild/blob/1258fa9dba5c321a4629b591a6dadbd2e26c6963/packages/core/src/createCompiler.ts#L73-L76
|
|
@@ -831,15 +975,11 @@ const createBrowserRuntime = async ({
|
|
|
831
975
|
const containerHtmlTemplate = containerDistPath
|
|
832
976
|
? await fs.readFile(join(containerDistPath, 'index.html'), 'utf-8')
|
|
833
977
|
: null;
|
|
834
|
-
const schedulerHtmlTemplate = containerDistPath
|
|
835
|
-
? await fs
|
|
836
|
-
.readFile(join(containerDistPath, 'scheduler.html'), 'utf-8')
|
|
837
|
-
.catch(() => null)
|
|
838
|
-
: null;
|
|
839
978
|
|
|
840
979
|
let injectedContainerHtml: string | null = null;
|
|
841
|
-
let injectedSchedulerHtml: string | null = null;
|
|
842
980
|
let serializedOptions = 'null';
|
|
981
|
+
// Reserved extension seam for future browser-side capabilities.
|
|
982
|
+
const dispatchHandlers = new Map<string, BrowserDispatchHandler>();
|
|
843
983
|
|
|
844
984
|
const setContainerOptions = (options: BrowserHostConfig): void => {
|
|
845
985
|
serializedOptions = serializeForInlineScript(options);
|
|
@@ -849,18 +989,17 @@ const createBrowserRuntime = async ({
|
|
|
849
989
|
serializedOptions,
|
|
850
990
|
);
|
|
851
991
|
}
|
|
852
|
-
injectedSchedulerHtml = (
|
|
853
|
-
schedulerHtmlTemplate || fallbackSchedulerHtmlTemplate
|
|
854
|
-
).replace(OPTIONS_PLACEHOLDER, serializedOptions);
|
|
855
992
|
};
|
|
856
993
|
|
|
857
|
-
// Get user Rsbuild config from the first browser project
|
|
858
994
|
const browserProjects = getBrowserProjects(context);
|
|
859
|
-
const
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
const
|
|
863
|
-
|
|
995
|
+
const projectByEnvironmentName = new Map(
|
|
996
|
+
browserProjects.map((project) => [project.environmentName, project]),
|
|
997
|
+
);
|
|
998
|
+
const userPlugins = browserProjects.flatMap(
|
|
999
|
+
(project) => project.normalizedConfig.plugins || [],
|
|
1000
|
+
);
|
|
1001
|
+
const browserLaunchOptions =
|
|
1002
|
+
ensureConsistentBrowserLaunchOptions(browserProjects);
|
|
864
1003
|
|
|
865
1004
|
// Rstest internal aliases that must not be overridden by user config
|
|
866
1005
|
const browserRuntimePath = fileURLToPath(
|
|
@@ -871,6 +1010,8 @@ const createBrowserRuntime = async ({
|
|
|
871
1010
|
'@rstest/browser-manifest': manifestPath,
|
|
872
1011
|
// User test code: import { describe, it } from '@rstest/core'
|
|
873
1012
|
'@rstest/core': resolveBrowserFile('client/public.ts'),
|
|
1013
|
+
// User test code: import { page } from '@rstest/browser'
|
|
1014
|
+
'@rstest/browser': resolveBrowserFile('browser.ts'),
|
|
874
1015
|
// Browser runtime APIs for entry.ts and public.ts
|
|
875
1016
|
// Uses dist file with extractSourceMap to preserve sourcemap chain for inline snapshots
|
|
876
1017
|
'@rstest/core/browser-runtime': browserRuntimePath,
|
|
@@ -885,8 +1026,8 @@ const createBrowserRuntime = async ({
|
|
|
885
1026
|
plugins: userPlugins,
|
|
886
1027
|
server: {
|
|
887
1028
|
printUrls: false,
|
|
888
|
-
port:
|
|
889
|
-
strictPort:
|
|
1029
|
+
port: browserLaunchOptions.port ?? 4000,
|
|
1030
|
+
strictPort: browserLaunchOptions.strictPort,
|
|
890
1031
|
},
|
|
891
1032
|
dev: {
|
|
892
1033
|
client: {
|
|
@@ -894,7 +1035,9 @@ const createBrowserRuntime = async ({
|
|
|
894
1035
|
},
|
|
895
1036
|
},
|
|
896
1037
|
environments: {
|
|
897
|
-
|
|
1038
|
+
...Object.fromEntries(
|
|
1039
|
+
browserProjects.map((project) => [project.environmentName, {}]),
|
|
1040
|
+
),
|
|
898
1041
|
},
|
|
899
1042
|
},
|
|
900
1043
|
});
|
|
@@ -904,13 +1047,39 @@ const createBrowserRuntime = async ({
|
|
|
904
1047
|
{
|
|
905
1048
|
name: 'rstest:browser-user-config',
|
|
906
1049
|
setup(api) {
|
|
1050
|
+
// Internal extension entry: register host dispatch handlers without
|
|
1051
|
+
// coupling scheduling to individual capability implementations.
|
|
1052
|
+
(api as { expose?: (name: string, value: unknown) => void }).expose?.(
|
|
1053
|
+
'rstest:browser',
|
|
1054
|
+
{
|
|
1055
|
+
registerDispatchHandler: (
|
|
1056
|
+
namespace: string,
|
|
1057
|
+
handler: BrowserDispatchHandler,
|
|
1058
|
+
) => {
|
|
1059
|
+
dispatchHandlers.set(namespace, handler);
|
|
1060
|
+
},
|
|
1061
|
+
},
|
|
1062
|
+
);
|
|
1063
|
+
|
|
907
1064
|
api.modifyEnvironmentConfig({
|
|
908
|
-
handler: (config, { mergeEnvironmentConfig }) => {
|
|
1065
|
+
handler: (config, { mergeEnvironmentConfig, name }) => {
|
|
1066
|
+
const project = projectByEnvironmentName.get(name);
|
|
1067
|
+
if (!project) {
|
|
1068
|
+
return config;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const userRsbuildConfig = project.normalizedConfig;
|
|
909
1072
|
// Merge order: current config -> userConfig -> rstest required config (highest priority)
|
|
910
1073
|
const merged = mergeEnvironmentConfig(config, userRsbuildConfig, {
|
|
911
1074
|
resolve: {
|
|
912
1075
|
alias: rstestInternalAliases,
|
|
913
1076
|
},
|
|
1077
|
+
source: {
|
|
1078
|
+
define: {
|
|
1079
|
+
'process.env': 'globalThis[Symbol.for("rstest.env")]',
|
|
1080
|
+
'import.meta.env': 'globalThis[Symbol.for("rstest.env")]',
|
|
1081
|
+
},
|
|
1082
|
+
},
|
|
914
1083
|
output: {
|
|
915
1084
|
target: 'web',
|
|
916
1085
|
// Enable source map for inline snapshot support
|
|
@@ -984,8 +1153,8 @@ const createBrowserRuntime = async ({
|
|
|
984
1153
|
if (stats) {
|
|
985
1154
|
const projectEntries = await collectProjectEntries(context);
|
|
986
1155
|
const entryTestFiles = new Set<string>(
|
|
987
|
-
projectEntries.
|
|
988
|
-
|
|
1156
|
+
collectWatchTestFiles(projectEntries).map(
|
|
1157
|
+
(file) => file.testPath,
|
|
989
1158
|
),
|
|
990
1159
|
);
|
|
991
1160
|
|
|
@@ -1015,7 +1184,9 @@ const createBrowserRuntime = async ({
|
|
|
1015
1184
|
}
|
|
1016
1185
|
|
|
1017
1186
|
// Register coverage plugin for browser mode
|
|
1018
|
-
const coverage =
|
|
1187
|
+
const coverage = browserProjects.find(
|
|
1188
|
+
(project) => project.normalizedConfig.coverage?.enabled,
|
|
1189
|
+
)?.normalizedConfig.coverage;
|
|
1019
1190
|
if (coverage?.enabled && context.command !== 'list') {
|
|
1020
1191
|
const { pluginCoverage } = await loadCoverageProvider(
|
|
1021
1192
|
coverage,
|
|
@@ -1109,82 +1280,72 @@ const createBrowserRuntime = async ({
|
|
|
1109
1280
|
}
|
|
1110
1281
|
};
|
|
1111
1282
|
|
|
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');
|
|
1283
|
+
devServer.middlewares.use(
|
|
1284
|
+
async (req: IncomingMessage, res: ServerResponse, next: () => void) => {
|
|
1285
|
+
if (!req.url) {
|
|
1286
|
+
next();
|
|
1123
1287
|
return;
|
|
1124
1288
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1289
|
+
const url = new URL(req.url, 'http://localhost');
|
|
1290
|
+
if (url.pathname === '/__open-in-editor') {
|
|
1291
|
+
const file = url.searchParams.get('file');
|
|
1292
|
+
if (!file) {
|
|
1293
|
+
res.statusCode = 400;
|
|
1294
|
+
res.end('Missing file');
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
try {
|
|
1298
|
+
await openEditor([{ file }]);
|
|
1299
|
+
res.statusCode = 204;
|
|
1300
|
+
res.end();
|
|
1301
|
+
} catch (error) {
|
|
1302
|
+
logger.debug(`[Browser UI] Failed to open editor: ${String(error)}`);
|
|
1303
|
+
res.statusCode = 500;
|
|
1304
|
+
res.end('Failed to open editor');
|
|
1305
|
+
}
|
|
1138
1306
|
return;
|
|
1139
1307
|
}
|
|
1308
|
+
if (url.pathname === '/') {
|
|
1309
|
+
if (await respondWithDevServerHtml(url, res)) {
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1140
1312
|
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
injectedSchedulerHtml ||
|
|
1145
|
-
(schedulerHtmlTemplate || fallbackSchedulerHtmlTemplate).replace(
|
|
1146
|
-
OPTIONS_PLACEHOLDER,
|
|
1147
|
-
'null',
|
|
1148
|
-
),
|
|
1149
|
-
);
|
|
1150
|
-
return;
|
|
1151
|
-
}
|
|
1313
|
+
const html =
|
|
1314
|
+
injectedContainerHtml ||
|
|
1315
|
+
containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
|
|
1152
1316
|
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1317
|
+
if (html) {
|
|
1318
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1319
|
+
res.end(html);
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1156
1322
|
|
|
1157
|
-
|
|
1158
|
-
res.
|
|
1159
|
-
res.end(html);
|
|
1323
|
+
res.statusCode = 502;
|
|
1324
|
+
res.end('Container UI is not available.');
|
|
1160
1325
|
return;
|
|
1161
1326
|
}
|
|
1327
|
+
if (url.pathname.startsWith('/container-static/')) {
|
|
1328
|
+
if (await proxyDevServerAsset(req, res)) {
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1162
1331
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1332
|
+
if (serveContainer) {
|
|
1333
|
+
serveContainer(req, res, next);
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
res.statusCode = 502;
|
|
1338
|
+
res.end('Container assets are not available.');
|
|
1169
1339
|
return;
|
|
1170
1340
|
}
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1341
|
+
if (url.pathname === '/runner.html') {
|
|
1342
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1343
|
+
res.end(htmlTemplate);
|
|
1174
1344
|
return;
|
|
1175
1345
|
}
|
|
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
|
-
});
|
|
1346
|
+
next();
|
|
1347
|
+
},
|
|
1348
|
+
);
|
|
1188
1349
|
|
|
1189
1350
|
const { port } = await devServer.listen();
|
|
1190
1351
|
|
|
@@ -1199,49 +1360,33 @@ const createBrowserRuntime = async ({
|
|
|
1199
1360
|
const wsPort = (wss.address() as AddressInfo).port;
|
|
1200
1361
|
logger.debug(`[Browser UI] WebSocket server started on port ${wsPort}`);
|
|
1201
1362
|
|
|
1202
|
-
|
|
1203
|
-
const browserName = browserConfig.browser;
|
|
1204
|
-
try {
|
|
1205
|
-
const playwright = await import('playwright');
|
|
1206
|
-
browserLauncher = playwright[browserName];
|
|
1207
|
-
} catch (_error) {
|
|
1208
|
-
wss.close();
|
|
1209
|
-
await devServer.close();
|
|
1210
|
-
throw _error;
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
let browser: BrowserInstance;
|
|
1363
|
+
const browserName = browserLaunchOptions.browser ?? 'chromium';
|
|
1214
1364
|
try {
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
'--disable-popup-blocking',
|
|
1222
|
-
'--no-first-run',
|
|
1223
|
-
'--no-default-browser-check',
|
|
1224
|
-
]
|
|
1225
|
-
: undefined,
|
|
1365
|
+
const providerImplementation = getBrowserProviderImplementation(
|
|
1366
|
+
browserLaunchOptions.provider,
|
|
1367
|
+
);
|
|
1368
|
+
const runtime = await providerImplementation.launchRuntime({
|
|
1369
|
+
browserName,
|
|
1370
|
+
headless: forceHeadless ?? browserLaunchOptions.headless,
|
|
1226
1371
|
});
|
|
1372
|
+
return {
|
|
1373
|
+
rsbuildInstance,
|
|
1374
|
+
devServer,
|
|
1375
|
+
browser: runtime.browser,
|
|
1376
|
+
port,
|
|
1377
|
+
wsPort,
|
|
1378
|
+
manifestPath,
|
|
1379
|
+
tempDir,
|
|
1380
|
+
manifestPlugin: virtualManifestPlugin,
|
|
1381
|
+
setContainerOptions,
|
|
1382
|
+
dispatchHandlers,
|
|
1383
|
+
wss,
|
|
1384
|
+
};
|
|
1227
1385
|
} catch (_error) {
|
|
1228
1386
|
wss.close();
|
|
1229
1387
|
await devServer.close();
|
|
1230
1388
|
throw _error;
|
|
1231
1389
|
}
|
|
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
1390
|
};
|
|
1246
1391
|
|
|
1247
1392
|
async function resolveProjectEntries(
|
|
@@ -1281,24 +1426,71 @@ export const runBrowserController = async (
|
|
|
1281
1426
|
const { skipOnTestRunEnd = false } = options ?? {};
|
|
1282
1427
|
const buildStart = Date.now();
|
|
1283
1428
|
const browserProjects = getBrowserProjects(context);
|
|
1284
|
-
const
|
|
1429
|
+
const useHeadlessDirect = browserProjects.every(
|
|
1285
1430
|
(project) => project.normalizedConfig.browser.headless,
|
|
1286
1431
|
);
|
|
1287
1432
|
|
|
1433
|
+
const browserSourceMapCache = new Map<string, SourceMapPayload | null>();
|
|
1434
|
+
|
|
1435
|
+
const isHttpLikeFile = (file: string): boolean => /^https?:\/\//.test(file);
|
|
1436
|
+
|
|
1437
|
+
const resolveBrowserSourcemap = async (sourcePath: string) => {
|
|
1438
|
+
if (!isHttpLikeFile(sourcePath)) {
|
|
1439
|
+
return {
|
|
1440
|
+
handled: false,
|
|
1441
|
+
sourcemap: null,
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
const normalizedUrl = normalizeJavaScriptUrl(sourcePath);
|
|
1446
|
+
if (!normalizedUrl) {
|
|
1447
|
+
return {
|
|
1448
|
+
handled: true,
|
|
1449
|
+
sourcemap: null,
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
if (browserSourceMapCache.has(normalizedUrl)) {
|
|
1454
|
+
return {
|
|
1455
|
+
handled: true,
|
|
1456
|
+
sourcemap: browserSourceMapCache.get(normalizedUrl) ?? null,
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
return {
|
|
1461
|
+
handled: true,
|
|
1462
|
+
sourcemap: await loadSourceMapWithCache({
|
|
1463
|
+
jsUrl: normalizedUrl,
|
|
1464
|
+
cache: browserSourceMapCache,
|
|
1465
|
+
}),
|
|
1466
|
+
};
|
|
1467
|
+
};
|
|
1468
|
+
|
|
1469
|
+
const getBrowserSourcemap = async (
|
|
1470
|
+
sourcePath: string,
|
|
1471
|
+
): Promise<SourceMapPayload | null> => {
|
|
1472
|
+
const result = await resolveBrowserSourcemap(sourcePath);
|
|
1473
|
+
return result.handled ? result.sourcemap : null;
|
|
1474
|
+
};
|
|
1475
|
+
|
|
1288
1476
|
/**
|
|
1289
1477
|
* Build an error BrowserTestRunResult and call onTestRunEnd if needed.
|
|
1290
1478
|
* Used for early-exit error paths to ensure errors reach the summary report.
|
|
1291
1479
|
*/
|
|
1292
1480
|
const buildErrorResult = async (
|
|
1293
1481
|
error: Error,
|
|
1482
|
+
close?: () => Promise<void>,
|
|
1294
1483
|
): Promise<BrowserTestRunResult> => {
|
|
1295
1484
|
const elapsed = Math.max(0, Date.now() - buildStart);
|
|
1296
|
-
const errorResult
|
|
1485
|
+
const errorResult = {
|
|
1297
1486
|
results: [],
|
|
1298
1487
|
testResults: [],
|
|
1299
1488
|
duration: { totalTime: elapsed, buildTime: elapsed, testTime: 0 },
|
|
1300
1489
|
hasFailure: true,
|
|
1301
1490
|
unhandledErrors: [error],
|
|
1491
|
+
getSourcemap: getBrowserSourcemap,
|
|
1492
|
+
resolveSourcemap: resolveBrowserSourcemap,
|
|
1493
|
+
close,
|
|
1302
1494
|
};
|
|
1303
1495
|
|
|
1304
1496
|
if (!skipOnTestRunEnd) {
|
|
@@ -1308,7 +1500,7 @@ export const runBrowserController = async (
|
|
|
1308
1500
|
testResults: [],
|
|
1309
1501
|
duration: errorResult.duration,
|
|
1310
1502
|
snapshotSummary: context.snapshotManager.summary,
|
|
1311
|
-
getSourcemap:
|
|
1503
|
+
getSourcemap: getBrowserSourcemap,
|
|
1312
1504
|
unhandledErrors: errorResult.unhandledErrors,
|
|
1313
1505
|
});
|
|
1314
1506
|
}
|
|
@@ -1326,32 +1518,94 @@ export const runBrowserController = async (
|
|
|
1326
1518
|
cleanup?: () => Promise<void>,
|
|
1327
1519
|
): Promise<BrowserTestRunResult> => {
|
|
1328
1520
|
ensureProcessExitCode(1);
|
|
1329
|
-
|
|
1330
|
-
|
|
1521
|
+
|
|
1522
|
+
const normalizedError = toError(error);
|
|
1523
|
+
|
|
1524
|
+
if (cleanup && skipOnTestRunEnd) {
|
|
1525
|
+
return buildErrorResult(normalizedError, cleanup);
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
try {
|
|
1529
|
+
return await buildErrorResult(normalizedError);
|
|
1530
|
+
} finally {
|
|
1531
|
+
await cleanup?.();
|
|
1532
|
+
}
|
|
1533
|
+
};
|
|
1534
|
+
|
|
1535
|
+
const collectDeletedTestPaths = (
|
|
1536
|
+
previous: TestFileInfo[],
|
|
1537
|
+
current: TestFileInfo[],
|
|
1538
|
+
): string[] => {
|
|
1539
|
+
const currentPathSet = new Set(current.map((file) => file.testPath));
|
|
1540
|
+
return previous
|
|
1541
|
+
.map((file) => file.testPath)
|
|
1542
|
+
.filter((testPath) => !currentPathSet.has(testPath));
|
|
1543
|
+
};
|
|
1544
|
+
|
|
1545
|
+
const notifyTestRunStart = async (): Promise<void> => {
|
|
1546
|
+
if (skipOnTestRunEnd) {
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
for (const reporter of context.reporters) {
|
|
1551
|
+
await reporter.onTestRunStart?.();
|
|
1552
|
+
}
|
|
1553
|
+
};
|
|
1554
|
+
|
|
1555
|
+
const notifyTestRunEnd = async ({
|
|
1556
|
+
duration,
|
|
1557
|
+
unhandledErrors,
|
|
1558
|
+
filterRerunTestPaths,
|
|
1559
|
+
}: {
|
|
1560
|
+
duration: {
|
|
1561
|
+
totalTime: number;
|
|
1562
|
+
buildTime: number;
|
|
1563
|
+
testTime: number;
|
|
1564
|
+
};
|
|
1565
|
+
unhandledErrors?: Error[];
|
|
1566
|
+
filterRerunTestPaths?: string[];
|
|
1567
|
+
}): Promise<void> => {
|
|
1568
|
+
if (skipOnTestRunEnd) {
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
for (const reporter of context.reporters) {
|
|
1573
|
+
await reporter.onTestRunEnd?.({
|
|
1574
|
+
results: context.reporterResults.results,
|
|
1575
|
+
testResults: context.reporterResults.testResults,
|
|
1576
|
+
duration,
|
|
1577
|
+
snapshotSummary: context.snapshotManager.summary,
|
|
1578
|
+
getSourcemap: getBrowserSourcemap,
|
|
1579
|
+
unhandledErrors,
|
|
1580
|
+
filterRerunTestPaths,
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1331
1583
|
};
|
|
1332
1584
|
|
|
1333
1585
|
const containerDevServerEnv = process.env.RSTEST_CONTAINER_DEV_SERVER;
|
|
1334
1586
|
let containerDevServer: string | undefined;
|
|
1335
1587
|
let containerDistPath: string | undefined;
|
|
1336
1588
|
|
|
1337
|
-
if (
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1589
|
+
if (!useHeadlessDirect) {
|
|
1590
|
+
if (containerDevServerEnv) {
|
|
1591
|
+
try {
|
|
1592
|
+
containerDevServer = new URL(containerDevServerEnv).toString();
|
|
1593
|
+
logger.debug(
|
|
1594
|
+
`[Browser UI] Using dev server for container: ${containerDevServer}`,
|
|
1595
|
+
);
|
|
1596
|
+
} catch (error) {
|
|
1597
|
+
const originalError = toError(error);
|
|
1598
|
+
originalError.message = `Invalid RSTEST_CONTAINER_DEV_SERVER value: ${originalError.message}`;
|
|
1599
|
+
return failWithError(originalError);
|
|
1600
|
+
}
|
|
1347
1601
|
}
|
|
1348
|
-
}
|
|
1349
1602
|
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1603
|
+
if (!containerDevServer) {
|
|
1604
|
+
try {
|
|
1605
|
+
containerDistPath = resolveContainerDist();
|
|
1606
|
+
} catch (error) {
|
|
1607
|
+
return failWithError(error);
|
|
1608
|
+
}
|
|
1355
1609
|
}
|
|
1356
1610
|
}
|
|
1357
1611
|
|
|
@@ -1381,6 +1635,8 @@ export const runBrowserController = async (
|
|
|
1381
1635
|
return;
|
|
1382
1636
|
}
|
|
1383
1637
|
|
|
1638
|
+
await notifyTestRunStart();
|
|
1639
|
+
|
|
1384
1640
|
const isWatchMode = context.command === 'watch';
|
|
1385
1641
|
const tempDir =
|
|
1386
1642
|
isWatchMode && watchContext.runtime
|
|
@@ -1402,12 +1658,7 @@ export const runBrowserController = async (
|
|
|
1402
1658
|
|
|
1403
1659
|
// Track initial test files for watch mode
|
|
1404
1660
|
if (isWatchMode) {
|
|
1405
|
-
watchContext.lastTestFiles = projectEntries
|
|
1406
|
-
entry.testFiles.map((testPath) => ({
|
|
1407
|
-
testPath,
|
|
1408
|
-
projectName: entry.project.name,
|
|
1409
|
-
})),
|
|
1410
|
-
);
|
|
1661
|
+
watchContext.lastTestFiles = collectWatchTestFiles(projectEntries);
|
|
1411
1662
|
}
|
|
1412
1663
|
|
|
1413
1664
|
let runtime = isWatchMode ? watchContext.runtime : null;
|
|
@@ -1484,149 +1735,99 @@ export const runBrowserController = async (
|
|
|
1484
1735
|
rpcTimeout: maxTestTimeoutForRpc,
|
|
1485
1736
|
};
|
|
1486
1737
|
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
const
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1738
|
+
const browserProviderProjects: BrowserProviderProject[] = browserProjects.map(
|
|
1739
|
+
(project) => ({
|
|
1740
|
+
rootPath: normalize(project.rootPath),
|
|
1741
|
+
provider: project.normalizedConfig.browser.provider,
|
|
1742
|
+
}),
|
|
1743
|
+
);
|
|
1744
|
+
const implementationByProvider = new Map<
|
|
1745
|
+
BrowserProvider,
|
|
1746
|
+
BrowserProviderImplementation
|
|
1747
|
+
>();
|
|
1748
|
+
for (const browserProject of browserProviderProjects) {
|
|
1749
|
+
if (!implementationByProvider.has(browserProject.provider)) {
|
|
1750
|
+
implementationByProvider.set(
|
|
1751
|
+
browserProject.provider,
|
|
1752
|
+
getBrowserProviderImplementation(browserProject.provider),
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1505
1756
|
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1757
|
+
let activeContainerPage: BrowserProviderPage | null = null;
|
|
1758
|
+
let getHeadlessRunnerPageBySessionId:
|
|
1759
|
+
| ((sessionId: string) => BrowserProviderPage | undefined)
|
|
1760
|
+
| undefined;
|
|
1761
|
+
|
|
1762
|
+
const dispatchBrowserRpcRequest = async ({
|
|
1763
|
+
request,
|
|
1764
|
+
target,
|
|
1765
|
+
}: {
|
|
1766
|
+
request: BrowserRpcRequest;
|
|
1767
|
+
target?: BrowserDispatchRequest['target'];
|
|
1768
|
+
}): Promise<unknown> => {
|
|
1769
|
+
const timeoutFallbackMs = maxTestTimeoutForRpc;
|
|
1770
|
+
const provider = resolveProviderForTestPath({
|
|
1771
|
+
testPath: request.testPath,
|
|
1772
|
+
browserProjects: browserProviderProjects,
|
|
1514
1773
|
});
|
|
1515
|
-
|
|
1774
|
+
const implementation = implementationByProvider.get(provider);
|
|
1775
|
+
if (!implementation) {
|
|
1776
|
+
throw new Error(`Browser provider implementation not found: ${provider}`);
|
|
1777
|
+
}
|
|
1516
1778
|
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
});
|
|
1779
|
+
const runnerPage = target?.sessionId
|
|
1780
|
+
? getHeadlessRunnerPageBySessionId?.(target.sessionId)
|
|
1781
|
+
: undefined;
|
|
1521
1782
|
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
}
|
|
1783
|
+
if (target?.sessionId && !runnerPage) {
|
|
1784
|
+
throw new Error(
|
|
1785
|
+
`Runner page session not found for browser dispatch: ${target.sessionId}`,
|
|
1786
|
+
);
|
|
1787
|
+
}
|
|
1527
1788
|
|
|
1528
|
-
if (
|
|
1529
|
-
|
|
1530
|
-
runtime.containerContext = containerContext;
|
|
1789
|
+
if (!runnerPage && !activeContainerPage) {
|
|
1790
|
+
throw new Error('Browser container page is not initialized');
|
|
1531
1791
|
}
|
|
1532
1792
|
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1793
|
+
try {
|
|
1794
|
+
return await implementation.dispatchRpc({
|
|
1795
|
+
containerPage: runnerPage
|
|
1796
|
+
? undefined
|
|
1797
|
+
: (activeContainerPage ?? undefined),
|
|
1798
|
+
runnerPage,
|
|
1799
|
+
request,
|
|
1800
|
+
timeoutFallbackMs,
|
|
1801
|
+
});
|
|
1802
|
+
} catch (error) {
|
|
1803
|
+
// birpc serializes thrown Errors as `{}` over JSON; throw a string instead.
|
|
1804
|
+
if (error instanceof Error) {
|
|
1805
|
+
throw error.message;
|
|
1542
1806
|
}
|
|
1807
|
+
throw String(error);
|
|
1808
|
+
}
|
|
1809
|
+
};
|
|
1810
|
+
|
|
1811
|
+
runtime.dispatchHandlers.set('browser', async (dispatchRequest) => {
|
|
1812
|
+
const request = validateBrowserRpcRequest(dispatchRequest.args);
|
|
1813
|
+
return dispatchBrowserRpcRequest({
|
|
1814
|
+
request,
|
|
1815
|
+
target: dispatchRequest.target,
|
|
1543
1816
|
});
|
|
1544
|
-
}
|
|
1817
|
+
});
|
|
1545
1818
|
|
|
1546
|
-
|
|
1547
|
-
const createRpcMethods = (): HostRpcMethods => ({
|
|
1548
|
-
async rerunTest(testFile: string, testNamePattern?: string) {
|
|
1549
|
-
const projectName = context.normalizedConfig.name || 'project';
|
|
1550
|
-
const relativePath = relative(context.rootPath, testFile);
|
|
1551
|
-
const displayPath = `<${projectName}>/${relativePath}`;
|
|
1552
|
-
logger.log(
|
|
1553
|
-
color.cyan(
|
|
1554
|
-
`\nRe-running test: ${displayPath}${testNamePattern ? ` (pattern: ${testNamePattern})` : ''}\n`,
|
|
1555
|
-
),
|
|
1556
|
-
);
|
|
1557
|
-
await rpcManager.reloadTestFile(testFile, testNamePattern);
|
|
1558
|
-
},
|
|
1559
|
-
async getTestFiles() {
|
|
1560
|
-
return allTestFiles;
|
|
1561
|
-
},
|
|
1562
|
-
async onTestFileStart(payload: TestFileStartPayload) {
|
|
1563
|
-
await Promise.all(
|
|
1564
|
-
context.reporters.map((reporter) =>
|
|
1565
|
-
(reporter as Reporter).onTestFileStart?.({
|
|
1566
|
-
testPath: payload.testPath,
|
|
1567
|
-
tests: [],
|
|
1568
|
-
}),
|
|
1569
|
-
),
|
|
1570
|
-
);
|
|
1571
|
-
},
|
|
1572
|
-
async onTestCaseResult(payload: TestResult) {
|
|
1573
|
-
caseResults.push(payload);
|
|
1574
|
-
await Promise.all(
|
|
1575
|
-
context.reporters.map((reporter) =>
|
|
1576
|
-
(reporter as Reporter).onTestCaseResult?.(payload),
|
|
1577
|
-
),
|
|
1578
|
-
);
|
|
1579
|
-
},
|
|
1580
|
-
async onTestFileComplete(payload: TestFileResult) {
|
|
1581
|
-
reporterResults.push(payload);
|
|
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
|
-
}
|
|
1595
|
-
},
|
|
1596
|
-
async onLog(payload: LogPayload) {
|
|
1597
|
-
const log: UserConsoleLog = {
|
|
1598
|
-
content: payload.content,
|
|
1599
|
-
name: payload.level,
|
|
1600
|
-
testPath: payload.testPath,
|
|
1601
|
-
type: payload.type,
|
|
1602
|
-
trace: payload.trace,
|
|
1603
|
-
};
|
|
1819
|
+
runtime.setContainerOptions(hostOptions);
|
|
1604
1820
|
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1821
|
+
// Track test results from browser runners
|
|
1822
|
+
const reporterResults: TestFileResult[] = [];
|
|
1823
|
+
const caseResults: TestResult[] = [];
|
|
1824
|
+
let fatalError: Error | null = null;
|
|
1608
1825
|
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
context.reporters.map((reporter) =>
|
|
1612
|
-
(reporter as Reporter).onUserConsoleLog?.(log),
|
|
1613
|
-
),
|
|
1614
|
-
);
|
|
1615
|
-
}
|
|
1616
|
-
},
|
|
1617
|
-
async onFatal(payload: FatalPayload) {
|
|
1618
|
-
fatalError = new Error(payload.message);
|
|
1619
|
-
fatalError.stack = payload.stack;
|
|
1620
|
-
if (resolveAllTests) {
|
|
1621
|
-
resolveAllTests();
|
|
1622
|
-
}
|
|
1623
|
-
},
|
|
1624
|
-
// Snapshot file operations
|
|
1625
|
-
async resolveSnapshotPath(testPath: string) {
|
|
1826
|
+
const snapshotRpcMethods = {
|
|
1827
|
+
async resolveSnapshotPath(testPath: string): Promise<string> {
|
|
1626
1828
|
const snapExtension = '.snap';
|
|
1627
1829
|
const resolver =
|
|
1628
1830
|
context.normalizedConfig.resolveSnapshotPath ||
|
|
1629
|
-
// test/index.ts -> test/__snapshots__/index.ts.snap
|
|
1630
1831
|
(() =>
|
|
1631
1832
|
join(
|
|
1632
1833
|
dirname(testPath),
|
|
@@ -1635,25 +1836,727 @@ export const runBrowserController = async (
|
|
|
1635
1836
|
));
|
|
1636
1837
|
return resolver(testPath, snapExtension);
|
|
1637
1838
|
},
|
|
1638
|
-
async readSnapshotFile(filepath: string) {
|
|
1839
|
+
async readSnapshotFile(filepath: string): Promise<string | null> {
|
|
1639
1840
|
try {
|
|
1640
1841
|
return await fs.readFile(filepath, 'utf-8');
|
|
1641
1842
|
} catch {
|
|
1642
1843
|
return null;
|
|
1643
1844
|
}
|
|
1644
1845
|
},
|
|
1645
|
-
async saveSnapshotFile(filepath: string, content: string) {
|
|
1846
|
+
async saveSnapshotFile(filepath: string, content: string): Promise<void> {
|
|
1646
1847
|
const dir = dirname(filepath);
|
|
1647
1848
|
await fs.mkdir(dir, { recursive: true });
|
|
1648
1849
|
await fs.writeFile(filepath, content, 'utf-8');
|
|
1649
1850
|
},
|
|
1650
|
-
async removeSnapshotFile(filepath: string) {
|
|
1851
|
+
async removeSnapshotFile(filepath: string): Promise<void> {
|
|
1651
1852
|
try {
|
|
1652
1853
|
await fs.unlink(filepath);
|
|
1653
1854
|
} catch {
|
|
1654
1855
|
// ignore if file doesn't exist
|
|
1655
1856
|
}
|
|
1656
1857
|
},
|
|
1858
|
+
};
|
|
1859
|
+
|
|
1860
|
+
const handleTestFileStart = async (
|
|
1861
|
+
payload: TestFileStartPayload,
|
|
1862
|
+
): Promise<void> => {
|
|
1863
|
+
await Promise.all(
|
|
1864
|
+
context.reporters.map((reporter) =>
|
|
1865
|
+
(reporter as Reporter).onTestFileStart?.({
|
|
1866
|
+
testPath: payload.testPath,
|
|
1867
|
+
tests: [],
|
|
1868
|
+
}),
|
|
1869
|
+
),
|
|
1870
|
+
);
|
|
1871
|
+
};
|
|
1872
|
+
|
|
1873
|
+
const handleTestFileReady = async (
|
|
1874
|
+
payload: TestFileReadyPayload,
|
|
1875
|
+
): Promise<void> => {
|
|
1876
|
+
await Promise.all(
|
|
1877
|
+
context.reporters.map((reporter) =>
|
|
1878
|
+
(reporter as Reporter).onTestFileReady?.(payload),
|
|
1879
|
+
),
|
|
1880
|
+
);
|
|
1881
|
+
};
|
|
1882
|
+
|
|
1883
|
+
const handleTestSuiteStart = async (
|
|
1884
|
+
payload: TestSuiteStartPayload,
|
|
1885
|
+
): Promise<void> => {
|
|
1886
|
+
await Promise.all(
|
|
1887
|
+
context.reporters.map((reporter) =>
|
|
1888
|
+
(reporter as Reporter).onTestSuiteStart?.(payload),
|
|
1889
|
+
),
|
|
1890
|
+
);
|
|
1891
|
+
};
|
|
1892
|
+
|
|
1893
|
+
const handleTestSuiteResult = async (
|
|
1894
|
+
payload: TestSuiteResultPayload,
|
|
1895
|
+
): Promise<void> => {
|
|
1896
|
+
await Promise.all(
|
|
1897
|
+
context.reporters.map((reporter) =>
|
|
1898
|
+
(reporter as Reporter).onTestSuiteResult?.(payload),
|
|
1899
|
+
),
|
|
1900
|
+
);
|
|
1901
|
+
};
|
|
1902
|
+
|
|
1903
|
+
const handleTestCaseStart = async (
|
|
1904
|
+
payload: TestCaseStartPayload,
|
|
1905
|
+
): Promise<void> => {
|
|
1906
|
+
await Promise.all(
|
|
1907
|
+
context.reporters.map((reporter) =>
|
|
1908
|
+
(reporter as Reporter).onTestCaseStart?.(payload),
|
|
1909
|
+
),
|
|
1910
|
+
);
|
|
1911
|
+
};
|
|
1912
|
+
|
|
1913
|
+
const handleTestCaseResult = async (payload: TestResult): Promise<void> => {
|
|
1914
|
+
caseResults.push(payload);
|
|
1915
|
+
await Promise.all(
|
|
1916
|
+
context.reporters.map((reporter) =>
|
|
1917
|
+
(reporter as Reporter).onTestCaseResult?.(payload),
|
|
1918
|
+
),
|
|
1919
|
+
);
|
|
1920
|
+
};
|
|
1921
|
+
|
|
1922
|
+
const handleTestFileComplete = async (
|
|
1923
|
+
payload: TestFileResult,
|
|
1924
|
+
): Promise<void> => {
|
|
1925
|
+
reporterResults.push(payload);
|
|
1926
|
+
context.updateReporterResultState([payload], payload.results);
|
|
1927
|
+
if (payload.snapshotResult) {
|
|
1928
|
+
context.snapshotManager.add(payload.snapshotResult);
|
|
1929
|
+
}
|
|
1930
|
+
await Promise.all(
|
|
1931
|
+
context.reporters.map((reporter) =>
|
|
1932
|
+
(reporter as Reporter).onTestFileResult?.(payload),
|
|
1933
|
+
),
|
|
1934
|
+
);
|
|
1935
|
+
if (payload.status === 'fail') {
|
|
1936
|
+
ensureProcessExitCode(1);
|
|
1937
|
+
}
|
|
1938
|
+
};
|
|
1939
|
+
|
|
1940
|
+
const handleLog = async (payload: LogPayload): Promise<void> => {
|
|
1941
|
+
const log: UserConsoleLog = {
|
|
1942
|
+
content: payload.content,
|
|
1943
|
+
name: payload.level,
|
|
1944
|
+
testPath: payload.testPath,
|
|
1945
|
+
type: payload.type,
|
|
1946
|
+
trace: payload.trace,
|
|
1947
|
+
};
|
|
1948
|
+
const shouldLog =
|
|
1949
|
+
context.normalizedConfig.onConsoleLog?.(log.content) ?? true;
|
|
1950
|
+
if (shouldLog) {
|
|
1951
|
+
await Promise.all(
|
|
1952
|
+
context.reporters.map((reporter) =>
|
|
1953
|
+
(reporter as Reporter).onUserConsoleLog?.(log),
|
|
1954
|
+
),
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
};
|
|
1958
|
+
|
|
1959
|
+
const handleFatal = async (payload: FatalPayload): Promise<void> => {
|
|
1960
|
+
const error = new Error(payload.message);
|
|
1961
|
+
error.stack = payload.stack;
|
|
1962
|
+
fatalError = error;
|
|
1963
|
+
ensureProcessExitCode(1);
|
|
1964
|
+
};
|
|
1965
|
+
|
|
1966
|
+
const runSnapshotRpc = async (
|
|
1967
|
+
request: SnapshotRpcRequest,
|
|
1968
|
+
): Promise<unknown> => {
|
|
1969
|
+
switch (request.method) {
|
|
1970
|
+
case 'resolveSnapshotPath':
|
|
1971
|
+
return snapshotRpcMethods.resolveSnapshotPath(request.args.testPath);
|
|
1972
|
+
case 'readSnapshotFile':
|
|
1973
|
+
return snapshotRpcMethods.readSnapshotFile(request.args.filepath);
|
|
1974
|
+
case 'saveSnapshotFile':
|
|
1975
|
+
return snapshotRpcMethods.saveSnapshotFile(
|
|
1976
|
+
request.args.filepath,
|
|
1977
|
+
request.args.content,
|
|
1978
|
+
);
|
|
1979
|
+
case 'removeSnapshotFile':
|
|
1980
|
+
return snapshotRpcMethods.removeSnapshotFile(request.args.filepath);
|
|
1981
|
+
default:
|
|
1982
|
+
return undefined;
|
|
1983
|
+
}
|
|
1984
|
+
};
|
|
1985
|
+
|
|
1986
|
+
const createDispatchRouter = (options?: HostDispatchRouterOptions) => {
|
|
1987
|
+
return createHostDispatchRouter({
|
|
1988
|
+
routerOptions: options,
|
|
1989
|
+
runnerCallbacks: {
|
|
1990
|
+
onTestFileStart: handleTestFileStart,
|
|
1991
|
+
onTestFileReady: handleTestFileReady,
|
|
1992
|
+
onTestSuiteStart: handleTestSuiteStart,
|
|
1993
|
+
onTestSuiteResult: handleTestSuiteResult,
|
|
1994
|
+
onTestCaseStart: handleTestCaseStart,
|
|
1995
|
+
onTestCaseResult: handleTestCaseResult,
|
|
1996
|
+
onTestFileComplete: handleTestFileComplete,
|
|
1997
|
+
onLog: handleLog,
|
|
1998
|
+
onFatal: handleFatal,
|
|
1999
|
+
},
|
|
2000
|
+
runSnapshotRpc,
|
|
2001
|
+
extensionHandlers: runtime.dispatchHandlers,
|
|
2002
|
+
onDuplicateNamespace: (namespace) => {
|
|
2003
|
+
logger.debug(
|
|
2004
|
+
`[Dispatch] Skip registering dispatch namespace "${namespace}" because it is already reserved`,
|
|
2005
|
+
);
|
|
2006
|
+
},
|
|
2007
|
+
});
|
|
2008
|
+
};
|
|
2009
|
+
|
|
2010
|
+
if (useHeadlessDirect) {
|
|
2011
|
+
// Session-based scheduling path: lifecycle + session index + dispatch routing.
|
|
2012
|
+
type ActiveHeadlessRun = RunSession & {
|
|
2013
|
+
contexts: Set<BrowserProviderContext>;
|
|
2014
|
+
};
|
|
2015
|
+
|
|
2016
|
+
const viewportByProject = mapViewportByProject(projectRuntimeConfigs);
|
|
2017
|
+
const runLifecycle = new RunSessionLifecycle<ActiveHeadlessRun>();
|
|
2018
|
+
const sessionRegistry = new RunnerSessionRegistry();
|
|
2019
|
+
getHeadlessRunnerPageBySessionId = (sessionId) => {
|
|
2020
|
+
return sessionRegistry.getById(sessionId)?.page;
|
|
2021
|
+
};
|
|
2022
|
+
let dispatchRequestCounter = 0;
|
|
2023
|
+
|
|
2024
|
+
const nextDispatchRequestId = (namespace: string): string => {
|
|
2025
|
+
return `${namespace}-${++dispatchRequestCounter}`;
|
|
2026
|
+
};
|
|
2027
|
+
|
|
2028
|
+
const closeContextSafely = async (
|
|
2029
|
+
browserContext: BrowserProviderContext,
|
|
2030
|
+
): Promise<void> => {
|
|
2031
|
+
try {
|
|
2032
|
+
await browserContext.close();
|
|
2033
|
+
} catch {
|
|
2034
|
+
// ignore
|
|
2035
|
+
}
|
|
2036
|
+
};
|
|
2037
|
+
|
|
2038
|
+
const cancelRun = async (
|
|
2039
|
+
run: ActiveHeadlessRun,
|
|
2040
|
+
waitForDone = true,
|
|
2041
|
+
): Promise<void> => {
|
|
2042
|
+
await runLifecycle.cancel(run, {
|
|
2043
|
+
waitForDone,
|
|
2044
|
+
onCancel: async (session) => {
|
|
2045
|
+
await Promise.all(
|
|
2046
|
+
Array.from(session.contexts).map((browserContext) =>
|
|
2047
|
+
closeContextSafely(browserContext),
|
|
2048
|
+
),
|
|
2049
|
+
);
|
|
2050
|
+
},
|
|
2051
|
+
});
|
|
2052
|
+
};
|
|
2053
|
+
|
|
2054
|
+
const dispatchRouter = createDispatchRouter({
|
|
2055
|
+
isRunTokenStale: (runToken) => runLifecycle.isTokenStale(runToken),
|
|
2056
|
+
onStale: (request) => {
|
|
2057
|
+
if (request.namespace === DISPATCH_NAMESPACE_RUNNER) {
|
|
2058
|
+
logger.debug(
|
|
2059
|
+
`[Headless] Dropped stale message "${request.method}" for ${request.target?.testFile ?? 'unknown'}`,
|
|
2060
|
+
);
|
|
2061
|
+
}
|
|
2062
|
+
},
|
|
2063
|
+
});
|
|
2064
|
+
|
|
2065
|
+
const dispatchRunnerMessage = async (
|
|
2066
|
+
run: ActiveHeadlessRun,
|
|
2067
|
+
file: TestFileInfo,
|
|
2068
|
+
sessionId: string,
|
|
2069
|
+
message: BrowserClientMessage,
|
|
2070
|
+
): Promise<void> => {
|
|
2071
|
+
const response = await dispatchRouter.dispatch({
|
|
2072
|
+
requestId: nextDispatchRequestId('runner'),
|
|
2073
|
+
runToken: run.token,
|
|
2074
|
+
namespace: DISPATCH_NAMESPACE_RUNNER,
|
|
2075
|
+
method: message.type,
|
|
2076
|
+
args: 'payload' in message ? message.payload : undefined,
|
|
2077
|
+
target: {
|
|
2078
|
+
sessionId,
|
|
2079
|
+
testFile: file.testPath,
|
|
2080
|
+
projectName: file.projectName,
|
|
2081
|
+
},
|
|
2082
|
+
});
|
|
2083
|
+
|
|
2084
|
+
if (response.stale) {
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
if (response.error) {
|
|
2089
|
+
throw new Error(response.error);
|
|
2090
|
+
}
|
|
2091
|
+
};
|
|
2092
|
+
|
|
2093
|
+
const runSingleFile = async (
|
|
2094
|
+
run: ActiveHeadlessRun,
|
|
2095
|
+
file: TestFileInfo,
|
|
2096
|
+
): Promise<void> => {
|
|
2097
|
+
if (run.cancelled || runLifecycle.isTokenStale(run.token)) {
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
const viewport = viewportByProject.get(file.projectName);
|
|
2102
|
+
const browserContext = await browser.newContext({
|
|
2103
|
+
viewport: viewport ?? null,
|
|
2104
|
+
});
|
|
2105
|
+
run.contexts.add(browserContext);
|
|
2106
|
+
|
|
2107
|
+
let page: BrowserProviderPage | null = null;
|
|
2108
|
+
let sessionId: string | null = null;
|
|
2109
|
+
let settled = false;
|
|
2110
|
+
let resolveDone: (() => void) | null = null;
|
|
2111
|
+
|
|
2112
|
+
const markDone = (): void => {
|
|
2113
|
+
if (!settled) {
|
|
2114
|
+
settled = true;
|
|
2115
|
+
resolveDone?.();
|
|
2116
|
+
}
|
|
2117
|
+
};
|
|
2118
|
+
|
|
2119
|
+
const donePromise = new Promise<void>((resolve) => {
|
|
2120
|
+
resolveDone = resolve;
|
|
2121
|
+
});
|
|
2122
|
+
|
|
2123
|
+
const projectRuntime = projectRuntimeConfigs.find(
|
|
2124
|
+
(project) => project.name === file.projectName,
|
|
2125
|
+
);
|
|
2126
|
+
const perFileTimeoutMs =
|
|
2127
|
+
(projectRuntime?.runtimeConfig.testTimeout ?? maxTestTimeoutForRpc) +
|
|
2128
|
+
30_000;
|
|
2129
|
+
|
|
2130
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
2131
|
+
|
|
2132
|
+
try {
|
|
2133
|
+
page = await browserContext.newPage();
|
|
2134
|
+
|
|
2135
|
+
const session = sessionRegistry.register({
|
|
2136
|
+
testFile: file.testPath,
|
|
2137
|
+
projectName: file.projectName,
|
|
2138
|
+
runToken: run.token,
|
|
2139
|
+
mode: 'headless-page',
|
|
2140
|
+
context: browserContext,
|
|
2141
|
+
page,
|
|
2142
|
+
});
|
|
2143
|
+
sessionId = session.id;
|
|
2144
|
+
|
|
2145
|
+
await attachHeadlessRunnerTransport(page, {
|
|
2146
|
+
onDispatchMessage: async (message) => {
|
|
2147
|
+
try {
|
|
2148
|
+
await dispatchRunnerMessage(run, file, session.id, message);
|
|
2149
|
+
if (
|
|
2150
|
+
message.type === 'file-complete' ||
|
|
2151
|
+
message.type === 'complete'
|
|
2152
|
+
) {
|
|
2153
|
+
markDone();
|
|
2154
|
+
} else if (message.type === 'fatal') {
|
|
2155
|
+
markDone();
|
|
2156
|
+
await cancelRun(run, false);
|
|
2157
|
+
}
|
|
2158
|
+
} catch (error) {
|
|
2159
|
+
const formatted = toError(error);
|
|
2160
|
+
await handleFatal({
|
|
2161
|
+
message: formatted.message,
|
|
2162
|
+
stack: formatted.stack,
|
|
2163
|
+
});
|
|
2164
|
+
markDone();
|
|
2165
|
+
await cancelRun(run, false);
|
|
2166
|
+
}
|
|
2167
|
+
},
|
|
2168
|
+
onDispatchRpc: async (request) => {
|
|
2169
|
+
return dispatchRouter.dispatch({
|
|
2170
|
+
...request,
|
|
2171
|
+
runToken: run.token,
|
|
2172
|
+
target: {
|
|
2173
|
+
sessionId: session.id,
|
|
2174
|
+
testFile: file.testPath,
|
|
2175
|
+
projectName: file.projectName,
|
|
2176
|
+
...request.target,
|
|
2177
|
+
},
|
|
2178
|
+
});
|
|
2179
|
+
},
|
|
2180
|
+
});
|
|
2181
|
+
|
|
2182
|
+
const inlineOptions: BrowserHostConfig = {
|
|
2183
|
+
...hostOptions,
|
|
2184
|
+
testFile: file.testPath,
|
|
2185
|
+
runId: `${run.token}:${session.id}`,
|
|
2186
|
+
};
|
|
2187
|
+
const serializedOptions = serializeForInlineScript(inlineOptions);
|
|
2188
|
+
await page.addInitScript(
|
|
2189
|
+
`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
|
|
2190
|
+
);
|
|
2191
|
+
|
|
2192
|
+
await page.goto(`http://localhost:${port}/runner.html`, {
|
|
2193
|
+
waitUntil: 'load',
|
|
2194
|
+
});
|
|
2195
|
+
|
|
2196
|
+
const timeoutPromise = new Promise<'timeout'>((resolve) => {
|
|
2197
|
+
timeoutId = setTimeout(() => resolve('timeout'), perFileTimeoutMs);
|
|
2198
|
+
});
|
|
2199
|
+
|
|
2200
|
+
const state = await Promise.race([
|
|
2201
|
+
donePromise.then(() => 'done' as const),
|
|
2202
|
+
timeoutPromise,
|
|
2203
|
+
run.cancelSignal.then(() => 'cancelled' as const),
|
|
2204
|
+
]);
|
|
2205
|
+
|
|
2206
|
+
if (state === 'cancelled') {
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
if (
|
|
2211
|
+
state === 'timeout' &&
|
|
2212
|
+
runLifecycle.isTokenActive(run.token) &&
|
|
2213
|
+
!run.cancelled
|
|
2214
|
+
) {
|
|
2215
|
+
await handleFatal({
|
|
2216
|
+
message: `Test execution timeout after ${perFileTimeoutMs / 1000}s for ${file.testPath}.`,
|
|
2217
|
+
});
|
|
2218
|
+
await cancelRun(run, false);
|
|
2219
|
+
}
|
|
2220
|
+
} catch (error) {
|
|
2221
|
+
if (runLifecycle.isTokenActive(run.token) && !run.cancelled) {
|
|
2222
|
+
const formatted = toError(error);
|
|
2223
|
+
await handleFatal({
|
|
2224
|
+
message: formatted.message,
|
|
2225
|
+
stack: formatted.stack,
|
|
2226
|
+
});
|
|
2227
|
+
await cancelRun(run, false);
|
|
2228
|
+
}
|
|
2229
|
+
} finally {
|
|
2230
|
+
if (timeoutId) {
|
|
2231
|
+
clearTimeout(timeoutId);
|
|
2232
|
+
}
|
|
2233
|
+
if (page) {
|
|
2234
|
+
try {
|
|
2235
|
+
await page.close();
|
|
2236
|
+
} catch {
|
|
2237
|
+
// ignore
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
if (sessionId) {
|
|
2241
|
+
sessionRegistry.deleteById(sessionId);
|
|
2242
|
+
}
|
|
2243
|
+
run.contexts.delete(browserContext);
|
|
2244
|
+
await closeContextSafely(browserContext);
|
|
2245
|
+
}
|
|
2246
|
+
};
|
|
2247
|
+
|
|
2248
|
+
const runFilesWithPool = async (files: TestFileInfo[]): Promise<void> => {
|
|
2249
|
+
if (files.length === 0) {
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
const previous = runLifecycle.activeSession;
|
|
2254
|
+
if (previous) {
|
|
2255
|
+
await cancelRun(previous);
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
const run = runLifecycle.createSession((token) => ({
|
|
2259
|
+
...createRunSession(token),
|
|
2260
|
+
contexts: new Set<BrowserProviderContext>(),
|
|
2261
|
+
}));
|
|
2262
|
+
|
|
2263
|
+
const queue = [...files];
|
|
2264
|
+
const concurrency = getHeadlessConcurrency(context, queue.length);
|
|
2265
|
+
|
|
2266
|
+
const worker = async (): Promise<void> => {
|
|
2267
|
+
while (
|
|
2268
|
+
queue.length > 0 &&
|
|
2269
|
+
!run.cancelled &&
|
|
2270
|
+
runLifecycle.isTokenActive(run.token)
|
|
2271
|
+
) {
|
|
2272
|
+
const next = queue.shift();
|
|
2273
|
+
if (!next) {
|
|
2274
|
+
return;
|
|
2275
|
+
}
|
|
2276
|
+
await runSingleFile(run, next);
|
|
2277
|
+
}
|
|
2278
|
+
};
|
|
2279
|
+
|
|
2280
|
+
run.done = Promise.all(
|
|
2281
|
+
Array.from(
|
|
2282
|
+
{ length: Math.min(queue.length, Math.max(concurrency, 1)) },
|
|
2283
|
+
() => worker(),
|
|
2284
|
+
),
|
|
2285
|
+
).then(() => {});
|
|
2286
|
+
|
|
2287
|
+
await run.done;
|
|
2288
|
+
runLifecycle.clearIfActive(run);
|
|
2289
|
+
};
|
|
2290
|
+
|
|
2291
|
+
const latestRerunScheduler = createHeadlessLatestRerunScheduler<
|
|
2292
|
+
TestFileInfo,
|
|
2293
|
+
ActiveHeadlessRun
|
|
2294
|
+
>({
|
|
2295
|
+
getActiveRun: () => runLifecycle.activeSession,
|
|
2296
|
+
isRunCancelled: (run) => run.cancelled,
|
|
2297
|
+
invalidateActiveRun: () => {
|
|
2298
|
+
runLifecycle.invalidateActiveToken();
|
|
2299
|
+
},
|
|
2300
|
+
interruptActiveRun: async (run) => {
|
|
2301
|
+
await cancelRun(run, false);
|
|
2302
|
+
},
|
|
2303
|
+
runFiles: async (files) => {
|
|
2304
|
+
await notifyTestRunStart();
|
|
2305
|
+
|
|
2306
|
+
const rerunStartTime = Date.now();
|
|
2307
|
+
const fatalErrorBeforeRun = fatalError;
|
|
2308
|
+
let rerunError: Error | undefined;
|
|
2309
|
+
|
|
2310
|
+
try {
|
|
2311
|
+
await runFilesWithPool(files);
|
|
2312
|
+
} catch (error) {
|
|
2313
|
+
rerunError = toError(error);
|
|
2314
|
+
throw error;
|
|
2315
|
+
} finally {
|
|
2316
|
+
const testTime = Math.max(0, Date.now() - rerunStartTime);
|
|
2317
|
+
const rerunFatalError =
|
|
2318
|
+
fatalError && fatalError !== fatalErrorBeforeRun
|
|
2319
|
+
? fatalError
|
|
2320
|
+
: undefined;
|
|
2321
|
+
await notifyTestRunEnd({
|
|
2322
|
+
duration: {
|
|
2323
|
+
totalTime: testTime,
|
|
2324
|
+
buildTime: 0,
|
|
2325
|
+
testTime,
|
|
2326
|
+
},
|
|
2327
|
+
filterRerunTestPaths: files.map((file) => file.testPath),
|
|
2328
|
+
unhandledErrors: rerunError
|
|
2329
|
+
? [rerunError]
|
|
2330
|
+
: rerunFatalError
|
|
2331
|
+
? [rerunFatalError]
|
|
2332
|
+
: undefined,
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
2335
|
+
},
|
|
2336
|
+
onError: async (error) => {
|
|
2337
|
+
const formatted = toError(error);
|
|
2338
|
+
await handleFatal({
|
|
2339
|
+
message: formatted.message,
|
|
2340
|
+
stack: formatted.stack,
|
|
2341
|
+
});
|
|
2342
|
+
},
|
|
2343
|
+
onInterrupt: (run) => {
|
|
2344
|
+
logger.debug(
|
|
2345
|
+
`[Headless] Interrupting active run token ${run.token} before scheduling latest rerun`,
|
|
2346
|
+
);
|
|
2347
|
+
},
|
|
2348
|
+
});
|
|
2349
|
+
|
|
2350
|
+
const testStart = Date.now();
|
|
2351
|
+
await runFilesWithPool(allTestFiles);
|
|
2352
|
+
const testTime = Date.now() - testStart;
|
|
2353
|
+
|
|
2354
|
+
if (isWatchMode) {
|
|
2355
|
+
triggerRerun = async () => {
|
|
2356
|
+
const newProjectEntries = await collectProjectEntries(context);
|
|
2357
|
+
const rerunPlan = planWatchRerun({
|
|
2358
|
+
projectEntries: newProjectEntries,
|
|
2359
|
+
previousTestFiles: watchContext.lastTestFiles,
|
|
2360
|
+
affectedTestFiles: watchContext.affectedTestFiles,
|
|
2361
|
+
});
|
|
2362
|
+
watchContext.affectedTestFiles = [];
|
|
2363
|
+
|
|
2364
|
+
if (rerunPlan.filesChanged) {
|
|
2365
|
+
const deletedTestPaths = collectDeletedTestPaths(
|
|
2366
|
+
watchContext.lastTestFiles,
|
|
2367
|
+
rerunPlan.currentTestFiles,
|
|
2368
|
+
);
|
|
2369
|
+
if (deletedTestPaths.length > 0) {
|
|
2370
|
+
context.updateReporterResultState([], [], deletedTestPaths);
|
|
2371
|
+
}
|
|
2372
|
+
watchContext.lastTestFiles = rerunPlan.currentTestFiles;
|
|
2373
|
+
if (rerunPlan.currentTestFiles.length === 0) {
|
|
2374
|
+
await latestRerunScheduler.enqueueLatest([]);
|
|
2375
|
+
logger.log(
|
|
2376
|
+
color.cyan('No browser test files remain after update.\n'),
|
|
2377
|
+
);
|
|
2378
|
+
return;
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
logger.log(
|
|
2382
|
+
color.cyan(
|
|
2383
|
+
`Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`,
|
|
2384
|
+
),
|
|
2385
|
+
);
|
|
2386
|
+
void latestRerunScheduler.enqueueLatest(rerunPlan.currentTestFiles);
|
|
2387
|
+
return;
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
if (rerunPlan.affectedTestFiles.length === 0) {
|
|
2391
|
+
logger.log(
|
|
2392
|
+
color.cyan(
|
|
2393
|
+
'No affected browser test files detected, skipping re-run.\n',
|
|
2394
|
+
),
|
|
2395
|
+
);
|
|
2396
|
+
return;
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
logger.log(
|
|
2400
|
+
color.cyan(
|
|
2401
|
+
`Re-running ${rerunPlan.affectedTestFiles.length} affected test file(s)...\n`,
|
|
2402
|
+
),
|
|
2403
|
+
);
|
|
2404
|
+
void latestRerunScheduler.enqueueLatest(rerunPlan.affectedTestFiles);
|
|
2405
|
+
};
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
const closeHeadlessRuntime = !isWatchMode
|
|
2409
|
+
? async () => {
|
|
2410
|
+
sessionRegistry.clear();
|
|
2411
|
+
await destroyBrowserRuntime(runtime);
|
|
2412
|
+
}
|
|
2413
|
+
: undefined;
|
|
2414
|
+
|
|
2415
|
+
if (fatalError) {
|
|
2416
|
+
return failWithError(fatalError, closeHeadlessRuntime);
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
const duration = {
|
|
2420
|
+
totalTime: buildTime + testTime,
|
|
2421
|
+
buildTime,
|
|
2422
|
+
testTime,
|
|
2423
|
+
};
|
|
2424
|
+
|
|
2425
|
+
context.updateReporterResultState(reporterResults, caseResults);
|
|
2426
|
+
|
|
2427
|
+
const isFailure = reporterResults.some(
|
|
2428
|
+
(result: TestFileResult) => result.status === 'fail',
|
|
2429
|
+
);
|
|
2430
|
+
if (isFailure) {
|
|
2431
|
+
ensureProcessExitCode(1);
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
const result = {
|
|
2435
|
+
results: reporterResults,
|
|
2436
|
+
testResults: caseResults,
|
|
2437
|
+
duration,
|
|
2438
|
+
hasFailure: isFailure,
|
|
2439
|
+
getSourcemap: getBrowserSourcemap,
|
|
2440
|
+
resolveSourcemap: resolveBrowserSourcemap,
|
|
2441
|
+
close: skipOnTestRunEnd ? closeHeadlessRuntime : undefined,
|
|
2442
|
+
};
|
|
2443
|
+
|
|
2444
|
+
if (!skipOnTestRunEnd) {
|
|
2445
|
+
try {
|
|
2446
|
+
await notifyTestRunEnd({ duration });
|
|
2447
|
+
} finally {
|
|
2448
|
+
await closeHeadlessRuntime?.();
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
if (isWatchMode && triggerRerun) {
|
|
2453
|
+
watchContext.hooksEnabled = true;
|
|
2454
|
+
logger.log(
|
|
2455
|
+
color.cyan(
|
|
2456
|
+
'\nWatch mode enabled - will re-run tests on file changes\n',
|
|
2457
|
+
),
|
|
2458
|
+
);
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
return result;
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
let completedTests = 0;
|
|
2465
|
+
|
|
2466
|
+
// Promise that resolves when all tests complete
|
|
2467
|
+
let resolveAllTests: (() => void) | undefined;
|
|
2468
|
+
const allTestsPromise = new Promise<void>((resolve) => {
|
|
2469
|
+
resolveAllTests = resolve;
|
|
2470
|
+
});
|
|
2471
|
+
|
|
2472
|
+
// Open a container page for user to view (reuse in watch mode)
|
|
2473
|
+
let containerContext: BrowserProviderContext;
|
|
2474
|
+
let containerPage: BrowserProviderPage;
|
|
2475
|
+
let isNewPage = false;
|
|
2476
|
+
|
|
2477
|
+
if (isWatchMode && runtime.containerPage && runtime.containerContext) {
|
|
2478
|
+
containerContext = runtime.containerContext;
|
|
2479
|
+
containerPage = runtime.containerPage;
|
|
2480
|
+
logger.log(color.gray('\n[Watch] Reusing existing container page\n'));
|
|
2481
|
+
} else {
|
|
2482
|
+
isNewPage = true;
|
|
2483
|
+
containerContext = await browser.newContext({
|
|
2484
|
+
viewport: null,
|
|
2485
|
+
});
|
|
2486
|
+
containerPage = await containerContext.newPage();
|
|
2487
|
+
|
|
2488
|
+
// Prevent popup windows from being created
|
|
2489
|
+
containerPage.on('popup', async (popup: BrowserProviderPage) => {
|
|
2490
|
+
await popup.close().catch(() => {});
|
|
2491
|
+
});
|
|
2492
|
+
|
|
2493
|
+
containerContext.on('page', async (page: BrowserProviderPage) => {
|
|
2494
|
+
if (page !== containerPage) {
|
|
2495
|
+
await page.close().catch(() => {});
|
|
2496
|
+
}
|
|
2497
|
+
});
|
|
2498
|
+
|
|
2499
|
+
if (isWatchMode) {
|
|
2500
|
+
runtime.containerPage = containerPage;
|
|
2501
|
+
runtime.containerContext = containerContext;
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
// Forward browser console to terminal
|
|
2505
|
+
containerPage.on('console', (msg) => {
|
|
2506
|
+
const text = msg.text();
|
|
2507
|
+
if (text.startsWith('[Container]') || text.startsWith('[Runner]')) {
|
|
2508
|
+
logger.log(color.gray(`[Browser Console] ${text}`));
|
|
2509
|
+
}
|
|
2510
|
+
});
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
activeContainerPage = containerPage;
|
|
2514
|
+
|
|
2515
|
+
const dispatchRouter = createDispatchRouter();
|
|
2516
|
+
|
|
2517
|
+
// Create RPC methods that can access test state variables
|
|
2518
|
+
const createRpcMethods = (): HostRpcMethods => ({
|
|
2519
|
+
async rerunTest(testFile: string, testNamePattern?: string) {
|
|
2520
|
+
const projectName = context.normalizedConfig.name || 'project';
|
|
2521
|
+
const relativePath = relative(context.rootPath, testFile);
|
|
2522
|
+
const displayPath = `<${projectName}>/${relativePath}`;
|
|
2523
|
+
logger.log(
|
|
2524
|
+
color.cyan(
|
|
2525
|
+
`\nRe-running test: ${displayPath}${testNamePattern ? ` (pattern: ${testNamePattern})` : ''}\n`,
|
|
2526
|
+
),
|
|
2527
|
+
);
|
|
2528
|
+
await rpcManager.reloadTestFile(testFile, testNamePattern);
|
|
2529
|
+
},
|
|
2530
|
+
async getTestFiles() {
|
|
2531
|
+
return allTestFiles;
|
|
2532
|
+
},
|
|
2533
|
+
async onTestFileStart(payload: TestFileStartPayload) {
|
|
2534
|
+
await handleTestFileStart(payload);
|
|
2535
|
+
},
|
|
2536
|
+
async onTestCaseResult(payload: TestResult) {
|
|
2537
|
+
await handleTestCaseResult(payload);
|
|
2538
|
+
},
|
|
2539
|
+
async onTestFileComplete(payload: TestFileResult) {
|
|
2540
|
+
await handleTestFileComplete(payload);
|
|
2541
|
+
|
|
2542
|
+
completedTests++;
|
|
2543
|
+
if (completedTests >= allTestFiles.length && resolveAllTests) {
|
|
2544
|
+
resolveAllTests();
|
|
2545
|
+
}
|
|
2546
|
+
},
|
|
2547
|
+
async onLog(payload: LogPayload) {
|
|
2548
|
+
await handleLog(payload);
|
|
2549
|
+
},
|
|
2550
|
+
async onFatal(payload: FatalPayload) {
|
|
2551
|
+
await handleFatal(payload);
|
|
2552
|
+
if (resolveAllTests) {
|
|
2553
|
+
resolveAllTests();
|
|
2554
|
+
}
|
|
2555
|
+
},
|
|
2556
|
+
async dispatch(request: BrowserDispatchRequest) {
|
|
2557
|
+
// Headed/container path now shares the same dispatch contract as headless.
|
|
2558
|
+
return dispatchRouter.dispatch(request);
|
|
2559
|
+
},
|
|
1657
2560
|
});
|
|
1658
2561
|
|
|
1659
2562
|
// Setup RPC manager
|
|
@@ -1678,13 +2581,7 @@ export const runBrowserController = async (
|
|
|
1678
2581
|
|
|
1679
2582
|
// Only navigate on first creation
|
|
1680
2583
|
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
|
-
}
|
|
2584
|
+
const pagePath = '/';
|
|
1688
2585
|
await containerPage.goto(`http://localhost:${port}${pagePath}`, {
|
|
1689
2586
|
waitUntil: 'load',
|
|
1690
2587
|
});
|
|
@@ -1729,63 +2626,88 @@ export const runBrowserController = async (
|
|
|
1729
2626
|
if (isWatchMode) {
|
|
1730
2627
|
triggerRerun = async () => {
|
|
1731
2628
|
const newProjectEntries = await collectProjectEntries(context);
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
})),
|
|
1739
|
-
);
|
|
2629
|
+
const rerunPlan = planWatchRerun({
|
|
2630
|
+
projectEntries: newProjectEntries,
|
|
2631
|
+
previousTestFiles: watchContext.lastTestFiles,
|
|
2632
|
+
affectedTestFiles: watchContext.affectedTestFiles,
|
|
2633
|
+
});
|
|
2634
|
+
watchContext.affectedTestFiles = [];
|
|
1740
2635
|
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
2636
|
+
if (rerunPlan.filesChanged) {
|
|
2637
|
+
const deletedTestPaths = collectDeletedTestPaths(
|
|
2638
|
+
watchContext.lastTestFiles,
|
|
2639
|
+
rerunPlan.currentTestFiles,
|
|
1745
2640
|
);
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
watchContext.lastTestFiles = currentTestFiles;
|
|
1752
|
-
await rpcManager.notifyTestFileUpdate(currentTestFiles);
|
|
2641
|
+
if (deletedTestPaths.length > 0) {
|
|
2642
|
+
context.updateReporterResultState([], [], deletedTestPaths);
|
|
2643
|
+
}
|
|
2644
|
+
watchContext.lastTestFiles = rerunPlan.currentTestFiles;
|
|
2645
|
+
await rpcManager.notifyTestFileUpdate(rerunPlan.currentTestFiles);
|
|
1753
2646
|
}
|
|
1754
2647
|
|
|
1755
|
-
|
|
1756
|
-
watchContext.affectedTestFiles = [];
|
|
1757
|
-
|
|
1758
|
-
if (affectedFiles.length > 0) {
|
|
2648
|
+
if (rerunPlan.normalizedAffectedTestFiles.length > 0) {
|
|
1759
2649
|
logger.log(
|
|
1760
2650
|
color.cyan(
|
|
1761
|
-
`Re-running ${
|
|
2651
|
+
`Re-running ${rerunPlan.normalizedAffectedTestFiles.length} affected test file(s)...\n`,
|
|
1762
2652
|
),
|
|
1763
2653
|
);
|
|
1764
|
-
|
|
1765
|
-
|
|
2654
|
+
await notifyTestRunStart();
|
|
2655
|
+
|
|
2656
|
+
const rerunStartTime = Date.now();
|
|
2657
|
+
const fatalErrorBeforeRun = fatalError;
|
|
2658
|
+
let rerunError: Error | undefined;
|
|
2659
|
+
|
|
2660
|
+
try {
|
|
2661
|
+
for (const testFile of rerunPlan.normalizedAffectedTestFiles) {
|
|
2662
|
+
await rpcManager.reloadTestFile(testFile);
|
|
2663
|
+
}
|
|
2664
|
+
} catch (error) {
|
|
2665
|
+
rerunError = toError(error);
|
|
2666
|
+
throw error;
|
|
2667
|
+
} finally {
|
|
2668
|
+
const testTime = Math.max(0, Date.now() - rerunStartTime);
|
|
2669
|
+
const rerunFatalError =
|
|
2670
|
+
fatalError && fatalError !== fatalErrorBeforeRun
|
|
2671
|
+
? fatalError
|
|
2672
|
+
: undefined;
|
|
2673
|
+
await notifyTestRunEnd({
|
|
2674
|
+
duration: {
|
|
2675
|
+
totalTime: testTime,
|
|
2676
|
+
buildTime: 0,
|
|
2677
|
+
testTime,
|
|
2678
|
+
},
|
|
2679
|
+
filterRerunTestPaths: rerunPlan.normalizedAffectedTestFiles,
|
|
2680
|
+
unhandledErrors: rerunError
|
|
2681
|
+
? [rerunError]
|
|
2682
|
+
: rerunFatalError
|
|
2683
|
+
? [rerunFatalError]
|
|
2684
|
+
: undefined,
|
|
2685
|
+
});
|
|
1766
2686
|
}
|
|
1767
|
-
} else if (!filesChanged) {
|
|
2687
|
+
} else if (!rerunPlan.filesChanged) {
|
|
1768
2688
|
logger.log(color.cyan('Tests will be re-executed automatically\n'));
|
|
1769
2689
|
}
|
|
1770
2690
|
};
|
|
1771
2691
|
}
|
|
1772
2692
|
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
2693
|
+
const closeContainerRuntime = !isWatchMode
|
|
2694
|
+
? async () => {
|
|
2695
|
+
try {
|
|
2696
|
+
await containerPage.close();
|
|
2697
|
+
} catch {
|
|
2698
|
+
// ignore
|
|
2699
|
+
}
|
|
2700
|
+
try {
|
|
2701
|
+
await containerContext.close();
|
|
2702
|
+
} catch {
|
|
2703
|
+
// ignore
|
|
2704
|
+
}
|
|
2705
|
+
await destroyBrowserRuntime(runtime);
|
|
2706
|
+
}
|
|
2707
|
+
: undefined;
|
|
1786
2708
|
|
|
1787
2709
|
if (fatalError) {
|
|
1788
|
-
return failWithError(fatalError);
|
|
2710
|
+
return failWithError(fatalError, closeContainerRuntime);
|
|
1789
2711
|
}
|
|
1790
2712
|
|
|
1791
2713
|
const duration = {
|
|
@@ -1803,23 +2725,21 @@ export const runBrowserController = async (
|
|
|
1803
2725
|
ensureProcessExitCode(1);
|
|
1804
2726
|
}
|
|
1805
2727
|
|
|
1806
|
-
const result
|
|
2728
|
+
const result = {
|
|
1807
2729
|
results: reporterResults,
|
|
1808
2730
|
testResults: caseResults,
|
|
1809
2731
|
duration,
|
|
1810
2732
|
hasFailure: isFailure,
|
|
2733
|
+
getSourcemap: getBrowserSourcemap,
|
|
2734
|
+
resolveSourcemap: resolveBrowserSourcemap,
|
|
2735
|
+
close: skipOnTestRunEnd ? closeContainerRuntime : undefined,
|
|
1811
2736
|
};
|
|
1812
2737
|
|
|
1813
|
-
// Only call onTestRunEnd if not skipped (for unified reporter output)
|
|
1814
2738
|
if (!skipOnTestRunEnd) {
|
|
1815
|
-
|
|
1816
|
-
await
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
duration,
|
|
1820
|
-
snapshotSummary: context.snapshotManager.summary,
|
|
1821
|
-
getSourcemap: async () => null,
|
|
1822
|
-
});
|
|
2739
|
+
try {
|
|
2740
|
+
await notifyTestRunEnd({ duration });
|
|
2741
|
+
} finally {
|
|
2742
|
+
await closeContainerRuntime?.();
|
|
1823
2743
|
}
|
|
1824
2744
|
}
|
|
1825
2745
|
|
|
@@ -1887,6 +2807,7 @@ export const listBrowserTests = async (
|
|
|
1887
2807
|
manifestPath,
|
|
1888
2808
|
entries: projectEntries,
|
|
1889
2809
|
});
|
|
2810
|
+
const browserProjects = getBrowserProjects(context);
|
|
1890
2811
|
|
|
1891
2812
|
// Create a simplified browser runtime for collect mode
|
|
1892
2813
|
let runtime: BrowserRuntime;
|
|
@@ -1902,9 +2823,14 @@ export const listBrowserTests = async (
|
|
|
1902
2823
|
forceHeadless: true, // Always use headless for list command
|
|
1903
2824
|
});
|
|
1904
2825
|
} catch (error) {
|
|
2826
|
+
const providers = [
|
|
2827
|
+
...new Set(
|
|
2828
|
+
browserProjects.map((p) => p.normalizedConfig.browser.provider),
|
|
2829
|
+
),
|
|
2830
|
+
];
|
|
1905
2831
|
logger.error(
|
|
1906
2832
|
color.red(
|
|
1907
|
-
|
|
2833
|
+
`Failed to initialize browser provider runtime (${providers.join(', ')}).`,
|
|
1908
2834
|
),
|
|
1909
2835
|
error,
|
|
1910
2836
|
);
|
|
@@ -1915,7 +2841,6 @@ export const listBrowserTests = async (
|
|
|
1915
2841
|
|
|
1916
2842
|
// Get browser projects for runtime config
|
|
1917
2843
|
// Normalize projectRoot to posix format for cross-platform compatibility
|
|
1918
|
-
const browserProjects = getBrowserProjects(context);
|
|
1919
2844
|
const projectRuntimeConfigs: BrowserProjectRuntime[] = browserProjects.map(
|
|
1920
2845
|
(project: ProjectContext) => ({
|
|
1921
2846
|
name: project.name,
|
|
@@ -1961,7 +2886,7 @@ export const listBrowserTests = async (
|
|
|
1961
2886
|
|
|
1962
2887
|
// Expose dispatch function for browser client to send messages
|
|
1963
2888
|
await page.exposeFunction(
|
|
1964
|
-
|
|
2889
|
+
DISPATCH_MESSAGE_TYPE,
|
|
1965
2890
|
(message: { type: string; payload?: unknown }) => {
|
|
1966
2891
|
switch (message.type) {
|
|
1967
2892
|
case 'collect-result': {
|