@rstest/browser 0.10.5 → 0.11.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/dist/browser-container/container-static/js/{854.c59f71483d.js → 328.37af068e21.js} +2712 -2786
- package/dist/browser-container/container-static/js/328.37af068e21.js.LICENSE.txt +1 -0
- package/dist/browser-container/container-static/js/{index.fedc31dff3.js → index.c8fe4ff310.js} +58 -56
- package/dist/browser-container/index.html +1 -1
- package/dist/browser.js +1 -1
- package/dist/hostController.d.ts +18 -1
- package/dist/index.js +513 -378
- package/dist/protocol.d.ts +7 -1
- package/dist/providers/index.d.ts +3 -0
- package/package.json +7 -7
- package/src/client/entry.ts +25 -23
- package/src/client/formatConsole.ts +77 -0
- package/src/client/locator.ts +1 -5
- package/src/hostController.ts +842 -548
- package/src/protocol.ts +9 -5
- package/src/providers/index.ts +3 -0
- package/src/providers/playwright/runtime.ts +11 -6
- package/dist/browser-container/container-static/js/854.c59f71483d.js.LICENSE.txt +0 -1
- package/src/client/fakeTimersStub.ts +0 -91
- /package/dist/{626.js → 33.js} +0 -0
package/src/hostController.ts
CHANGED
|
@@ -101,15 +101,6 @@ type RsbuildInstance = rsbuild.RsbuildInstance;
|
|
|
101
101
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
102
102
|
const OPTIONS_PLACEHOLDER = '__RSTEST_OPTIONS_PLACEHOLDER__';
|
|
103
103
|
|
|
104
|
-
/**
|
|
105
|
-
* Extra time added on top of a file's `testTimeout` before the host gives up
|
|
106
|
-
* waiting for that file's RPC to settle. Covers fixed per-file overhead (page
|
|
107
|
-
* navigation, runner boot) that is not part of the user's test budget. The
|
|
108
|
-
* headless and headed scheduling paths both apply this same buffer, so it lives
|
|
109
|
-
* here as one constant to keep their per-file timeout semantics identical.
|
|
110
|
-
*/
|
|
111
|
-
const PER_FILE_TIMEOUT_BUFFER_MS = 30_000;
|
|
112
|
-
|
|
113
104
|
/**
|
|
114
105
|
* Monotonic counter for synthetic per-file Perfetto `pid` values in `--trace`
|
|
115
106
|
* mode. Browser host runs every test file inside the same Node process, so
|
|
@@ -138,10 +129,6 @@ const serializeForInlineScript = (value: unknown): string => {
|
|
|
138
129
|
// Type Definitions
|
|
139
130
|
// ============================================================================
|
|
140
131
|
|
|
141
|
-
type VirtualModulesPluginInstance = InstanceType<
|
|
142
|
-
(typeof rspack.experiments)['VirtualModulesPlugin']
|
|
143
|
-
>;
|
|
144
|
-
|
|
145
132
|
type BrowserProjectEntries = {
|
|
146
133
|
project: ProjectContext;
|
|
147
134
|
setupFiles: string[];
|
|
@@ -415,16 +402,30 @@ class ContainerRpcManager {
|
|
|
415
402
|
// Browser Runtime - Core runtime state
|
|
416
403
|
// ============================================================================
|
|
417
404
|
|
|
418
|
-
|
|
405
|
+
// One isolated rsbuild instance + dev server per browser project. Physical
|
|
406
|
+
// isolation (separate compiler, dev server, port, virtual manifest, runner)
|
|
407
|
+
// prevents one project's build/resolve config from leaking into another's
|
|
408
|
+
// compilation and avoids shared-dev-server races (chunk filenames,
|
|
409
|
+
// lazyCompilation backend) between projects.
|
|
410
|
+
type BrowserProjectServer = {
|
|
411
|
+
projectName: string;
|
|
412
|
+
environmentName: string;
|
|
419
413
|
rsbuildInstance: RsbuildInstance;
|
|
420
414
|
devServer: RsbuildDevServer;
|
|
415
|
+
port: number;
|
|
416
|
+
manifestPath: string;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
type BrowserRuntime = {
|
|
420
|
+
// Per-project servers, keyed by project name.
|
|
421
|
+
projectServers: Map<string, BrowserProjectServer>;
|
|
422
|
+
// The server that hosts the container UI HTML (headed mode). The WebSocket
|
|
423
|
+
// server below is shared and reachable from any origin.
|
|
424
|
+
containerServer: BrowserProjectServer;
|
|
421
425
|
browser: BrowserProviderBrowser;
|
|
422
426
|
browserLaunchOptions: BrowserLaunchOptions;
|
|
423
|
-
port: number;
|
|
424
427
|
wsPort: number;
|
|
425
|
-
manifestPath: string;
|
|
426
428
|
tempDir: string;
|
|
427
|
-
manifestPlugin: VirtualModulesPluginInstance;
|
|
428
429
|
containerPage?: BrowserProviderPage;
|
|
429
430
|
containerContext?: BrowserProviderContext;
|
|
430
431
|
setContainerOptions: (options: BrowserHostConfig) => void;
|
|
@@ -600,8 +601,24 @@ export const createBrowserLazyCompilationConfig = (
|
|
|
600
601
|
};
|
|
601
602
|
};
|
|
602
603
|
|
|
604
|
+
/**
|
|
605
|
+
* HMR — and the lazyCompilation transport it carries — is wired only for headed
|
|
606
|
+
* watch, the sole path that reuses a persistent page and applies module updates
|
|
607
|
+
* in place. Headless always loads each test file in a fresh page (pulling the
|
|
608
|
+
* latest incrementally-built chunks over HTTP), and one-shot runs never rerun,
|
|
609
|
+
* so pushing HMR updates there is dead weight that only races factory
|
|
610
|
+
* registration for chunk-split node_modules (rspack#11922) and lets
|
|
611
|
+
* lazyCompilation's accept-chain walk abort the next spec when no boundary
|
|
612
|
+
* exists (#1472). Disabling HMR does not make watch rebuilds any less
|
|
613
|
+
* incremental — HMR is only the client push transport.
|
|
614
|
+
*/
|
|
615
|
+
export const shouldEnableBrowserHmr = (
|
|
616
|
+
isWatchMode: boolean,
|
|
617
|
+
isHeadless: boolean,
|
|
618
|
+
): boolean => isWatchMode && !isHeadless;
|
|
619
|
+
|
|
603
620
|
export const createBrowserRsbuildDevConfig = (
|
|
604
|
-
|
|
621
|
+
enableHmr: boolean,
|
|
605
622
|
): {
|
|
606
623
|
writeToDisk: boolean;
|
|
607
624
|
hmr: boolean;
|
|
@@ -611,9 +628,10 @@ export const createBrowserRsbuildDevConfig = (
|
|
|
611
628
|
} => {
|
|
612
629
|
return {
|
|
613
630
|
writeToDisk: isDebug(),
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
|
|
631
|
+
// `enableHmr` is gated to headed watch by `shouldEnableBrowserHmr` — the one
|
|
632
|
+
// path that reuses a page. See that helper for why fresh-page runs (headless,
|
|
633
|
+
// or any one-shot) must not receive HMR pushes.
|
|
634
|
+
hmr: enableHmr,
|
|
617
635
|
client: {
|
|
618
636
|
logLevel: 'error' as const,
|
|
619
637
|
},
|
|
@@ -629,6 +647,7 @@ const globToRegexp = (glob: string): RegExp => {
|
|
|
629
647
|
fastpaths: false,
|
|
630
648
|
noglobstar: false,
|
|
631
649
|
bash: false,
|
|
650
|
+
dot: true,
|
|
632
651
|
});
|
|
633
652
|
|
|
634
653
|
if (!regex) {
|
|
@@ -676,44 +695,189 @@ const globPatternsToRegExp = (patterns: string[]): RegExp => {
|
|
|
676
695
|
return new RegExp(`(?:${regexParts.join('|')})$`);
|
|
677
696
|
};
|
|
678
697
|
|
|
698
|
+
const REGEXP_SPECIAL_CHARACTERS = /[|\\{}()[\]^$+*?.]/g;
|
|
699
|
+
const PATH_SEPARATOR_SOURCE = String.raw`[\\/]`;
|
|
700
|
+
const WINDOWS_ABSOLUTE_PATH_SOURCE = String.raw`[A-Za-z]:[\\/]`;
|
|
701
|
+
|
|
702
|
+
const escapeRegExp = (value: string): string =>
|
|
703
|
+
value.replace(REGEXP_SPECIAL_CHARACTERS, '\\$&');
|
|
704
|
+
|
|
705
|
+
const normalizePathForRegExp = (value: string): string =>
|
|
706
|
+
normalize(value).replaceAll('\\', '/');
|
|
707
|
+
|
|
708
|
+
const normalizeExcludePatternForRegExp = (value: string): string =>
|
|
709
|
+
value.startsWith('./')
|
|
710
|
+
? `./${normalizePathForRegExp(value.substring(2))}`
|
|
711
|
+
: normalizePathForRegExp(value);
|
|
712
|
+
|
|
713
|
+
const isAbsolutePatternForRegExp = (value: string): boolean =>
|
|
714
|
+
value.startsWith('/') || /^[A-Za-z]:\//.test(value);
|
|
715
|
+
|
|
716
|
+
const isEscapedRegExpCharacter = (source: string, index: number): boolean => {
|
|
717
|
+
let backslashCount = 0;
|
|
718
|
+
for (
|
|
719
|
+
let current = index - 1;
|
|
720
|
+
current >= 0 && source[current] === '\\';
|
|
721
|
+
current--
|
|
722
|
+
) {
|
|
723
|
+
backslashCount++;
|
|
724
|
+
}
|
|
725
|
+
return backslashCount % 2 === 1;
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
const replacePathSeparatorsInRegExpSource = (source: string): string => {
|
|
729
|
+
let result = '';
|
|
730
|
+
let inCharacterClass = false;
|
|
731
|
+
|
|
732
|
+
for (let index = 0; index < source.length; index++) {
|
|
733
|
+
const character = source[index];
|
|
734
|
+
const isEscaped = isEscapedRegExpCharacter(source, index);
|
|
735
|
+
|
|
736
|
+
if (character === '[' && !isEscaped) {
|
|
737
|
+
inCharacterClass = true;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (!inCharacterClass && character === '\\' && source[index + 1] === '/') {
|
|
741
|
+
result += PATH_SEPARATOR_SOURCE;
|
|
742
|
+
index++;
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
result += character;
|
|
747
|
+
|
|
748
|
+
if (character === ']' && !isEscaped) {
|
|
749
|
+
inCharacterClass = false;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return result;
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
type BrowserContextExcludeSource = {
|
|
757
|
+
relative: string;
|
|
758
|
+
absolute: string;
|
|
759
|
+
isAbsolute: boolean;
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
const createRelativeContextExcludeSource = (
|
|
763
|
+
source: string,
|
|
764
|
+
normalizedPattern: string,
|
|
765
|
+
): string => {
|
|
766
|
+
if (normalizedPattern.startsWith('./')) {
|
|
767
|
+
return source;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return normalizedPattern.startsWith('**/')
|
|
771
|
+
? `(?:(?:${source})|\\.(?:${source}))`
|
|
772
|
+
: `(?:(?:${source})|\\.${PATH_SEPARATOR_SOURCE}(?:${source}))`;
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
const createProjectAbsoluteExcludeSource = (
|
|
776
|
+
source: string,
|
|
777
|
+
normalizedPattern: string,
|
|
778
|
+
): string =>
|
|
779
|
+
normalizedPattern.startsWith('./') || normalizedPattern.startsWith('**/')
|
|
780
|
+
? source
|
|
781
|
+
: `${PATH_SEPARATOR_SOURCE}(?:${source})`;
|
|
782
|
+
|
|
679
783
|
/**
|
|
680
784
|
* Convert exclude patterns to a RegExp for import.meta.webpackContext's exclude option
|
|
681
785
|
* This is used at compile time to filter out files during bundling
|
|
682
786
|
*
|
|
683
787
|
* Example:
|
|
684
788
|
* Input: ['**\/node_modules\/**', '**\/dist\/**']
|
|
685
|
-
* Output:
|
|
789
|
+
* Output: a regexp matching node_modules or dist path segments.
|
|
686
790
|
*/
|
|
687
|
-
const
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
);
|
|
697
|
-
if (match) {
|
|
698
|
-
// Handle {a,b,c} patterns
|
|
699
|
-
const parts = match[1]!.split(',');
|
|
700
|
-
for (const part of parts) {
|
|
701
|
-
// Clean up the part (remove leading dots for hidden dirs)
|
|
702
|
-
const cleaned = part.replace(/^\./, '');
|
|
703
|
-
if (cleaned && !keywords.includes(cleaned)) {
|
|
704
|
-
keywords.push(cleaned);
|
|
705
|
-
}
|
|
706
|
-
}
|
|
791
|
+
const excludePatternsToRegExpSources = (
|
|
792
|
+
patterns: string[],
|
|
793
|
+
): BrowserContextExcludeSource[] | null => {
|
|
794
|
+
const sources = patterns.map((pattern) => {
|
|
795
|
+
const normalizedPattern = normalizeExcludePatternForRegExp(pattern);
|
|
796
|
+
const regex = globToRegexp(normalizedPattern);
|
|
797
|
+
let source = regex.source;
|
|
798
|
+
if (source.startsWith('^')) {
|
|
799
|
+
source = source.substring(1);
|
|
707
800
|
}
|
|
801
|
+
if (source.endsWith('$')) {
|
|
802
|
+
source = source.substring(0, source.length - 1);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
source = replacePathSeparatorsInRegExpSource(source);
|
|
806
|
+
const isAbsolute = isAbsolutePatternForRegExp(normalizedPattern);
|
|
807
|
+
const absolute = normalizedPattern.startsWith('./')
|
|
808
|
+
? source.substring(2)
|
|
809
|
+
: source;
|
|
810
|
+
|
|
811
|
+
return {
|
|
812
|
+
relative: isAbsolute
|
|
813
|
+
? source
|
|
814
|
+
: createRelativeContextExcludeSource(source, normalizedPattern),
|
|
815
|
+
absolute: isAbsolute
|
|
816
|
+
? absolute
|
|
817
|
+
: createProjectAbsoluteExcludeSource(absolute, normalizedPattern),
|
|
818
|
+
isAbsolute,
|
|
819
|
+
};
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
if (sources.length === 0) {
|
|
823
|
+
return null;
|
|
708
824
|
}
|
|
709
825
|
|
|
710
|
-
|
|
826
|
+
return sources;
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
export const createBrowserContextExcludeRegExp = (
|
|
830
|
+
patterns: string[],
|
|
831
|
+
projectRoot: string,
|
|
832
|
+
): RegExp | null => {
|
|
833
|
+
const excludeSources = excludePatternsToRegExpSources(patterns);
|
|
834
|
+
if (!excludeSources) {
|
|
711
835
|
return null;
|
|
712
836
|
}
|
|
713
837
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
838
|
+
const normalizedProjectRoot = normalizePathForRegExp(projectRoot).replace(
|
|
839
|
+
/[\\/]$/,
|
|
840
|
+
'',
|
|
841
|
+
);
|
|
842
|
+
const projectRootSource = normalizedProjectRoot
|
|
843
|
+
.split('/')
|
|
844
|
+
.map(escapeRegExp)
|
|
845
|
+
.join(PATH_SEPARATOR_SOURCE);
|
|
846
|
+
const relativeExcludeSources = excludeSources.filter(
|
|
847
|
+
(source) => !source.isAbsolute,
|
|
848
|
+
);
|
|
849
|
+
const absoluteExcludeSources = excludeSources.filter(
|
|
850
|
+
(source) => source.isAbsolute,
|
|
851
|
+
);
|
|
852
|
+
const sourceBranches: string[] = [];
|
|
853
|
+
|
|
854
|
+
if (relativeExcludeSources.length > 0) {
|
|
855
|
+
const relativePatternSource = `(?:${relativeExcludeSources
|
|
856
|
+
.map((source) => source.relative)
|
|
857
|
+
.join('|')})`;
|
|
858
|
+
const absolutePatternSource = `(?:${relativeExcludeSources
|
|
859
|
+
.map((source) => source.absolute)
|
|
860
|
+
.join('|')})`;
|
|
861
|
+
const relativeSource = `(?:${relativePatternSource})`;
|
|
862
|
+
const absoluteSource = normalizedProjectRoot
|
|
863
|
+
? `${projectRootSource}(?=${PATH_SEPARATOR_SOURCE})(?:${absolutePatternSource})`
|
|
864
|
+
: `(?:${absolutePatternSource})`;
|
|
865
|
+
|
|
866
|
+
sourceBranches.push(
|
|
867
|
+
`(?!${WINDOWS_ABSOLUTE_PATH_SOURCE}|${PATH_SEPARATOR_SOURCE})${relativeSource}`,
|
|
868
|
+
absoluteSource,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (absoluteExcludeSources.length > 0) {
|
|
873
|
+
sourceBranches.push(
|
|
874
|
+
`(?:${absoluteExcludeSources
|
|
875
|
+
.map((source) => source.relative)
|
|
876
|
+
.join('|')})`,
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return new RegExp(`^(?:${sourceBranches.join('|')})$`);
|
|
717
881
|
};
|
|
718
882
|
|
|
719
883
|
type StatsModule = {
|
|
@@ -909,17 +1073,18 @@ const ensureConsistentBrowserLaunchOptions = (
|
|
|
909
1073
|
|
|
910
1074
|
for (const project of projects.slice(1)) {
|
|
911
1075
|
const options = getBrowserLaunchOptions(project);
|
|
1076
|
+
// Each browser project now runs on its own rsbuild dev server, so ports may
|
|
1077
|
+
// differ per project. Only the shared single Playwright browser forces
|
|
1078
|
+
// provider/browser/headless/providerOptions to match across projects.
|
|
912
1079
|
if (
|
|
913
1080
|
options.provider !== firstOptions.provider ||
|
|
914
1081
|
options.browser !== firstOptions.browser ||
|
|
915
1082
|
options.headless !== firstOptions.headless ||
|
|
916
|
-
options.port !== firstOptions.port ||
|
|
917
|
-
options.strictPort !== firstOptions.strictPort ||
|
|
918
1083
|
!isDeepStrictEqual(options.providerOptions, firstOptions.providerOptions)
|
|
919
1084
|
) {
|
|
920
1085
|
throw new Error(
|
|
921
1086
|
`Browser launch config mismatch between projects "${firstProject.name}" and "${project.name}". ` +
|
|
922
|
-
'All browser-enabled projects in one run must share provider/browser/headless/
|
|
1087
|
+
'All browser-enabled projects in one run must share provider/browser/headless/providerOptions.',
|
|
923
1088
|
);
|
|
924
1089
|
}
|
|
925
1090
|
}
|
|
@@ -1027,12 +1192,38 @@ const toSafeVarName = (name: string): string => {
|
|
|
1027
1192
|
return name.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
1028
1193
|
};
|
|
1029
1194
|
|
|
1195
|
+
// Host-side mirror of the browser runtime's `toContextKey` (client/entry.ts):
|
|
1196
|
+
// `./<path-relative-to-project-root>` with forward slashes. The runtime derives
|
|
1197
|
+
// the same key from the target test file, so the non-watch import map below must
|
|
1198
|
+
// key by the identical form for `loadTest(key)` to resolve.
|
|
1199
|
+
export const toContextKey = (
|
|
1200
|
+
filePath: string,
|
|
1201
|
+
projectRootPosix: string,
|
|
1202
|
+
): string => {
|
|
1203
|
+
const posixPath = normalize(filePath);
|
|
1204
|
+
// Only strip the root at a path boundary: a bare `startsWith` would mangle a
|
|
1205
|
+
// sibling like `/repo/pkg-extra/a.test.ts` under root `/repo/pkg`.
|
|
1206
|
+
const withinRoot =
|
|
1207
|
+
posixPath === projectRootPosix ||
|
|
1208
|
+
posixPath.startsWith(`${projectRootPosix}/`);
|
|
1209
|
+
if (!withinRoot) {
|
|
1210
|
+
// Test file outside the project root: use the absolute path as the key so
|
|
1211
|
+
// the runtime `toAbsolutePath` can round-trip it. A `./`-prefixed relative
|
|
1212
|
+
// key would be re-rooted under projectRoot and point at a nonexistent file.
|
|
1213
|
+
return posixPath;
|
|
1214
|
+
}
|
|
1215
|
+
const rel = posixPath.slice(projectRootPosix.length);
|
|
1216
|
+
return rel.startsWith('/') ? `.${rel}` : `./${rel}`;
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1030
1219
|
const generateManifestModule = ({
|
|
1031
1220
|
manifestPath,
|
|
1032
1221
|
entries,
|
|
1222
|
+
isWatchMode,
|
|
1033
1223
|
}: {
|
|
1034
1224
|
manifestPath: string;
|
|
1035
1225
|
entries: BrowserProjectEntries[];
|
|
1226
|
+
isWatchMode: boolean;
|
|
1036
1227
|
}): string => {
|
|
1037
1228
|
const manifestDirPosix = normalize(dirname(manifestPath));
|
|
1038
1229
|
|
|
@@ -1078,27 +1269,56 @@ const generateManifestModule = ({
|
|
|
1078
1269
|
lines.push('};');
|
|
1079
1270
|
lines.push('');
|
|
1080
1271
|
|
|
1081
|
-
// 3. Test context for each project
|
|
1272
|
+
// 3. Test context for each project. Both branches expose the same shape as a
|
|
1273
|
+
// webpackContext (callable by key, plus `keys()`), consumed in section 4.
|
|
1082
1274
|
lines.push('// Test context for each project');
|
|
1083
|
-
for (const { project } of entries) {
|
|
1275
|
+
for (const { project, testFiles } of entries) {
|
|
1084
1276
|
const varName = `context_${toSafeVarName(project.environmentName)}`;
|
|
1085
1277
|
const projectRootPosix = normalize(project.rootPath);
|
|
1086
|
-
const includeRegExp = globPatternsToRegExp(
|
|
1087
|
-
project.normalizedConfig.include,
|
|
1088
|
-
);
|
|
1089
|
-
const excludePatterns = project.normalizedConfig.exclude.patterns;
|
|
1090
|
-
const excludeRegExp = excludePatternsToRegExp(excludePatterns);
|
|
1091
1278
|
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1279
|
+
if (isWatchMode) {
|
|
1280
|
+
// Watch mode keeps the include-glob context so newly added files are
|
|
1281
|
+
// picked up on rebuild via `keys()` without regenerating the manifest.
|
|
1282
|
+
// `mode: 'lazy'` is plain code-splitting (async chunks over HTTP), so it
|
|
1283
|
+
// works with or without lazyCompilation; headed watch additionally layers
|
|
1284
|
+
// lazyCompilation on top to keep the initial build cheap.
|
|
1285
|
+
const includeRegExp = globPatternsToRegExp(
|
|
1286
|
+
project.normalizedConfig.include,
|
|
1287
|
+
);
|
|
1288
|
+
const excludeRegExp = createBrowserContextExcludeRegExp(
|
|
1289
|
+
project.normalizedConfig.exclude.patterns,
|
|
1290
|
+
projectRootPosix,
|
|
1291
|
+
);
|
|
1292
|
+
lines.push(
|
|
1293
|
+
`const ${varName} = import.meta.webpackContext(${JSON.stringify(projectRootPosix)}, {`,
|
|
1294
|
+
);
|
|
1295
|
+
lines.push(' recursive: true,');
|
|
1296
|
+
lines.push(` regExp: ${includeRegExp.toString()},`);
|
|
1297
|
+
if (excludeRegExp) {
|
|
1298
|
+
lines.push(` exclude: ${excludeRegExp.toString()},`);
|
|
1299
|
+
}
|
|
1300
|
+
lines.push(" mode: 'lazy',");
|
|
1301
|
+
lines.push('});');
|
|
1302
|
+
} else {
|
|
1303
|
+
// One-shot runs: the file set is fixed and already filtered, so emit an
|
|
1304
|
+
// explicit lazy-import map (one chunk per literal `import()`, like the
|
|
1305
|
+
// setup loaders above). The eager, non-lazyCompilation build then compiles
|
|
1306
|
+
// only the run set instead of every included test file.
|
|
1307
|
+
lines.push(`const ${varName}_modules = {`);
|
|
1308
|
+
for (const filePath of testFiles) {
|
|
1309
|
+
const key = toContextKey(filePath, projectRootPosix);
|
|
1310
|
+
const importPath = toRelativeImport(filePath);
|
|
1311
|
+
lines.push(
|
|
1312
|
+
` ${JSON.stringify(key)}: () => import(${JSON.stringify(importPath)}),`,
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
lines.push('};');
|
|
1316
|
+
lines.push(
|
|
1317
|
+
`const ${varName} = Object.assign((key) => ${varName}_modules[key](), {`,
|
|
1318
|
+
);
|
|
1319
|
+
lines.push(` keys: () => Object.keys(${varName}_modules),`);
|
|
1320
|
+
lines.push('});');
|
|
1099
1321
|
}
|
|
1100
|
-
lines.push(" mode: 'lazy',");
|
|
1101
|
-
lines.push('});');
|
|
1102
1322
|
lines.push('');
|
|
1103
1323
|
}
|
|
1104
1324
|
|
|
@@ -1155,6 +1375,27 @@ const VIRTUAL_MANIFEST_FILENAME = 'virtual-manifest.ts';
|
|
|
1155
1375
|
// Browser Runtime Lifecycle
|
|
1156
1376
|
// ============================================================================
|
|
1157
1377
|
|
|
1378
|
+
const closeAllProjectServers = (
|
|
1379
|
+
servers: Iterable<BrowserProjectServer>,
|
|
1380
|
+
): Promise<unknown> =>
|
|
1381
|
+
Promise.allSettled([...servers].map((server) => server.devServer.close()));
|
|
1382
|
+
|
|
1383
|
+
// Copy a proxied fetch Response's status + headers onto the Node response,
|
|
1384
|
+
// dropping content-length (the body is re-sent, so the original length may not
|
|
1385
|
+
// match).
|
|
1386
|
+
const copyProxyResponseHeaders = (
|
|
1387
|
+
response: Response,
|
|
1388
|
+
res: ServerResponse,
|
|
1389
|
+
): void => {
|
|
1390
|
+
res.statusCode = response.status;
|
|
1391
|
+
response.headers.forEach((value, key) => {
|
|
1392
|
+
if (key.toLowerCase() === 'content-length') {
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
res.setHeader(key, value);
|
|
1396
|
+
});
|
|
1397
|
+
};
|
|
1398
|
+
|
|
1158
1399
|
const destroyBrowserRuntime = async (
|
|
1159
1400
|
runtime: BrowserRuntime,
|
|
1160
1401
|
): Promise<void> => {
|
|
@@ -1163,11 +1404,7 @@ const destroyBrowserRuntime = async (
|
|
|
1163
1404
|
} catch {
|
|
1164
1405
|
// ignore
|
|
1165
1406
|
}
|
|
1166
|
-
|
|
1167
|
-
await runtime.devServer?.close?.();
|
|
1168
|
-
} catch {
|
|
1169
|
-
// ignore
|
|
1170
|
-
}
|
|
1407
|
+
await closeAllProjectServers(runtime.projectServers.values());
|
|
1171
1408
|
try {
|
|
1172
1409
|
runtime.wss?.close();
|
|
1173
1410
|
} catch {
|
|
@@ -1218,8 +1455,7 @@ const registerWatchCleanup = (): void => {
|
|
|
1218
1455
|
|
|
1219
1456
|
const createBrowserRuntime = async ({
|
|
1220
1457
|
context,
|
|
1221
|
-
|
|
1222
|
-
manifestSource,
|
|
1458
|
+
projectEntries,
|
|
1223
1459
|
tempDir,
|
|
1224
1460
|
isWatchMode,
|
|
1225
1461
|
onTriggerRerun,
|
|
@@ -1228,8 +1464,7 @@ const createBrowserRuntime = async ({
|
|
|
1228
1464
|
forceHeadless,
|
|
1229
1465
|
}: {
|
|
1230
1466
|
context: RstestContext;
|
|
1231
|
-
|
|
1232
|
-
manifestSource: string;
|
|
1467
|
+
projectEntries: BrowserProjectEntries[];
|
|
1233
1468
|
tempDir: string;
|
|
1234
1469
|
isWatchMode: boolean;
|
|
1235
1470
|
onTriggerRerun?: () => Promise<void>;
|
|
@@ -1238,10 +1473,7 @@ const createBrowserRuntime = async ({
|
|
|
1238
1473
|
/** Force headless mode regardless of user config (used for list command) */
|
|
1239
1474
|
forceHeadless?: boolean;
|
|
1240
1475
|
}): Promise<BrowserRuntime> => {
|
|
1241
|
-
|
|
1242
|
-
[manifestPath]: manifestSource,
|
|
1243
|
-
});
|
|
1244
|
-
|
|
1476
|
+
// ---- Shared singletons (created once, wired into every project server) ----
|
|
1245
1477
|
const containerHtmlTemplate = containerDistPath
|
|
1246
1478
|
? await fs.readFile(join(containerDistPath, 'index.html'), 'utf-8')
|
|
1247
1479
|
: null;
|
|
@@ -1262,12 +1494,6 @@ const createBrowserRuntime = async ({
|
|
|
1262
1494
|
};
|
|
1263
1495
|
|
|
1264
1496
|
const browserProjects = getBrowserProjects(context);
|
|
1265
|
-
const projectByEnvironmentName = new Map(
|
|
1266
|
-
browserProjects.map((project) => [project.environmentName, project]),
|
|
1267
|
-
);
|
|
1268
|
-
const userPlugins = browserProjects.flatMap(
|
|
1269
|
-
(project) => project.normalizedConfig.plugins || [],
|
|
1270
|
-
);
|
|
1271
1497
|
const browserLaunchOptions =
|
|
1272
1498
|
ensureConsistentBrowserLaunchOptions(browserProjects);
|
|
1273
1499
|
|
|
@@ -1276,8 +1502,9 @@ const createBrowserRuntime = async ({
|
|
|
1276
1502
|
import.meta.resolve('@rstest/core/internal/browser-runtime'),
|
|
1277
1503
|
);
|
|
1278
1504
|
|
|
1279
|
-
|
|
1280
|
-
|
|
1505
|
+
// Shared by every project — only the per-project `@rstest/browser-manifest`
|
|
1506
|
+
// alias varies (one virtual manifest per server).
|
|
1507
|
+
const staticRstestAliases = {
|
|
1281
1508
|
// User test code: import { describe, it } from '@rstest/core'
|
|
1282
1509
|
'@rstest/core': resolveBrowserFile('client/public.ts'),
|
|
1283
1510
|
// User test code: import { page } from '@rstest/browser'
|
|
@@ -1285,228 +1512,17 @@ const createBrowserRuntime = async ({
|
|
|
1285
1512
|
// Browser runtime APIs for entry.ts and public.ts
|
|
1286
1513
|
// Uses dist file with extractSourceMap to preserve sourcemap chain for inline snapshots
|
|
1287
1514
|
'@rstest/core/internal/browser-runtime': browserRuntimePath,
|
|
1288
|
-
'@sinonjs/fake-timers': resolveBrowserFile('client/fakeTimersStub.ts'),
|
|
1289
1515
|
};
|
|
1290
1516
|
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
printUrls: false,
|
|
1299
|
-
port: browserLaunchOptions.port ?? 4000,
|
|
1300
|
-
strictPort: browserLaunchOptions.strictPort,
|
|
1301
|
-
},
|
|
1302
|
-
dev: createBrowserRsbuildDevConfig(isWatchMode),
|
|
1303
|
-
environments: {
|
|
1304
|
-
...Object.fromEntries(
|
|
1305
|
-
browserProjects.map((project) => [project.environmentName, {}]),
|
|
1306
|
-
),
|
|
1307
|
-
},
|
|
1308
|
-
},
|
|
1309
|
-
});
|
|
1310
|
-
|
|
1311
|
-
// Add plugin to merge user Rsbuild config with rstest required config
|
|
1312
|
-
rsbuildInstance.addPlugins([
|
|
1313
|
-
{
|
|
1314
|
-
name: 'rstest:browser-user-config',
|
|
1315
|
-
setup(api) {
|
|
1316
|
-
// Internal extension entry: register host dispatch handlers without
|
|
1317
|
-
// coupling scheduling to individual capability implementations.
|
|
1318
|
-
(api as { expose?: (name: string, value: unknown) => void }).expose?.(
|
|
1319
|
-
'rstest:browser',
|
|
1320
|
-
{
|
|
1321
|
-
registerDispatchHandler: (
|
|
1322
|
-
namespace: string,
|
|
1323
|
-
handler: BrowserDispatchHandler,
|
|
1324
|
-
) => {
|
|
1325
|
-
dispatchHandlers.set(namespace, handler);
|
|
1326
|
-
},
|
|
1327
|
-
},
|
|
1328
|
-
);
|
|
1329
|
-
|
|
1330
|
-
api.modifyEnvironmentConfig({
|
|
1331
|
-
handler: (config, { mergeEnvironmentConfig, name }) => {
|
|
1332
|
-
const project = projectByEnvironmentName.get(name);
|
|
1333
|
-
if (!project) {
|
|
1334
|
-
return config;
|
|
1335
|
-
}
|
|
1517
|
+
// rspack `define` replaces `process.env` / `import.meta.env` with this literal
|
|
1518
|
+
// expression. JSON.stringify reproduces the exact double-quoted `"rstest.env"`
|
|
1519
|
+
// text, so the owned key can never drift from the runtime
|
|
1520
|
+
// `Symbol.for(RSTEST_ENV_SYMBOL_KEY)` sites.
|
|
1521
|
+
const rstestEnvDefine = `globalThis[Symbol.for(${JSON.stringify(
|
|
1522
|
+
RSTEST_ENV_SYMBOL_KEY,
|
|
1523
|
+
)})]`;
|
|
1336
1524
|
|
|
1337
|
-
|
|
1338
|
-
const buildCache = resolveProjectBuildCache({
|
|
1339
|
-
context,
|
|
1340
|
-
project,
|
|
1341
|
-
});
|
|
1342
|
-
const setupFiles = Object.values(
|
|
1343
|
-
getSetupFiles(
|
|
1344
|
-
project.normalizedConfig.setupFiles,
|
|
1345
|
-
project.rootPath,
|
|
1346
|
-
),
|
|
1347
|
-
);
|
|
1348
|
-
// rspack `define` replaces `process.env` / `import.meta.env` with
|
|
1349
|
-
// this literal expression. JSON.stringify reproduces the exact
|
|
1350
|
-
// double-quoted `"rstest.env"` text, so the owned key can never
|
|
1351
|
-
// drift from the runtime `Symbol.for(RSTEST_ENV_SYMBOL_KEY)` sites.
|
|
1352
|
-
const rstestEnvDefine = `globalThis[Symbol.for(${JSON.stringify(
|
|
1353
|
-
RSTEST_ENV_SYMBOL_KEY,
|
|
1354
|
-
)})]`;
|
|
1355
|
-
// Merge order: current config -> userConfig -> rstest required config (highest priority)
|
|
1356
|
-
const merged = mergeEnvironmentConfig(
|
|
1357
|
-
config,
|
|
1358
|
-
{
|
|
1359
|
-
...userRsbuildConfig,
|
|
1360
|
-
performance: buildCache
|
|
1361
|
-
? {
|
|
1362
|
-
...userRsbuildConfig.performance,
|
|
1363
|
-
buildCache,
|
|
1364
|
-
}
|
|
1365
|
-
: userRsbuildConfig.performance,
|
|
1366
|
-
},
|
|
1367
|
-
{
|
|
1368
|
-
resolve: {
|
|
1369
|
-
alias: rstestInternalAliases,
|
|
1370
|
-
},
|
|
1371
|
-
source: {
|
|
1372
|
-
define: {
|
|
1373
|
-
'process.env': rstestEnvDefine,
|
|
1374
|
-
'import.meta.env': rstestEnvDefine,
|
|
1375
|
-
},
|
|
1376
|
-
},
|
|
1377
|
-
output: {
|
|
1378
|
-
target: 'web',
|
|
1379
|
-
// Enable source map for inline snapshot support
|
|
1380
|
-
sourceMap: {
|
|
1381
|
-
js: 'source-map',
|
|
1382
|
-
},
|
|
1383
|
-
},
|
|
1384
|
-
tools: {
|
|
1385
|
-
rspack: (rspackConfig) => {
|
|
1386
|
-
rspackConfig.mode = 'development';
|
|
1387
|
-
rspackConfig.lazyCompilation =
|
|
1388
|
-
createBrowserLazyCompilationConfig(setupFiles);
|
|
1389
|
-
rspackConfig.plugins = rspackConfig.plugins || [];
|
|
1390
|
-
rspackConfig.plugins.push(virtualManifestPlugin);
|
|
1391
|
-
|
|
1392
|
-
applyDefaultWatchOptions(rspackConfig, isWatchMode);
|
|
1393
|
-
|
|
1394
|
-
// Extract and merge sourcemaps from pre-built @rstest/core files
|
|
1395
|
-
// This preserves the sourcemap chain for inline snapshot support
|
|
1396
|
-
// See: https://rspack.dev/config/module-rules#rulesextractsourcemap
|
|
1397
|
-
const browserRuntimeDir = dirname(browserRuntimePath);
|
|
1398
|
-
rspackConfig.module = rspackConfig.module || {};
|
|
1399
|
-
rspackConfig.module.rules = rspackConfig.module.rules || [];
|
|
1400
|
-
rspackConfig.module.rules.unshift({
|
|
1401
|
-
test: /\.js$/,
|
|
1402
|
-
include: browserRuntimeDir,
|
|
1403
|
-
extractSourceMap: true,
|
|
1404
|
-
});
|
|
1405
|
-
|
|
1406
|
-
if (isDebug()) {
|
|
1407
|
-
logger.log(
|
|
1408
|
-
`[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`,
|
|
1409
|
-
);
|
|
1410
|
-
}
|
|
1411
|
-
},
|
|
1412
|
-
},
|
|
1413
|
-
},
|
|
1414
|
-
);
|
|
1415
|
-
|
|
1416
|
-
// Completely overwrite entry to prevent Rsbuild default entry detection from taking effect.
|
|
1417
|
-
// In browser mode, entry is fully controlled by rstest (not user's src/index.ts).
|
|
1418
|
-
// This must be done after mergeEnvironmentConfig to ensure highest priority.
|
|
1419
|
-
merged.source = merged.source || {};
|
|
1420
|
-
merged.source.entry = {
|
|
1421
|
-
runner: resolveBrowserFile('client/entry.ts'),
|
|
1422
|
-
};
|
|
1423
|
-
|
|
1424
|
-
return merged;
|
|
1425
|
-
},
|
|
1426
|
-
// Execute after all other plugins to ensure rstest's entry config has the highest priority
|
|
1427
|
-
order: 'post',
|
|
1428
|
-
});
|
|
1429
|
-
},
|
|
1430
|
-
},
|
|
1431
|
-
]);
|
|
1432
|
-
|
|
1433
|
-
// Register watch plugin if in watch mode
|
|
1434
|
-
if (isWatchMode && onTriggerRerun) {
|
|
1435
|
-
rsbuildInstance.addPlugins([
|
|
1436
|
-
{
|
|
1437
|
-
name: 'rstest:browser-watch',
|
|
1438
|
-
setup(api) {
|
|
1439
|
-
api.onBeforeDevCompile(() => {
|
|
1440
|
-
if (!watchContext.hooksEnabled) {
|
|
1441
|
-
return;
|
|
1442
|
-
}
|
|
1443
|
-
logger.log(color.cyan('\nFile changed, re-running tests...\n'));
|
|
1444
|
-
});
|
|
1445
|
-
|
|
1446
|
-
api.onAfterDevCompile(async ({ stats }) => {
|
|
1447
|
-
// Collect hashes even during initial build to establish baseline
|
|
1448
|
-
if (stats) {
|
|
1449
|
-
const projectEntries = await collectProjectEntries(context);
|
|
1450
|
-
const entryTestFiles = new Set<string>(
|
|
1451
|
-
collectWatchTestFiles(projectEntries).map(
|
|
1452
|
-
(file) => file.testPath,
|
|
1453
|
-
),
|
|
1454
|
-
);
|
|
1455
|
-
|
|
1456
|
-
const statsJson = stats.toJson({ all: true });
|
|
1457
|
-
const affected = getAffectedTestFiles(
|
|
1458
|
-
statsJson.chunks,
|
|
1459
|
-
entryTestFiles,
|
|
1460
|
-
);
|
|
1461
|
-
watchContext.affectedTestFiles = affected;
|
|
1462
|
-
|
|
1463
|
-
if (affected.length > 0) {
|
|
1464
|
-
logger.debug(
|
|
1465
|
-
`[Watch] Affected test files: ${affected.join(', ')}`,
|
|
1466
|
-
);
|
|
1467
|
-
}
|
|
1468
|
-
}
|
|
1469
|
-
|
|
1470
|
-
if (!watchContext.hooksEnabled) {
|
|
1471
|
-
return;
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
await onTriggerRerun();
|
|
1475
|
-
});
|
|
1476
|
-
},
|
|
1477
|
-
},
|
|
1478
|
-
]);
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
|
-
// Register coverage plugin for browser mode
|
|
1482
|
-
const coverage = browserProjects.find(
|
|
1483
|
-
(project) => project.normalizedConfig.coverage?.enabled,
|
|
1484
|
-
)?.normalizedConfig.coverage;
|
|
1485
|
-
if (coverage?.enabled && context.command !== 'list') {
|
|
1486
|
-
const { pluginCoverage } = await loadCoverageProvider(
|
|
1487
|
-
coverage,
|
|
1488
|
-
context.rootPath,
|
|
1489
|
-
);
|
|
1490
|
-
rsbuildInstance.addPlugins([pluginCoverage(coverage)]);
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
|
-
const devServer = await rsbuildInstance.createDevServer({
|
|
1494
|
-
getPortSilently: true,
|
|
1495
|
-
});
|
|
1496
|
-
|
|
1497
|
-
if (isDebug()) {
|
|
1498
|
-
await rsbuildInstance.inspectConfig({
|
|
1499
|
-
writeToDisk: true,
|
|
1500
|
-
extraConfigs: {
|
|
1501
|
-
rstest: {
|
|
1502
|
-
...context.normalizedConfig,
|
|
1503
|
-
projects: browserProjects.map((p) => p.normalizedConfig),
|
|
1504
|
-
},
|
|
1505
|
-
},
|
|
1506
|
-
});
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
// Serve prebuilt container assets (SPA) via sirv
|
|
1525
|
+
// Serve prebuilt container assets (SPA) via sirv (container origin only)
|
|
1510
1526
|
const serveContainer = containerDistPath
|
|
1511
1527
|
? sirv(containerDistPath, {
|
|
1512
1528
|
dev: false,
|
|
@@ -1536,13 +1552,7 @@ const createBrowserRuntime = async ({
|
|
|
1536
1552
|
let html = await response.text();
|
|
1537
1553
|
html = html.replace(OPTIONS_PLACEHOLDER, serializedOptions);
|
|
1538
1554
|
|
|
1539
|
-
res
|
|
1540
|
-
response.headers.forEach((value, key) => {
|
|
1541
|
-
if (key.toLowerCase() === 'content-length') {
|
|
1542
|
-
return;
|
|
1543
|
-
}
|
|
1544
|
-
res.setHeader(key, value);
|
|
1545
|
-
});
|
|
1555
|
+
copyProxyResponseHeaders(response, res);
|
|
1546
1556
|
res.setHeader('Content-Type', 'text/html');
|
|
1547
1557
|
res.end(html);
|
|
1548
1558
|
return true;
|
|
@@ -1570,13 +1580,7 @@ const createBrowserRuntime = async ({
|
|
|
1570
1580
|
}
|
|
1571
1581
|
|
|
1572
1582
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1573
|
-
res
|
|
1574
|
-
response.headers.forEach((value, key) => {
|
|
1575
|
-
if (key.toLowerCase() === 'content-length') {
|
|
1576
|
-
return;
|
|
1577
|
-
}
|
|
1578
|
-
res.setHeader(key, value);
|
|
1579
|
-
});
|
|
1583
|
+
copyProxyResponseHeaders(response, res);
|
|
1580
1584
|
res.end(buffer);
|
|
1581
1585
|
return true;
|
|
1582
1586
|
} catch (error) {
|
|
@@ -1587,75 +1591,366 @@ const createBrowserRuntime = async ({
|
|
|
1587
1591
|
}
|
|
1588
1592
|
};
|
|
1589
1593
|
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1594
|
+
const entryByEnvironmentName = new Map(
|
|
1595
|
+
projectEntries.map((entry) => [entry.project.environmentName, entry]),
|
|
1596
|
+
);
|
|
1597
|
+
|
|
1598
|
+
// ---- Build one isolated rsbuild instance + dev server per project ----
|
|
1599
|
+
const buildProjectServer = async (
|
|
1600
|
+
project: ProjectContext,
|
|
1601
|
+
isContainerServer: boolean,
|
|
1602
|
+
): Promise<BrowserProjectServer> => {
|
|
1603
|
+
const manifestPath = join(
|
|
1604
|
+
tempDir,
|
|
1605
|
+
toSafeVarName(project.environmentName),
|
|
1606
|
+
VIRTUAL_MANIFEST_FILENAME,
|
|
1607
|
+
);
|
|
1608
|
+
const entry = entryByEnvironmentName.get(project.environmentName);
|
|
1609
|
+
const manifestSource = generateManifestModule({
|
|
1610
|
+
manifestPath,
|
|
1611
|
+
entries: [
|
|
1612
|
+
{
|
|
1613
|
+
project,
|
|
1614
|
+
testFiles: entry?.testFiles ?? [],
|
|
1615
|
+
setupFiles: entry?.setupFiles ?? [],
|
|
1616
|
+
},
|
|
1617
|
+
],
|
|
1618
|
+
isWatchMode,
|
|
1619
|
+
});
|
|
1620
|
+
const virtualManifestPlugin = new rspack.experiments.VirtualModulesPlugin({
|
|
1621
|
+
[manifestPath]: manifestSource,
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
const rstestInternalAliases = {
|
|
1625
|
+
'@rstest/browser-manifest': manifestPath,
|
|
1626
|
+
...staticRstestAliases,
|
|
1627
|
+
};
|
|
1628
|
+
|
|
1629
|
+
const isHeadless =
|
|
1630
|
+
forceHeadless || project.normalizedConfig.browser.headless;
|
|
1631
|
+
const enableHmr = shouldEnableBrowserHmr(isWatchMode, isHeadless);
|
|
1632
|
+
|
|
1633
|
+
const rsbuildInstance = await createRsbuild({
|
|
1634
|
+
callerName: 'rstest-browser',
|
|
1635
|
+
rsbuildConfig: {
|
|
1636
|
+
root: context.rootPath,
|
|
1637
|
+
mode: 'development',
|
|
1638
|
+
plugins: project.normalizedConfig.plugins || [],
|
|
1639
|
+
server: {
|
|
1640
|
+
printUrls: false,
|
|
1641
|
+
// Each project gets its own dev server. Honor an explicitly
|
|
1642
|
+
// configured port; otherwise keep the historical 4000 default for the
|
|
1643
|
+
// container server and let the OS assign free ports for the rest, so
|
|
1644
|
+
// multiple projects never collide on one port.
|
|
1645
|
+
port:
|
|
1646
|
+
project.normalizedConfig.browser.port ??
|
|
1647
|
+
(isContainerServer ? 4000 : 0),
|
|
1648
|
+
strictPort: project.normalizedConfig.browser.strictPort,
|
|
1649
|
+
},
|
|
1650
|
+
dev: createBrowserRsbuildDevConfig(enableHmr),
|
|
1651
|
+
environments: {
|
|
1652
|
+
[project.environmentName]: {},
|
|
1653
|
+
},
|
|
1654
|
+
},
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
// Add plugin to merge user Rsbuild config with rstest required config
|
|
1658
|
+
rsbuildInstance.addPlugins([
|
|
1659
|
+
{
|
|
1660
|
+
name: 'rstest:browser-user-config',
|
|
1661
|
+
setup(api) {
|
|
1662
|
+
// Internal extension entry: register host dispatch handlers without
|
|
1663
|
+
// coupling scheduling to individual capability implementations.
|
|
1664
|
+
(api as { expose?: (name: string, value: unknown) => void }).expose?.(
|
|
1665
|
+
'rstest:browser',
|
|
1666
|
+
{
|
|
1667
|
+
registerDispatchHandler: (
|
|
1668
|
+
namespace: string,
|
|
1669
|
+
handler: BrowserDispatchHandler,
|
|
1670
|
+
) => {
|
|
1671
|
+
dispatchHandlers.set(namespace, handler);
|
|
1672
|
+
},
|
|
1673
|
+
},
|
|
1674
|
+
);
|
|
1675
|
+
|
|
1676
|
+
api.modifyEnvironmentConfig({
|
|
1677
|
+
handler: (config, { mergeEnvironmentConfig, name }) => {
|
|
1678
|
+
if (name !== project.environmentName) {
|
|
1679
|
+
return config;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
const userRsbuildConfig = project.normalizedConfig;
|
|
1683
|
+
const buildCache = resolveProjectBuildCache({
|
|
1684
|
+
context,
|
|
1685
|
+
project,
|
|
1686
|
+
});
|
|
1687
|
+
const setupFiles = Object.values(
|
|
1688
|
+
getSetupFiles(
|
|
1689
|
+
project.normalizedConfig.setupFiles,
|
|
1690
|
+
project.rootPath,
|
|
1691
|
+
),
|
|
1692
|
+
);
|
|
1693
|
+
// Merge order: current config -> userConfig -> rstest required config (highest priority)
|
|
1694
|
+
const merged = mergeEnvironmentConfig(
|
|
1695
|
+
config,
|
|
1696
|
+
{
|
|
1697
|
+
...userRsbuildConfig,
|
|
1698
|
+
performance: buildCache
|
|
1699
|
+
? {
|
|
1700
|
+
...userRsbuildConfig.performance,
|
|
1701
|
+
buildCache,
|
|
1702
|
+
}
|
|
1703
|
+
: userRsbuildConfig.performance,
|
|
1704
|
+
},
|
|
1705
|
+
{
|
|
1706
|
+
resolve: {
|
|
1707
|
+
alias: rstestInternalAliases,
|
|
1708
|
+
},
|
|
1709
|
+
source: {
|
|
1710
|
+
define: {
|
|
1711
|
+
'process.env': rstestEnvDefine,
|
|
1712
|
+
'import.meta.env': rstestEnvDefine,
|
|
1713
|
+
},
|
|
1714
|
+
},
|
|
1715
|
+
output: {
|
|
1716
|
+
target: 'web',
|
|
1717
|
+
// Enable source map for inline snapshot support
|
|
1718
|
+
sourceMap: {
|
|
1719
|
+
js: 'source-map',
|
|
1720
|
+
},
|
|
1721
|
+
},
|
|
1722
|
+
tools: {
|
|
1723
|
+
rspack: (rspackConfig) => {
|
|
1724
|
+
rspackConfig.mode = 'development';
|
|
1725
|
+
// lazyCompilation's only delivery transport is the HMR
|
|
1726
|
+
// runtime, so it follows the same gate as HMR (see
|
|
1727
|
+
// `shouldEnableBrowserHmr`): headed watch only, everything
|
|
1728
|
+
// else compiles eagerly.
|
|
1729
|
+
rspackConfig.lazyCompilation = enableHmr
|
|
1730
|
+
? createBrowserLazyCompilationConfig(setupFiles)
|
|
1731
|
+
: false;
|
|
1732
|
+
rspackConfig.plugins = rspackConfig.plugins || [];
|
|
1733
|
+
rspackConfig.plugins.push(virtualManifestPlugin);
|
|
1734
|
+
|
|
1735
|
+
applyDefaultWatchOptions(rspackConfig, isWatchMode);
|
|
1736
|
+
|
|
1737
|
+
// Extract and merge sourcemaps from pre-built @rstest/core files
|
|
1738
|
+
// This preserves the sourcemap chain for inline snapshot support
|
|
1739
|
+
// See: https://rspack.dev/config/module-rules#rulesextractsourcemap
|
|
1740
|
+
const browserRuntimeDir = dirname(browserRuntimePath);
|
|
1741
|
+
rspackConfig.module = rspackConfig.module || {};
|
|
1742
|
+
rspackConfig.module.rules =
|
|
1743
|
+
rspackConfig.module.rules || [];
|
|
1744
|
+
rspackConfig.module.rules.unshift({
|
|
1745
|
+
test: /\.js$/,
|
|
1746
|
+
include: browserRuntimeDir,
|
|
1747
|
+
extractSourceMap: true,
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
if (isDebug()) {
|
|
1751
|
+
logger.log(
|
|
1752
|
+
`[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`,
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1755
|
+
},
|
|
1756
|
+
},
|
|
1757
|
+
},
|
|
1758
|
+
);
|
|
1759
|
+
|
|
1760
|
+
// Completely overwrite entry to prevent Rsbuild default entry detection from taking effect.
|
|
1761
|
+
// In browser mode, entry is fully controlled by rstest (not user's src/index.ts).
|
|
1762
|
+
// This must be done after mergeEnvironmentConfig to ensure highest priority.
|
|
1763
|
+
merged.source = merged.source || {};
|
|
1764
|
+
merged.source.entry = {
|
|
1765
|
+
runner: resolveBrowserFile('client/entry.ts'),
|
|
1766
|
+
};
|
|
1767
|
+
|
|
1768
|
+
return merged;
|
|
1769
|
+
},
|
|
1770
|
+
// Execute after all other plugins to ensure rstest's entry config has the highest priority
|
|
1771
|
+
order: 'post',
|
|
1772
|
+
});
|
|
1773
|
+
},
|
|
1774
|
+
},
|
|
1775
|
+
]);
|
|
1776
|
+
|
|
1777
|
+
// Register watch plugin if in watch mode
|
|
1778
|
+
if (isWatchMode && onTriggerRerun) {
|
|
1779
|
+
rsbuildInstance.addPlugins([
|
|
1780
|
+
{
|
|
1781
|
+
name: 'rstest:browser-watch',
|
|
1782
|
+
setup(api) {
|
|
1783
|
+
api.onBeforeDevCompile(() => {
|
|
1784
|
+
if (!watchContext.hooksEnabled) {
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
logger.log(color.cyan('\nFile changed, re-running tests...\n'));
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
api.onAfterDevCompile(async ({ stats }) => {
|
|
1791
|
+
// Collect hashes even during initial build to establish baseline
|
|
1792
|
+
if (stats) {
|
|
1793
|
+
const allProjectEntries = await collectProjectEntries(context);
|
|
1794
|
+
const entryTestFiles = new Set<string>(
|
|
1795
|
+
collectWatchTestFiles(allProjectEntries).map(
|
|
1796
|
+
(file) => file.testPath,
|
|
1797
|
+
),
|
|
1798
|
+
);
|
|
1799
|
+
|
|
1800
|
+
const statsJson = stats.toJson({ all: true });
|
|
1801
|
+
const affected = getAffectedTestFiles(
|
|
1802
|
+
statsJson.chunks,
|
|
1803
|
+
entryTestFiles,
|
|
1804
|
+
);
|
|
1805
|
+
watchContext.affectedTestFiles = affected;
|
|
1806
|
+
|
|
1807
|
+
if (affected.length > 0) {
|
|
1808
|
+
logger.debug(
|
|
1809
|
+
`[Watch] Affected test files: ${affected.join(', ')}`,
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
if (!watchContext.hooksEnabled) {
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
await onTriggerRerun();
|
|
1819
|
+
});
|
|
1820
|
+
},
|
|
1821
|
+
},
|
|
1822
|
+
]);
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// Register coverage plugin if this project enables coverage
|
|
1826
|
+
const coverage = project.normalizedConfig.coverage;
|
|
1827
|
+
if (coverage?.enabled && context.command !== 'list') {
|
|
1828
|
+
const { pluginCoverage } = await loadCoverageProvider(
|
|
1829
|
+
coverage,
|
|
1830
|
+
context.rootPath,
|
|
1831
|
+
);
|
|
1832
|
+
rsbuildInstance.addPlugins([pluginCoverage(coverage)]);
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
const devServer = await rsbuildInstance.createDevServer({
|
|
1836
|
+
getPortSilently: true,
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
if (isDebug()) {
|
|
1840
|
+
await rsbuildInstance.inspectConfig({
|
|
1841
|
+
writeToDisk: true,
|
|
1842
|
+
extraConfigs: {
|
|
1843
|
+
rstest: {
|
|
1844
|
+
...context.normalizedConfig,
|
|
1845
|
+
projects: [project.normalizedConfig],
|
|
1846
|
+
},
|
|
1847
|
+
},
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
devServer.middlewares.use(
|
|
1852
|
+
async (req: IncomingMessage, res: ServerResponse, next: () => void) => {
|
|
1853
|
+
if (!req.url) {
|
|
1854
|
+
next();
|
|
1602
1855
|
return;
|
|
1603
1856
|
}
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1857
|
+
const url = new URL(req.url, 'http://localhost');
|
|
1858
|
+
if (url.pathname === '/__open-in-editor') {
|
|
1859
|
+
const file = url.searchParams.get('file');
|
|
1860
|
+
if (!file) {
|
|
1861
|
+
res.statusCode = 400;
|
|
1862
|
+
res.end('Missing file');
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
try {
|
|
1866
|
+
await openEditor([{ file }]);
|
|
1867
|
+
res.statusCode = 204;
|
|
1868
|
+
res.end();
|
|
1869
|
+
} catch (error) {
|
|
1870
|
+
logger.debug(
|
|
1871
|
+
`[Browser UI] Failed to open editor: ${String(error)}`,
|
|
1872
|
+
);
|
|
1873
|
+
res.statusCode = 500;
|
|
1874
|
+
res.end('Failed to open editor');
|
|
1875
|
+
}
|
|
1617
1876
|
return;
|
|
1618
1877
|
}
|
|
1878
|
+
// Container UI HTML + static assets are served by the container origin
|
|
1879
|
+
// only. Per-project runner servers expose just /runner.html + assets.
|
|
1880
|
+
if (isContainerServer) {
|
|
1881
|
+
if (url.pathname === '/') {
|
|
1882
|
+
if (await respondWithDevServerHtml(url, res)) {
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
const html =
|
|
1887
|
+
injectedContainerHtml ||
|
|
1888
|
+
containerHtmlTemplate?.replace(OPTIONS_PLACEHOLDER, 'null');
|
|
1889
|
+
|
|
1890
|
+
if (html) {
|
|
1891
|
+
res.setHeader('Content-Type', 'text/html');
|
|
1892
|
+
res.end(html);
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
res.statusCode = 502;
|
|
1897
|
+
res.end('Container UI is not available.');
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
if (url.pathname.startsWith('/container-static/')) {
|
|
1901
|
+
if (await proxyDevServerAsset(req, res)) {
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1619
1904
|
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1905
|
+
if (serveContainer) {
|
|
1906
|
+
serveContainer(req, res, next);
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1623
1909
|
|
|
1624
|
-
|
|
1910
|
+
res.statusCode = 502;
|
|
1911
|
+
res.end('Container assets are not available.');
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
if (url.pathname === '/runner.html') {
|
|
1625
1916
|
res.setHeader('Content-Type', 'text/html');
|
|
1626
|
-
res.end(
|
|
1917
|
+
res.end(htmlTemplate);
|
|
1627
1918
|
return;
|
|
1628
1919
|
}
|
|
1920
|
+
next();
|
|
1921
|
+
},
|
|
1922
|
+
);
|
|
1629
1923
|
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
return;
|
|
1633
|
-
}
|
|
1634
|
-
if (url.pathname.startsWith('/container-static/')) {
|
|
1635
|
-
if (await proxyDevServerAsset(req, res)) {
|
|
1636
|
-
return;
|
|
1637
|
-
}
|
|
1924
|
+
const { port: listenPort } = await devServer.listen();
|
|
1925
|
+
const port = resolveListenPort(listenPort, devServer.httpServer);
|
|
1638
1926
|
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1927
|
+
return {
|
|
1928
|
+
projectName: project.name,
|
|
1929
|
+
environmentName: project.environmentName,
|
|
1930
|
+
rsbuildInstance,
|
|
1931
|
+
devServer,
|
|
1932
|
+
port,
|
|
1933
|
+
manifestPath,
|
|
1934
|
+
};
|
|
1935
|
+
};
|
|
1643
1936
|
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1937
|
+
// Build each project's server sequentially. Servers must bind ports one at a
|
|
1938
|
+
// time: projects may share a configured port and rely on strictPort:false
|
|
1939
|
+
// bumping to the next free one, which races under concurrent listen().
|
|
1940
|
+
const projectServers = new Map<string, BrowserProjectServer>();
|
|
1941
|
+
try {
|
|
1942
|
+
for (const [index, project] of browserProjects.entries()) {
|
|
1943
|
+
const server = await buildProjectServer(project, index === 0);
|
|
1944
|
+
projectServers.set(server.projectName, server);
|
|
1945
|
+
}
|
|
1946
|
+
} catch (error) {
|
|
1947
|
+
await closeAllProjectServers(projectServers.values());
|
|
1948
|
+
throw error;
|
|
1949
|
+
}
|
|
1656
1950
|
|
|
1657
|
-
|
|
1658
|
-
|
|
1951
|
+
// browserProjects is non-empty (ensureConsistentBrowserLaunchOptions throws
|
|
1952
|
+
// otherwise) and index 0 is the designated container origin.
|
|
1953
|
+
const containerServer = projectServers.get(browserProjects[0]!.name)!;
|
|
1659
1954
|
|
|
1660
1955
|
// Create WebSocket server on an available port
|
|
1661
1956
|
// Using port: 0 lets the OS assign an available port, avoiding conflicts
|
|
@@ -1679,22 +1974,19 @@ const createBrowserRuntime = async ({
|
|
|
1679
1974
|
providerOptions: browserLaunchOptions.providerOptions,
|
|
1680
1975
|
});
|
|
1681
1976
|
return {
|
|
1682
|
-
|
|
1683
|
-
|
|
1977
|
+
projectServers,
|
|
1978
|
+
containerServer,
|
|
1684
1979
|
browser: runtime.browser,
|
|
1685
1980
|
browserLaunchOptions,
|
|
1686
|
-
port,
|
|
1687
1981
|
wsPort,
|
|
1688
|
-
manifestPath,
|
|
1689
1982
|
tempDir,
|
|
1690
|
-
manifestPlugin: virtualManifestPlugin,
|
|
1691
1983
|
setContainerOptions,
|
|
1692
1984
|
dispatchHandlers,
|
|
1693
1985
|
wss,
|
|
1694
1986
|
};
|
|
1695
1987
|
} catch (error) {
|
|
1696
1988
|
wss.close();
|
|
1697
|
-
await
|
|
1989
|
+
await closeAllProjectServers(projectServers.values());
|
|
1698
1990
|
throw error;
|
|
1699
1991
|
}
|
|
1700
1992
|
};
|
|
@@ -2020,12 +2312,6 @@ export const runBrowserController = async (
|
|
|
2020
2312
|
Date.now().toString(),
|
|
2021
2313
|
);
|
|
2022
2314
|
|
|
2023
|
-
const manifestPath = join(tempDir, VIRTUAL_MANIFEST_FILENAME);
|
|
2024
|
-
const manifestSource = generateManifestModule({
|
|
2025
|
-
manifestPath,
|
|
2026
|
-
entries: projectEntries,
|
|
2027
|
-
});
|
|
2028
|
-
|
|
2029
2315
|
// Track initial test files for watch mode
|
|
2030
2316
|
if (isWatchMode) {
|
|
2031
2317
|
watchContext.lastTestFiles = collectWatchTestFiles(projectEntries);
|
|
@@ -2040,8 +2326,7 @@ export const runBrowserController = async (
|
|
|
2040
2326
|
try {
|
|
2041
2327
|
runtime = await createBrowserRuntime({
|
|
2042
2328
|
context,
|
|
2043
|
-
|
|
2044
|
-
manifestSource,
|
|
2329
|
+
projectEntries,
|
|
2045
2330
|
tempDir,
|
|
2046
2331
|
isWatchMode,
|
|
2047
2332
|
onTriggerRerun: isWatchMode
|
|
@@ -2070,7 +2355,7 @@ export const runBrowserController = async (
|
|
|
2070
2355
|
}
|
|
2071
2356
|
}
|
|
2072
2357
|
|
|
2073
|
-
const { browser, browserLaunchOptions,
|
|
2358
|
+
const { browser, browserLaunchOptions, wsPort, wss } = runtime;
|
|
2074
2359
|
const buildTime = Date.now() - buildStart;
|
|
2075
2360
|
|
|
2076
2361
|
// Collect all test files from project entries with project info
|
|
@@ -2101,13 +2386,22 @@ export const runBrowserController = async (
|
|
|
2101
2386
|
),
|
|
2102
2387
|
);
|
|
2103
2388
|
|
|
2389
|
+
const projectRunnerUrls = Object.fromEntries(
|
|
2390
|
+
[...runtime.projectServers].map(([name, server]) => [
|
|
2391
|
+
name,
|
|
2392
|
+
`http://localhost:${server.port}`,
|
|
2393
|
+
]),
|
|
2394
|
+
);
|
|
2395
|
+
|
|
2104
2396
|
const hostOptions: BrowserHostConfig = {
|
|
2105
2397
|
rootPath: normalize(context.rootPath),
|
|
2106
2398
|
projects: projectRuntimeConfigs,
|
|
2107
2399
|
snapshot: {
|
|
2108
2400
|
updateSnapshot: context.snapshotManager.options.updateSnapshot,
|
|
2109
2401
|
},
|
|
2110
|
-
|
|
2402
|
+
// Container origin (fallback). Per-project runner origins below.
|
|
2403
|
+
runnerUrl: `http://localhost:${runtime.containerServer.port}`,
|
|
2404
|
+
projectRunnerUrls,
|
|
2111
2405
|
wsPort,
|
|
2112
2406
|
debug: isDebug(),
|
|
2113
2407
|
rpcTimeout: maxTestTimeoutForRpc,
|
|
@@ -2134,8 +2428,7 @@ export const runBrowserController = async (
|
|
|
2134
2428
|
|
|
2135
2429
|
let activeContainerPage: BrowserProviderPage | null = null;
|
|
2136
2430
|
let getHeadlessRunnerPageBySessionId:
|
|
2137
|
-
|
|
2138
|
-
| undefined;
|
|
2431
|
+
((sessionId: string) => BrowserProviderPage | undefined) | undefined;
|
|
2139
2432
|
|
|
2140
2433
|
const dispatchBrowserRpcRequest = async ({
|
|
2141
2434
|
request,
|
|
@@ -2682,17 +2975,34 @@ export const runBrowserController = async (
|
|
|
2682
2975
|
resolveDone = resolve;
|
|
2683
2976
|
});
|
|
2684
2977
|
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2978
|
+
// Event-driven death detection (vitest-style): a renderer crash or an
|
|
2979
|
+
// unexpected page close produces no further messages, so fail the file at
|
|
2980
|
+
// once. Per-test/hook timeouts are enforced inside the runner, so the host
|
|
2981
|
+
// deliberately keeps no execution-duration watchdog. Our own teardown
|
|
2982
|
+
// close is ignored because `settled`/`run.cancelled` are set by then.
|
|
2983
|
+
const crashDeferred = createDeferredPromise<string>();
|
|
2984
|
+
const onPageDead = (reason: string): void => {
|
|
2985
|
+
if (
|
|
2986
|
+
settled ||
|
|
2987
|
+
run.cancelled ||
|
|
2988
|
+
!runLifecycle.isTokenActive(run.token)
|
|
2989
|
+
) {
|
|
2990
|
+
return;
|
|
2991
|
+
}
|
|
2992
|
+
settled = true;
|
|
2993
|
+
crashDeferred.resolve(reason);
|
|
2994
|
+
};
|
|
2693
2995
|
|
|
2694
2996
|
try {
|
|
2695
2997
|
page = await browserContext.newPage();
|
|
2998
|
+
page.on('crash', () =>
|
|
2999
|
+
onPageDead(`Browser page crashed while running ${file.testPath}.`),
|
|
3000
|
+
);
|
|
3001
|
+
page.on('close', () =>
|
|
3002
|
+
onPageDead(
|
|
3003
|
+
`Browser page closed unexpectedly while running ${file.testPath}.`,
|
|
3004
|
+
),
|
|
3005
|
+
);
|
|
2696
3006
|
|
|
2697
3007
|
const session = sessionRegistry.register({
|
|
2698
3008
|
testFile: file.testPath,
|
|
@@ -2751,32 +3061,35 @@ export const runBrowserController = async (
|
|
|
2751
3061
|
`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
|
|
2752
3062
|
);
|
|
2753
3063
|
|
|
2754
|
-
|
|
3064
|
+
const projectServer = runtime.projectServers.get(file.projectName);
|
|
3065
|
+
if (!projectServer) {
|
|
3066
|
+
throw new Error(
|
|
3067
|
+
`No browser dev server for project "${file.projectName}" (test file: ${file.testPath}).`,
|
|
3068
|
+
);
|
|
3069
|
+
}
|
|
3070
|
+
await page.goto(`http://localhost:${projectServer.port}/runner.html`, {
|
|
2755
3071
|
waitUntil: 'load',
|
|
2756
3072
|
});
|
|
2757
3073
|
|
|
2758
|
-
const timeoutPromise = new Promise<'timeout'>((resolve) => {
|
|
2759
|
-
timeoutId = setTimeout(() => resolve('timeout'), perFileTimeoutMs);
|
|
2760
|
-
});
|
|
2761
|
-
|
|
2762
3074
|
const state = await Promise.race([
|
|
2763
|
-
donePromise.then(() => 'done' as const),
|
|
2764
|
-
|
|
2765
|
-
|
|
3075
|
+
donePromise.then(() => ({ type: 'done' as const })),
|
|
3076
|
+
crashDeferred.promise.then((reason) => ({
|
|
3077
|
+
type: 'crash' as const,
|
|
3078
|
+
reason,
|
|
3079
|
+
})),
|
|
3080
|
+
run.cancelSignal.then(() => ({ type: 'cancelled' as const })),
|
|
2766
3081
|
]);
|
|
2767
3082
|
|
|
2768
|
-
if (state === 'cancelled') {
|
|
3083
|
+
if (state.type === 'cancelled') {
|
|
2769
3084
|
return;
|
|
2770
3085
|
}
|
|
2771
3086
|
|
|
2772
3087
|
if (
|
|
2773
|
-
state === '
|
|
3088
|
+
state.type === 'crash' &&
|
|
2774
3089
|
runLifecycle.isTokenActive(run.token) &&
|
|
2775
3090
|
!run.cancelled
|
|
2776
3091
|
) {
|
|
2777
|
-
await handleFatal({
|
|
2778
|
-
message: `Test execution timeout after ${perFileTimeoutMs / 1000}s for ${file.testPath}.`,
|
|
2779
|
-
});
|
|
3092
|
+
await handleFatal({ message: state.reason });
|
|
2780
3093
|
await cancelRun(run, false);
|
|
2781
3094
|
}
|
|
2782
3095
|
} catch (error) {
|
|
@@ -2789,9 +3102,6 @@ export const runBrowserController = async (
|
|
|
2789
3102
|
await cancelRun(run, false);
|
|
2790
3103
|
}
|
|
2791
3104
|
} finally {
|
|
2792
|
-
if (timeoutId) {
|
|
2793
|
-
clearTimeout(timeoutId);
|
|
2794
|
-
}
|
|
2795
3105
|
if (page) {
|
|
2796
3106
|
try {
|
|
2797
3107
|
await page.close();
|
|
@@ -3173,16 +3483,6 @@ export const runBrowserController = async (
|
|
|
3173
3483
|
return fileInfo;
|
|
3174
3484
|
};
|
|
3175
3485
|
|
|
3176
|
-
const getHeadedPerFileTimeoutMs = (file: TestFileInfo): number => {
|
|
3177
|
-
const projectRuntime = projectRuntimeConfigs.find(
|
|
3178
|
-
(project) => project.name === file.projectName,
|
|
3179
|
-
);
|
|
3180
|
-
return (
|
|
3181
|
-
(projectRuntime?.runtimeConfig.testTimeout ?? maxTestTimeoutForRpc) +
|
|
3182
|
-
PER_FILE_TIMEOUT_BUFFER_MS
|
|
3183
|
-
);
|
|
3184
|
-
};
|
|
3185
|
-
|
|
3186
3486
|
// Open a container page for user to view (reuse in watch mode)
|
|
3187
3487
|
let containerContext: BrowserProviderContext;
|
|
3188
3488
|
let containerPage: BrowserProviderPage;
|
|
@@ -3307,12 +3607,13 @@ export const runBrowserController = async (
|
|
|
3307
3607
|
pending.deferred.resolve();
|
|
3308
3608
|
};
|
|
3309
3609
|
|
|
3310
|
-
|
|
3610
|
+
// No execution-duration watchdog: per-test/hook timeouts are enforced inside
|
|
3611
|
+
// the runner, and a dead container is caught event-driven by the WebSocket
|
|
3612
|
+
// `close` handler, which rejects every pending reload via `onDisconnect`.
|
|
3613
|
+
const reloadTestFileAndWait = async (
|
|
3311
3614
|
file: TestFileInfo,
|
|
3312
3615
|
testNamePattern?: string,
|
|
3313
3616
|
): Promise<void> => {
|
|
3314
|
-
const timeoutMs = getHeadedPerFileTimeoutMs(file);
|
|
3315
|
-
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
3316
3617
|
let reloadAck: ReloadTestFileAck | undefined;
|
|
3317
3618
|
|
|
3318
3619
|
try {
|
|
@@ -3320,22 +3621,7 @@ export const runBrowserController = async (
|
|
|
3320
3621
|
file.testPath,
|
|
3321
3622
|
testNamePattern,
|
|
3322
3623
|
);
|
|
3323
|
-
|
|
3324
|
-
file.testPath,
|
|
3325
|
-
reloadAck.runId,
|
|
3326
|
-
);
|
|
3327
|
-
await Promise.race([
|
|
3328
|
-
completionPromise,
|
|
3329
|
-
new Promise<never>((_, reject) => {
|
|
3330
|
-
timeoutId = setTimeout(() => {
|
|
3331
|
-
reject(
|
|
3332
|
-
new Error(
|
|
3333
|
-
`Headed test execution timeout after ${timeoutMs / 1000}s for ${file.testPath}.`,
|
|
3334
|
-
),
|
|
3335
|
-
);
|
|
3336
|
-
}, timeoutMs);
|
|
3337
|
-
}),
|
|
3338
|
-
]);
|
|
3624
|
+
await registerPendingHeadedReload(file.testPath, reloadAck.runId);
|
|
3339
3625
|
} catch (error) {
|
|
3340
3626
|
if (reloadAck?.runId) {
|
|
3341
3627
|
rejectPendingHeadedReload(
|
|
@@ -3345,10 +3631,6 @@ export const runBrowserController = async (
|
|
|
3345
3631
|
);
|
|
3346
3632
|
}
|
|
3347
3633
|
throw error;
|
|
3348
|
-
} finally {
|
|
3349
|
-
if (timeoutId) {
|
|
3350
|
-
clearTimeout(timeoutId);
|
|
3351
|
-
}
|
|
3352
3634
|
}
|
|
3353
3635
|
};
|
|
3354
3636
|
|
|
@@ -3432,13 +3714,14 @@ export const runBrowserController = async (
|
|
|
3432
3714
|
// Only navigate on first creation
|
|
3433
3715
|
if (isNewPage) {
|
|
3434
3716
|
const pagePath = '/';
|
|
3435
|
-
|
|
3717
|
+
const containerPort = runtime.containerServer.port;
|
|
3718
|
+
await containerPage.goto(`http://localhost:${containerPort}${pagePath}`, {
|
|
3436
3719
|
waitUntil: 'load',
|
|
3437
3720
|
});
|
|
3438
3721
|
|
|
3439
3722
|
logger.log(
|
|
3440
3723
|
color.cyan(
|
|
3441
|
-
`\nBrowser mode opened at http://localhost:${
|
|
3724
|
+
`\nBrowser mode opened at http://localhost:${containerPort}${pagePath}\n`,
|
|
3442
3725
|
),
|
|
3443
3726
|
);
|
|
3444
3727
|
}
|
|
@@ -3451,7 +3734,7 @@ export const runBrowserController = async (
|
|
|
3451
3734
|
if (fatalError) {
|
|
3452
3735
|
return;
|
|
3453
3736
|
}
|
|
3454
|
-
await
|
|
3737
|
+
await reloadTestFileAndWait(file, testNamePattern);
|
|
3455
3738
|
});
|
|
3456
3739
|
};
|
|
3457
3740
|
|
|
@@ -3670,11 +3953,6 @@ export const listBrowserTests = async (
|
|
|
3670
3953
|
`list-${Date.now()}`,
|
|
3671
3954
|
);
|
|
3672
3955
|
|
|
3673
|
-
const manifestPath = join(tempDir, VIRTUAL_MANIFEST_FILENAME);
|
|
3674
|
-
const manifestSource = generateManifestModule({
|
|
3675
|
-
manifestPath,
|
|
3676
|
-
entries: projectEntries,
|
|
3677
|
-
});
|
|
3678
3956
|
const browserProjects = getBrowserProjects(context);
|
|
3679
3957
|
|
|
3680
3958
|
// Create a simplified browser runtime for collect mode
|
|
@@ -3682,8 +3960,7 @@ export const listBrowserTests = async (
|
|
|
3682
3960
|
try {
|
|
3683
3961
|
runtime = await createBrowserRuntime({
|
|
3684
3962
|
context,
|
|
3685
|
-
|
|
3686
|
-
manifestSource,
|
|
3963
|
+
projectEntries,
|
|
3687
3964
|
tempDir,
|
|
3688
3965
|
isWatchMode: false,
|
|
3689
3966
|
containerDistPath: undefined,
|
|
@@ -3705,7 +3982,7 @@ export const listBrowserTests = async (
|
|
|
3705
3982
|
throw error;
|
|
3706
3983
|
}
|
|
3707
3984
|
|
|
3708
|
-
const { browser, browserLaunchOptions
|
|
3985
|
+
const { browser, browserLaunchOptions } = runtime;
|
|
3709
3986
|
|
|
3710
3987
|
// Get browser projects for runtime config
|
|
3711
3988
|
// Normalize projectRoot to posix format for cross-platform compatibility
|
|
@@ -3739,105 +4016,122 @@ export const listBrowserTests = async (
|
|
|
3739
4016
|
|
|
3740
4017
|
runtime.setContainerOptions(hostOptions);
|
|
3741
4018
|
|
|
3742
|
-
// Collect results
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
let collectCompleted = false;
|
|
3746
|
-
|
|
3747
|
-
// Promise that resolves when collection is complete
|
|
3748
|
-
let resolveCollect: (() => void) | undefined;
|
|
3749
|
-
const collectPromise = new Promise<void>((resolve) => {
|
|
3750
|
-
resolveCollect = resolve;
|
|
3751
|
-
});
|
|
3752
|
-
|
|
3753
|
-
// Create a headless page to run collection
|
|
4019
|
+
// Collect results across every project's isolated dev server. Each server
|
|
4020
|
+
// serves only its own project's manifest, so collection navigates one page
|
|
4021
|
+
// per project; each page returns its own results, aggregated afterwards.
|
|
3754
4022
|
const browserContext = await browser.newContext({
|
|
3755
4023
|
providerOptions: browserLaunchOptions.providerOptions,
|
|
3756
4024
|
viewport: null,
|
|
3757
4025
|
});
|
|
3758
|
-
const page = await browserContext.newPage();
|
|
3759
|
-
|
|
3760
|
-
// Expose dispatch function for browser client to send messages
|
|
3761
|
-
await page.exposeFunction(
|
|
3762
|
-
DISPATCH_MESSAGE_TYPE,
|
|
3763
|
-
(message: { type: string; payload?: unknown }) => {
|
|
3764
|
-
switch (message.type) {
|
|
3765
|
-
case 'collect-result': {
|
|
3766
|
-
const payload = message.payload as {
|
|
3767
|
-
testPath: string;
|
|
3768
|
-
project: string;
|
|
3769
|
-
tests: Test[];
|
|
3770
|
-
};
|
|
3771
|
-
collectResults.push({
|
|
3772
|
-
testPath: payload.testPath,
|
|
3773
|
-
project: payload.project,
|
|
3774
|
-
tests: payload.tests,
|
|
3775
|
-
});
|
|
3776
|
-
break;
|
|
3777
|
-
}
|
|
3778
|
-
case 'collect-complete':
|
|
3779
|
-
collectCompleted = true;
|
|
3780
|
-
resolveCollect?.();
|
|
3781
|
-
break;
|
|
3782
|
-
case 'fatal': {
|
|
3783
|
-
const payload = message.payload as {
|
|
3784
|
-
message: string;
|
|
3785
|
-
stack?: string;
|
|
3786
|
-
};
|
|
3787
|
-
fatalError = new Error(payload.message);
|
|
3788
|
-
fatalError.stack = payload.stack;
|
|
3789
|
-
resolveCollect?.();
|
|
3790
|
-
break;
|
|
3791
|
-
}
|
|
3792
|
-
case 'ready':
|
|
3793
|
-
case 'log':
|
|
3794
|
-
// Ignore these messages during collection
|
|
3795
|
-
break;
|
|
3796
|
-
default:
|
|
3797
|
-
// Log unexpected messages for debugging
|
|
3798
|
-
logger.debug(`[List] Unexpected message: ${message.type}`);
|
|
3799
|
-
}
|
|
3800
|
-
},
|
|
3801
|
-
);
|
|
3802
4026
|
|
|
3803
|
-
// Inject host options before navigation so the runner can access them
|
|
3804
4027
|
const serializedOptions = serializeForInlineScript(hostOptions);
|
|
3805
|
-
await page.addInitScript(
|
|
3806
|
-
`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
|
|
3807
|
-
);
|
|
3808
4028
|
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
4029
|
+
const collectFromServer = async (
|
|
4030
|
+
server: BrowserProjectServer,
|
|
4031
|
+
): Promise<{ results: ListCommandResult[]; error: Error | null }> => {
|
|
4032
|
+
const results: ListCommandResult[] = [];
|
|
4033
|
+
let error: Error | null = null;
|
|
4034
|
+
let collectCompleted = false;
|
|
4035
|
+
let resolveCollect: (() => void) | undefined;
|
|
4036
|
+
const collectPromise = new Promise<void>((resolve) => {
|
|
4037
|
+
resolveCollect = resolve;
|
|
4038
|
+
});
|
|
3813
4039
|
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
4040
|
+
const page = await browserContext.newPage();
|
|
4041
|
+
|
|
4042
|
+
// Expose dispatch function for browser client to send messages
|
|
4043
|
+
await page.exposeFunction(
|
|
4044
|
+
DISPATCH_MESSAGE_TYPE,
|
|
4045
|
+
(message: { type: string; payload?: unknown }) => {
|
|
4046
|
+
switch (message.type) {
|
|
4047
|
+
case 'collect-result': {
|
|
4048
|
+
const payload = message.payload as {
|
|
4049
|
+
testPath: string;
|
|
4050
|
+
project: string;
|
|
4051
|
+
tests: Test[];
|
|
4052
|
+
};
|
|
4053
|
+
results.push({
|
|
4054
|
+
testPath: payload.testPath,
|
|
4055
|
+
project: payload.project,
|
|
4056
|
+
tests: payload.tests,
|
|
4057
|
+
});
|
|
4058
|
+
break;
|
|
4059
|
+
}
|
|
4060
|
+
case 'collect-complete':
|
|
4061
|
+
collectCompleted = true;
|
|
4062
|
+
resolveCollect?.();
|
|
4063
|
+
break;
|
|
4064
|
+
case 'fatal': {
|
|
4065
|
+
const payload = message.payload as {
|
|
4066
|
+
message: string;
|
|
4067
|
+
stack?: string;
|
|
4068
|
+
};
|
|
4069
|
+
error = new Error(payload.message);
|
|
4070
|
+
error.stack = payload.stack;
|
|
4071
|
+
resolveCollect?.();
|
|
4072
|
+
break;
|
|
4073
|
+
}
|
|
4074
|
+
case 'ready':
|
|
4075
|
+
case 'log':
|
|
4076
|
+
// Ignore these messages during collection
|
|
4077
|
+
break;
|
|
4078
|
+
default:
|
|
4079
|
+
// Log unexpected messages for debugging
|
|
4080
|
+
logger.debug(`[List] Unexpected message: ${message.type}`);
|
|
4081
|
+
}
|
|
4082
|
+
},
|
|
4083
|
+
);
|
|
3829
4084
|
|
|
3830
|
-
|
|
4085
|
+
// Inject host options before navigation so the runner can access them
|
|
4086
|
+
await page.addInitScript(
|
|
4087
|
+
`window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
|
|
4088
|
+
);
|
|
3831
4089
|
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
4090
|
+
// Navigate to this project's runner page
|
|
4091
|
+
await page.goto(`http://localhost:${server.port}/runner.html`, {
|
|
4092
|
+
waitUntil: 'load',
|
|
4093
|
+
});
|
|
4094
|
+
|
|
4095
|
+
// Wait for collection to complete with timeout
|
|
4096
|
+
const timeoutMs = 30000;
|
|
4097
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
4098
|
+
const timeoutPromise = new Promise<void>((resolve) => {
|
|
4099
|
+
timeoutId = setTimeout(() => {
|
|
4100
|
+
if (!collectCompleted) {
|
|
4101
|
+
logger.warn(
|
|
4102
|
+
color.yellow(
|
|
4103
|
+
`[List] Browser test collection timed out after ${timeoutMs}ms`,
|
|
4104
|
+
),
|
|
4105
|
+
);
|
|
4106
|
+
}
|
|
4107
|
+
resolve();
|
|
4108
|
+
}, timeoutMs);
|
|
4109
|
+
});
|
|
4110
|
+
|
|
4111
|
+
await Promise.race([collectPromise, timeoutPromise]);
|
|
4112
|
+
|
|
4113
|
+
// Clear timeout to prevent Node.js from waiting for it
|
|
4114
|
+
if (timeoutId) {
|
|
4115
|
+
clearTimeout(timeoutId);
|
|
4116
|
+
}
|
|
4117
|
+
|
|
4118
|
+
await page.close().catch(() => {});
|
|
4119
|
+
return { results, error };
|
|
4120
|
+
};
|
|
4121
|
+
|
|
4122
|
+
// Collect every project concurrently — each navigates its own page against
|
|
4123
|
+
// its own dev server and returns its own results.
|
|
4124
|
+
const collected = await Promise.all(
|
|
4125
|
+
[...runtime.projectServers.values()].map((server) =>
|
|
4126
|
+
collectFromServer(server),
|
|
4127
|
+
),
|
|
4128
|
+
);
|
|
4129
|
+
const collectResults = collected.flatMap((entry) => entry.results);
|
|
4130
|
+
const fatalError = collected.find((entry) => entry.error)?.error ?? null;
|
|
3836
4131
|
|
|
3837
4132
|
// Cleanup
|
|
3838
4133
|
const cleanup = async () => {
|
|
3839
4134
|
try {
|
|
3840
|
-
await page.close();
|
|
3841
4135
|
await browserContext.close();
|
|
3842
4136
|
} catch {
|
|
3843
4137
|
// ignore
|
|
@@ -3855,8 +4149,8 @@ export const listBrowserTests = async (
|
|
|
3855
4149
|
errors: [
|
|
3856
4150
|
{
|
|
3857
4151
|
name: 'BrowserCollectError',
|
|
3858
|
-
message:
|
|
3859
|
-
stack:
|
|
4152
|
+
message: fatalError.message,
|
|
4153
|
+
stack: fatalError.stack,
|
|
3860
4154
|
} as FormattedError,
|
|
3861
4155
|
],
|
|
3862
4156
|
};
|