@rstest/browser 0.9.2 → 0.9.4
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/{101.82cdbbe145.js → 927.514b181bd2.js} +1710 -1645
- package/dist/browser-container/container-static/js/927.514b181bd2.js.LICENSE.txt +1 -0
- package/dist/browser-container/container-static/js/{index.602d6770fe.js → index.5acf502b10.js} +505 -434
- package/dist/browser-container/container-static/js/{lib-react.ce60b6aea5.js → lib-react.f905279759.js} +4 -4
- package/dist/browser-container/container-static/js/lib-react.f905279759.js.LICENSE.txt +1 -0
- package/dist/browser-container/index.html +1 -1
- package/dist/index.js +149 -36
- package/dist/providers/index.d.ts +10 -0
- package/dist/providers/playwright/runtime.d.ts +2 -1
- package/dist/rslib-runtime.js +20 -0
- package/package.json +4 -3
- package/src/AGENTS.md +1 -1
- package/src/client/AGENTS.md +8 -8
- package/src/configValidation.ts +4 -0
- package/src/hostController.ts +254 -52
- package/src/index.ts +0 -1
- package/src/providers/index.ts +10 -0
- package/src/providers/playwright/implementation.ts +2 -0
- package/src/providers/playwright/runtime.ts +37 -15
- package/dist/browser-container/container-static/js/101.82cdbbe145.js.LICENSE.txt +0 -1
- package/dist/browser-container/container-static/js/lib-react.ce60b6aea5.js.LICENSE.txt +0 -1
package/src/hostController.ts
CHANGED
|
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
|
|
|
3
3
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
4
4
|
import type { AddressInfo } from 'node:net';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
6
7
|
import type { Rspack } from '@rstest/core';
|
|
7
8
|
import {
|
|
8
9
|
type BrowserTestRunOptions,
|
|
@@ -30,7 +31,7 @@ import {
|
|
|
30
31
|
import { type BirpcReturn, createBirpc } from 'birpc';
|
|
31
32
|
import openEditor from 'open-editor';
|
|
32
33
|
import { basename, dirname, join, normalize, relative, resolve } from 'pathe';
|
|
33
|
-
import
|
|
34
|
+
import picomatch from 'picomatch';
|
|
34
35
|
import sirv from 'sirv';
|
|
35
36
|
import { type WebSocket, WebSocketServer } from 'ws';
|
|
36
37
|
import { getHeadlessConcurrency } from './concurrency';
|
|
@@ -123,10 +124,24 @@ type BrowserProviderProject = {
|
|
|
123
124
|
provider: BrowserProvider;
|
|
124
125
|
};
|
|
125
126
|
|
|
126
|
-
type BrowserLaunchOptions =
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
type BrowserLaunchOptions = {
|
|
128
|
+
provider: BrowserProvider;
|
|
129
|
+
browser: ProjectContext['normalizedConfig']['browser']['browser'];
|
|
130
|
+
headless: ProjectContext['normalizedConfig']['browser']['headless'];
|
|
131
|
+
port: ProjectContext['normalizedConfig']['browser']['port'];
|
|
132
|
+
strictPort: ProjectContext['normalizedConfig']['browser']['strictPort'];
|
|
133
|
+
providerOptions: Record<string, unknown>;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const getBrowserProviderOptions = (
|
|
137
|
+
project: ProjectContext,
|
|
138
|
+
): Record<string, unknown> => {
|
|
139
|
+
const browserConfig = project.normalizedConfig.browser as {
|
|
140
|
+
providerOptions?: Record<string, unknown>;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return browserConfig.providerOptions ?? {};
|
|
144
|
+
};
|
|
130
145
|
|
|
131
146
|
/** Payload for test file start event */
|
|
132
147
|
type TestFileStartPayload = {
|
|
@@ -157,6 +172,33 @@ type TestFileReadyPayload = ReporterHookArg<'onTestFileReady'>;
|
|
|
157
172
|
type TestSuiteStartPayload = ReporterHookArg<'onTestSuiteStart'>;
|
|
158
173
|
type TestSuiteResultPayload = ReporterHookArg<'onTestSuiteResult'>;
|
|
159
174
|
type TestCaseStartPayload = ReporterHookArg<'onTestCaseStart'>;
|
|
175
|
+
type ReloadTestFileAck = {
|
|
176
|
+
runId: string;
|
|
177
|
+
};
|
|
178
|
+
type HeadedTestFileCompletePayload = TestFileResult & {
|
|
179
|
+
runId?: string;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
type DeferredPromise<T> = {
|
|
183
|
+
promise: Promise<T>;
|
|
184
|
+
resolve: (value: T | PromiseLike<T>) => void;
|
|
185
|
+
reject: (reason?: unknown) => void;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const createDeferredPromise = <T>(): DeferredPromise<T> => {
|
|
189
|
+
let resolve!: DeferredPromise<T>['resolve'];
|
|
190
|
+
let reject!: DeferredPromise<T>['reject'];
|
|
191
|
+
const promise = new Promise<T>((res, rej) => {
|
|
192
|
+
resolve = res;
|
|
193
|
+
reject = rej;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
promise,
|
|
198
|
+
resolve,
|
|
199
|
+
reject,
|
|
200
|
+
};
|
|
201
|
+
};
|
|
160
202
|
|
|
161
203
|
/** RPC methods exposed by the host (server) to the container (client) */
|
|
162
204
|
type HostRpcMethods = {
|
|
@@ -166,7 +208,7 @@ type HostRpcMethods = {
|
|
|
166
208
|
// Test result callbacks from container
|
|
167
209
|
onTestFileStart: (payload: TestFileStartPayload) => Promise<void>;
|
|
168
210
|
onTestCaseResult: (payload: TestResult) => Promise<void>;
|
|
169
|
-
onTestFileComplete: (payload:
|
|
211
|
+
onTestFileComplete: (payload: HeadedTestFileCompletePayload) => Promise<void>;
|
|
170
212
|
onLog: (payload: LogPayload) => Promise<void>;
|
|
171
213
|
onFatal: (payload: FatalPayload) => Promise<void>;
|
|
172
214
|
// Generic dispatch endpoint used by runner RPC requests.
|
|
@@ -178,7 +220,10 @@ type HostRpcMethods = {
|
|
|
178
220
|
/** RPC methods exposed by the container (client) to the host (server) */
|
|
179
221
|
type ContainerRpcMethods = {
|
|
180
222
|
onTestFileUpdate: (testFiles: TestFileInfo[]) => Promise<void>;
|
|
181
|
-
reloadTestFile: (
|
|
223
|
+
reloadTestFile: (
|
|
224
|
+
testFile: string,
|
|
225
|
+
testNamePattern?: string,
|
|
226
|
+
) => Promise<ReloadTestFileAck>;
|
|
182
227
|
};
|
|
183
228
|
|
|
184
229
|
type ContainerRpc = BirpcReturn<ContainerRpcMethods, HostRpcMethods>;
|
|
@@ -196,16 +241,27 @@ class ContainerRpcManager {
|
|
|
196
241
|
private ws: WebSocket | null = null;
|
|
197
242
|
private rpc: ContainerRpc | null = null;
|
|
198
243
|
private methods: HostRpcMethods;
|
|
244
|
+
private onDisconnect?: (error: Error) => void;
|
|
245
|
+
private detachActiveSocketListeners: (() => void) | null = null;
|
|
199
246
|
|
|
200
|
-
constructor(
|
|
247
|
+
constructor(
|
|
248
|
+
wss: WebSocketServer,
|
|
249
|
+
methods: HostRpcMethods,
|
|
250
|
+
onDisconnect?: (error: Error) => void,
|
|
251
|
+
) {
|
|
201
252
|
this.wss = wss;
|
|
202
253
|
this.methods = methods;
|
|
254
|
+
this.onDisconnect = onDisconnect;
|
|
203
255
|
this.setupConnectionHandler();
|
|
204
256
|
}
|
|
205
257
|
|
|
206
258
|
/** Update the RPC methods (used when starting a new test run) */
|
|
207
|
-
updateMethods(
|
|
259
|
+
updateMethods(
|
|
260
|
+
methods: HostRpcMethods,
|
|
261
|
+
onDisconnect?: (error: Error) => void,
|
|
262
|
+
): void {
|
|
208
263
|
this.methods = methods;
|
|
264
|
+
this.onDisconnect = onDisconnect;
|
|
209
265
|
// Re-create birpc with new methods if already connected
|
|
210
266
|
if (this.ws && this.ws.readyState === this.ws.OPEN) {
|
|
211
267
|
this.attachWebSocket(this.ws);
|
|
@@ -223,35 +279,68 @@ class ContainerRpcManager {
|
|
|
223
279
|
}
|
|
224
280
|
|
|
225
281
|
private attachWebSocket(ws: WebSocket): void {
|
|
282
|
+
this.detachActiveSocketListeners?.();
|
|
283
|
+
if (this.rpc && !this.rpc.$closed) {
|
|
284
|
+
this.rpc.$close(new Error('Container RPC transport reattached'));
|
|
285
|
+
}
|
|
226
286
|
this.ws = ws;
|
|
287
|
+
const messageHandlers = new WeakMap<
|
|
288
|
+
(data: any) => void,
|
|
289
|
+
(message: any) => void
|
|
290
|
+
>();
|
|
227
291
|
|
|
228
292
|
this.rpc = createBirpc<ContainerRpcMethods, HostRpcMethods>(this.methods, {
|
|
293
|
+
timeout: -1,
|
|
229
294
|
post: (data) => {
|
|
230
295
|
if (ws.readyState === ws.OPEN) {
|
|
231
296
|
ws.send(JSON.stringify(data));
|
|
232
297
|
}
|
|
233
298
|
},
|
|
234
299
|
on: (fn) => {
|
|
235
|
-
|
|
300
|
+
const handler = (message: any) => {
|
|
236
301
|
try {
|
|
237
302
|
const data = JSON.parse(message.toString());
|
|
238
303
|
fn(data);
|
|
239
304
|
} catch {
|
|
240
305
|
// ignore invalid messages
|
|
241
306
|
}
|
|
242
|
-
}
|
|
307
|
+
};
|
|
308
|
+
messageHandlers.set(fn, handler);
|
|
309
|
+
ws.on('message', handler);
|
|
310
|
+
},
|
|
311
|
+
off: (fn) => {
|
|
312
|
+
const handler = messageHandlers.get(fn);
|
|
313
|
+
if (!handler) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
ws.off('message', handler);
|
|
317
|
+
messageHandlers.delete(fn);
|
|
243
318
|
},
|
|
244
319
|
});
|
|
245
320
|
|
|
246
|
-
|
|
321
|
+
const handleClose = () => {
|
|
247
322
|
// Only clear if this is still the active connection
|
|
248
323
|
// This prevents a race condition when a new connection is established
|
|
249
324
|
// before the old one's close event fires
|
|
250
325
|
if (this.ws === ws) {
|
|
251
326
|
this.ws = null;
|
|
252
|
-
this.rpc = null;
|
|
253
327
|
}
|
|
254
|
-
|
|
328
|
+
this.detachActiveSocketListeners?.();
|
|
329
|
+
this.detachActiveSocketListeners = null;
|
|
330
|
+
if (this.rpc && !this.rpc.$closed) {
|
|
331
|
+
const disconnectError = new Error(
|
|
332
|
+
'Browser UI WebSocket disconnected before reload completed',
|
|
333
|
+
);
|
|
334
|
+
this.rpc.$close(disconnectError);
|
|
335
|
+
this.onDisconnect?.(disconnectError);
|
|
336
|
+
}
|
|
337
|
+
this.rpc = null;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
ws.on('close', handleClose);
|
|
341
|
+
this.detachActiveSocketListeners = () => {
|
|
342
|
+
ws.off('close', handleClose);
|
|
343
|
+
};
|
|
255
344
|
}
|
|
256
345
|
|
|
257
346
|
/** Check if a container is currently connected */
|
|
@@ -278,16 +367,15 @@ class ContainerRpcManager {
|
|
|
278
367
|
async reloadTestFile(
|
|
279
368
|
testFile: string,
|
|
280
369
|
testNamePattern?: string,
|
|
281
|
-
): Promise<
|
|
370
|
+
): Promise<ReloadTestFileAck> {
|
|
282
371
|
logger.debug(
|
|
283
372
|
`[Browser UI] reloadTestFile called, rpc: ${this.rpc ? 'exists' : 'null'}, ws: ${this.ws ? 'exists' : 'null'}`,
|
|
284
373
|
);
|
|
285
374
|
if (!this.rpc) {
|
|
286
|
-
|
|
287
|
-
return;
|
|
375
|
+
throw new Error('Browser UI RPC not available for reloadTestFile');
|
|
288
376
|
}
|
|
289
377
|
logger.debug(`[Browser UI] Calling reloadTestFile: ${testFile}`);
|
|
290
|
-
|
|
378
|
+
return this.rpc.reloadTestFile(testFile, testNamePattern);
|
|
291
379
|
}
|
|
292
380
|
}
|
|
293
381
|
|
|
@@ -299,6 +387,7 @@ type BrowserRuntime = {
|
|
|
299
387
|
rsbuildInstance: RsbuildInstance;
|
|
300
388
|
devServer: RsbuildDevServer;
|
|
301
389
|
browser: BrowserProviderBrowser;
|
|
390
|
+
browserLaunchOptions: BrowserLaunchOptions;
|
|
302
391
|
port: number;
|
|
303
392
|
wsPort: number;
|
|
304
393
|
manifestPath: string;
|
|
@@ -724,6 +813,7 @@ const getBrowserLaunchOptions = (
|
|
|
724
813
|
headless: project.normalizedConfig.browser.headless,
|
|
725
814
|
port: project.normalizedConfig.browser.port,
|
|
726
815
|
strictPort: project.normalizedConfig.browser.strictPort,
|
|
816
|
+
providerOptions: getBrowserProviderOptions(project),
|
|
727
817
|
});
|
|
728
818
|
|
|
729
819
|
const ensureConsistentBrowserLaunchOptions = (
|
|
@@ -743,11 +833,12 @@ const ensureConsistentBrowserLaunchOptions = (
|
|
|
743
833
|
options.browser !== firstOptions.browser ||
|
|
744
834
|
options.headless !== firstOptions.headless ||
|
|
745
835
|
options.port !== firstOptions.port ||
|
|
746
|
-
options.strictPort !== firstOptions.strictPort
|
|
836
|
+
options.strictPort !== firstOptions.strictPort ||
|
|
837
|
+
!isDeepStrictEqual(options.providerOptions, firstOptions.providerOptions)
|
|
747
838
|
) {
|
|
748
839
|
throw new Error(
|
|
749
840
|
`Browser launch config mismatch between projects "${firstProject.name}" and "${project.name}". ` +
|
|
750
|
-
'All browser-enabled projects in one run must share provider/browser/headless/port/strictPort.',
|
|
841
|
+
'All browser-enabled projects in one run must share provider/browser/headless/port/strictPort/providerOptions.',
|
|
751
842
|
);
|
|
752
843
|
}
|
|
753
844
|
}
|
|
@@ -782,35 +873,33 @@ const resolveProviderForTestPath = ({
|
|
|
782
873
|
const collectProjectEntries = async (
|
|
783
874
|
context: Rstest,
|
|
784
875
|
): Promise<BrowserProjectEntries[]> => {
|
|
785
|
-
const projectEntries: BrowserProjectEntries[] = [];
|
|
786
|
-
|
|
787
876
|
// Only collect entries for browser mode projects
|
|
788
877
|
const browserProjects = getBrowserProjects(context);
|
|
789
878
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
const setup = getSetupFiles(setupFiles, project.rootPath);
|
|
879
|
+
return Promise.all(
|
|
880
|
+
browserProjects.map(async (project) => {
|
|
881
|
+
const {
|
|
882
|
+
normalizedConfig: { include, exclude, includeSource, setupFiles },
|
|
883
|
+
} = project;
|
|
884
|
+
|
|
885
|
+
const tests = await getTestEntries({
|
|
886
|
+
include,
|
|
887
|
+
exclude: exclude.patterns,
|
|
888
|
+
includeSource,
|
|
889
|
+
rootPath: context.rootPath,
|
|
890
|
+
projectRoot: project.rootPath,
|
|
891
|
+
fileFilters: context.fileFilters || [],
|
|
892
|
+
});
|
|
805
893
|
|
|
806
|
-
|
|
807
|
-
project,
|
|
808
|
-
setupFiles: Object.values(setup),
|
|
809
|
-
testFiles: Object.values(tests),
|
|
810
|
-
});
|
|
811
|
-
}
|
|
894
|
+
const setup = getSetupFiles(setupFiles, project.rootPath);
|
|
812
895
|
|
|
813
|
-
|
|
896
|
+
return {
|
|
897
|
+
project,
|
|
898
|
+
setupFiles: Object.values(setup),
|
|
899
|
+
testFiles: Object.values(tests),
|
|
900
|
+
};
|
|
901
|
+
}),
|
|
902
|
+
);
|
|
814
903
|
};
|
|
815
904
|
|
|
816
905
|
const resolveBrowserFile = (relativePath: string): string => {
|
|
@@ -1469,11 +1558,13 @@ const createBrowserRuntime = async ({
|
|
|
1469
1558
|
const runtime = await providerImplementation.launchRuntime({
|
|
1470
1559
|
browserName,
|
|
1471
1560
|
headless: forceHeadless ?? browserLaunchOptions.headless,
|
|
1561
|
+
providerOptions: browserLaunchOptions.providerOptions,
|
|
1472
1562
|
});
|
|
1473
1563
|
return {
|
|
1474
1564
|
rsbuildInstance,
|
|
1475
1565
|
devServer,
|
|
1476
1566
|
browser: runtime.browser,
|
|
1567
|
+
browserLaunchOptions,
|
|
1477
1568
|
port,
|
|
1478
1569
|
wsPort,
|
|
1479
1570
|
manifestPath,
|
|
@@ -1802,7 +1893,7 @@ export const runBrowserController = async (
|
|
|
1802
1893
|
}
|
|
1803
1894
|
}
|
|
1804
1895
|
|
|
1805
|
-
const { browser, port, wsPort, wss } = runtime;
|
|
1896
|
+
const { browser, browserLaunchOptions, port, wsPort, wss } = runtime;
|
|
1806
1897
|
const buildTime = Date.now() - buildStart;
|
|
1807
1898
|
|
|
1808
1899
|
// Collect all test files from project entries with project info
|
|
@@ -2208,6 +2299,7 @@ export const runBrowserController = async (
|
|
|
2208
2299
|
|
|
2209
2300
|
const viewport = viewportByProject.get(file.projectName);
|
|
2210
2301
|
const browserContext = await browser.newContext({
|
|
2302
|
+
providerOptions: browserLaunchOptions.providerOptions,
|
|
2211
2303
|
viewport: viewport ?? null,
|
|
2212
2304
|
});
|
|
2213
2305
|
run.contexts.add(browserContext);
|
|
@@ -2673,6 +2765,7 @@ export const runBrowserController = async (
|
|
|
2673
2765
|
} else {
|
|
2674
2766
|
isNewPage = true;
|
|
2675
2767
|
containerContext = await browser.newContext({
|
|
2768
|
+
providerOptions: browserLaunchOptions.providerOptions,
|
|
2676
2769
|
viewport: null,
|
|
2677
2770
|
});
|
|
2678
2771
|
containerPage = await containerContext.newPage();
|
|
@@ -2706,6 +2799,13 @@ export const runBrowserController = async (
|
|
|
2706
2799
|
|
|
2707
2800
|
const dispatchRouter = createDispatchRouter();
|
|
2708
2801
|
const headedReloadQueue = createHeadedSerialTaskQueue();
|
|
2802
|
+
const pendingHeadedReloads = new Map<
|
|
2803
|
+
string,
|
|
2804
|
+
{
|
|
2805
|
+
runId: string;
|
|
2806
|
+
deferred: DeferredPromise<void>;
|
|
2807
|
+
}
|
|
2808
|
+
>();
|
|
2709
2809
|
let enqueueHeadedReload = async (
|
|
2710
2810
|
_file: TestFileInfo,
|
|
2711
2811
|
_testNamePattern?: string,
|
|
@@ -2713,16 +2813,89 @@ export const runBrowserController = async (
|
|
|
2713
2813
|
throw new Error('Headed reload queue is not initialized');
|
|
2714
2814
|
};
|
|
2715
2815
|
|
|
2816
|
+
const rejectPendingHeadedReload = (
|
|
2817
|
+
testPath: string,
|
|
2818
|
+
error: Error,
|
|
2819
|
+
runId?: string,
|
|
2820
|
+
): void => {
|
|
2821
|
+
const pending = pendingHeadedReloads.get(testPath);
|
|
2822
|
+
if (!pending) {
|
|
2823
|
+
return;
|
|
2824
|
+
}
|
|
2825
|
+
if (runId && pending.runId !== runId) {
|
|
2826
|
+
return;
|
|
2827
|
+
}
|
|
2828
|
+
pendingHeadedReloads.delete(testPath);
|
|
2829
|
+
pending.deferred.reject(error);
|
|
2830
|
+
};
|
|
2831
|
+
|
|
2832
|
+
const rejectAllPendingHeadedReloads = (error: Error): void => {
|
|
2833
|
+
for (const [testPath, pending] of pendingHeadedReloads) {
|
|
2834
|
+
pendingHeadedReloads.delete(testPath);
|
|
2835
|
+
pending.deferred.reject(error);
|
|
2836
|
+
}
|
|
2837
|
+
};
|
|
2838
|
+
|
|
2839
|
+
const registerPendingHeadedReload = (
|
|
2840
|
+
testPath: string,
|
|
2841
|
+
runId: string,
|
|
2842
|
+
): Promise<void> => {
|
|
2843
|
+
const previousPending = pendingHeadedReloads.get(testPath);
|
|
2844
|
+
if (previousPending) {
|
|
2845
|
+
previousPending.deferred.reject(
|
|
2846
|
+
new Error(
|
|
2847
|
+
`Reload for "${testPath}" was superseded by a newer request.`,
|
|
2848
|
+
),
|
|
2849
|
+
);
|
|
2850
|
+
pendingHeadedReloads.delete(testPath);
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
const deferred = createDeferredPromise<void>();
|
|
2854
|
+
pendingHeadedReloads.set(testPath, {
|
|
2855
|
+
runId,
|
|
2856
|
+
deferred,
|
|
2857
|
+
});
|
|
2858
|
+
|
|
2859
|
+
return deferred.promise;
|
|
2860
|
+
};
|
|
2861
|
+
|
|
2862
|
+
const resolvePendingHeadedReload = (
|
|
2863
|
+
testPath: string,
|
|
2864
|
+
runId?: string,
|
|
2865
|
+
): void => {
|
|
2866
|
+
const pending = pendingHeadedReloads.get(testPath);
|
|
2867
|
+
if (!pending) {
|
|
2868
|
+
return;
|
|
2869
|
+
}
|
|
2870
|
+
if (runId && pending.runId !== runId) {
|
|
2871
|
+
logger.debug(
|
|
2872
|
+
`[Browser UI] Ignoring stale file-complete for ${testPath}. current=${pending.runId}, incoming=${runId}`,
|
|
2873
|
+
);
|
|
2874
|
+
return;
|
|
2875
|
+
}
|
|
2876
|
+
pendingHeadedReloads.delete(testPath);
|
|
2877
|
+
pending.deferred.resolve();
|
|
2878
|
+
};
|
|
2879
|
+
|
|
2716
2880
|
const reloadTestFileWithTimeout = async (
|
|
2717
2881
|
file: TestFileInfo,
|
|
2718
2882
|
testNamePattern?: string,
|
|
2719
2883
|
): Promise<void> => {
|
|
2720
2884
|
const timeoutMs = getHeadedPerFileTimeoutMs(file);
|
|
2721
2885
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
2886
|
+
let reloadAck: ReloadTestFileAck | undefined;
|
|
2722
2887
|
|
|
2723
2888
|
try {
|
|
2889
|
+
reloadAck = await rpcManager.reloadTestFile(
|
|
2890
|
+
file.testPath,
|
|
2891
|
+
testNamePattern,
|
|
2892
|
+
);
|
|
2893
|
+
const completionPromise = registerPendingHeadedReload(
|
|
2894
|
+
file.testPath,
|
|
2895
|
+
reloadAck.runId,
|
|
2896
|
+
);
|
|
2724
2897
|
await Promise.race([
|
|
2725
|
-
|
|
2898
|
+
completionPromise,
|
|
2726
2899
|
new Promise<never>((_, reject) => {
|
|
2727
2900
|
timeoutId = setTimeout(() => {
|
|
2728
2901
|
reject(
|
|
@@ -2733,6 +2906,15 @@ export const runBrowserController = async (
|
|
|
2733
2906
|
}, timeoutMs);
|
|
2734
2907
|
}),
|
|
2735
2908
|
]);
|
|
2909
|
+
} catch (error) {
|
|
2910
|
+
if (reloadAck?.runId) {
|
|
2911
|
+
rejectPendingHeadedReload(
|
|
2912
|
+
file.testPath,
|
|
2913
|
+
toError(error),
|
|
2914
|
+
reloadAck.runId,
|
|
2915
|
+
);
|
|
2916
|
+
}
|
|
2917
|
+
throw error;
|
|
2736
2918
|
} finally {
|
|
2737
2919
|
if (timeoutId) {
|
|
2738
2920
|
clearTimeout(timeoutId);
|
|
@@ -2765,13 +2947,26 @@ export const runBrowserController = async (
|
|
|
2765
2947
|
async onTestCaseResult(payload: TestResult) {
|
|
2766
2948
|
await handleTestCaseResult(payload);
|
|
2767
2949
|
},
|
|
2768
|
-
async onTestFileComplete(payload:
|
|
2769
|
-
|
|
2950
|
+
async onTestFileComplete(payload: HeadedTestFileCompletePayload) {
|
|
2951
|
+
try {
|
|
2952
|
+
await handleTestFileComplete(payload);
|
|
2953
|
+
resolvePendingHeadedReload(payload.testPath, payload.runId);
|
|
2954
|
+
} catch (error) {
|
|
2955
|
+
rejectPendingHeadedReload(
|
|
2956
|
+
payload.testPath,
|
|
2957
|
+
toError(error),
|
|
2958
|
+
payload.runId,
|
|
2959
|
+
);
|
|
2960
|
+
throw error;
|
|
2961
|
+
}
|
|
2770
2962
|
},
|
|
2771
2963
|
async onLog(payload: LogPayload) {
|
|
2772
2964
|
await handleLog(payload);
|
|
2773
2965
|
},
|
|
2774
2966
|
async onFatal(payload: FatalPayload) {
|
|
2967
|
+
const error = new Error(payload.message);
|
|
2968
|
+
error.stack = payload.stack;
|
|
2969
|
+
rejectAllPendingHeadedReloads(error);
|
|
2775
2970
|
await handleFatal(payload);
|
|
2776
2971
|
},
|
|
2777
2972
|
async dispatch(request: BrowserDispatchRequest) {
|
|
@@ -2786,14 +2981,18 @@ export const runBrowserController = async (
|
|
|
2786
2981
|
if (isWatchMode && runtime.rpcManager) {
|
|
2787
2982
|
rpcManager = runtime.rpcManager;
|
|
2788
2983
|
// Update methods with new test state (caseResults, completedTests, etc.)
|
|
2789
|
-
rpcManager.updateMethods(createRpcMethods());
|
|
2984
|
+
rpcManager.updateMethods(createRpcMethods(), rejectAllPendingHeadedReloads);
|
|
2790
2985
|
// Reattach if we have an existing WebSocket
|
|
2791
2986
|
const existingWs = rpcManager.currentWebSocket;
|
|
2792
2987
|
if (existingWs) {
|
|
2793
2988
|
rpcManager.reattach(existingWs);
|
|
2794
2989
|
}
|
|
2795
2990
|
} else {
|
|
2796
|
-
rpcManager = new ContainerRpcManager(
|
|
2991
|
+
rpcManager = new ContainerRpcManager(
|
|
2992
|
+
wss,
|
|
2993
|
+
createRpcMethods(),
|
|
2994
|
+
rejectAllPendingHeadedReloads,
|
|
2995
|
+
);
|
|
2797
2996
|
|
|
2798
2997
|
if (isWatchMode) {
|
|
2799
2998
|
runtime.rpcManager = rpcManager;
|
|
@@ -3066,7 +3265,7 @@ export const listBrowserTests = async (
|
|
|
3066
3265
|
throw error;
|
|
3067
3266
|
}
|
|
3068
3267
|
|
|
3069
|
-
const { browser, port } = runtime;
|
|
3268
|
+
const { browser, browserLaunchOptions, port } = runtime;
|
|
3070
3269
|
|
|
3071
3270
|
// Get browser projects for runtime config
|
|
3072
3271
|
// Normalize projectRoot to posix format for cross-platform compatibility
|
|
@@ -3110,7 +3309,10 @@ export const listBrowserTests = async (
|
|
|
3110
3309
|
});
|
|
3111
3310
|
|
|
3112
3311
|
// Create a headless page to run collection
|
|
3113
|
-
const browserContext = await browser.newContext({
|
|
3312
|
+
const browserContext = await browser.newContext({
|
|
3313
|
+
providerOptions: browserLaunchOptions.providerOptions,
|
|
3314
|
+
viewport: null,
|
|
3315
|
+
});
|
|
3114
3316
|
const page = await browserContext.newPage();
|
|
3115
3317
|
|
|
3116
3318
|
// Expose dispatch function for browser client to send messages
|
package/src/index.ts
CHANGED
package/src/providers/index.ts
CHANGED
|
@@ -6,6 +6,14 @@ import { playwrightProviderImplementation } from './playwright';
|
|
|
6
6
|
*
|
|
7
7
|
* When adding a new built-in provider, implement `BrowserProviderImplementation`
|
|
8
8
|
* and register it in `providerImplementations` below.
|
|
9
|
+
*
|
|
10
|
+
* Provider-agnostic policy:
|
|
11
|
+
* - keep shared contracts and behavior provider-neutral
|
|
12
|
+
* - `browser.providerOptions` stays opaque at the framework boundary
|
|
13
|
+
* - do not export provider-owned config types from `@rstest/browser`
|
|
14
|
+
* - do not reference optional peer provider types from public declarations
|
|
15
|
+
* - keep provider-specific behavior and config decoding inside provider implementations
|
|
16
|
+
* - prefer direct passthrough to provider APIs over provider-specific translation
|
|
9
17
|
*/
|
|
10
18
|
export type BrowserProvider = 'playwright';
|
|
11
19
|
|
|
@@ -50,6 +58,7 @@ export type BrowserProviderBrowser = {
|
|
|
50
58
|
close: () => Promise<void>;
|
|
51
59
|
newContext: (options: {
|
|
52
60
|
viewport: { width: number; height: number } | null;
|
|
61
|
+
providerOptions?: Record<string, unknown>;
|
|
53
62
|
}) => Promise<BrowserProviderContext>;
|
|
54
63
|
};
|
|
55
64
|
|
|
@@ -62,6 +71,7 @@ export type BrowserProviderRuntime = {
|
|
|
62
71
|
export type LaunchBrowserInput = {
|
|
63
72
|
browserName: 'chromium' | 'firefox' | 'webkit';
|
|
64
73
|
headless: boolean | undefined;
|
|
74
|
+
providerOptions: Record<string, unknown>;
|
|
65
75
|
};
|
|
66
76
|
|
|
67
77
|
/** Input contract for provider-side browser RPC dispatch. */
|
|
@@ -11,10 +11,12 @@ export const playwrightProviderImplementation: BrowserProviderImplementation = {
|
|
|
11
11
|
async launchRuntime({
|
|
12
12
|
browserName,
|
|
13
13
|
headless,
|
|
14
|
+
providerOptions,
|
|
14
15
|
}): Promise<BrowserProviderRuntime> {
|
|
15
16
|
return launchPlaywrightBrowser({
|
|
16
17
|
browserName,
|
|
17
18
|
headless,
|
|
19
|
+
providerOptions,
|
|
18
20
|
});
|
|
19
21
|
},
|
|
20
22
|
async dispatchRpc({
|
|
@@ -1,32 +1,54 @@
|
|
|
1
|
-
import type { BrowserProviderRuntime } from '../index';
|
|
2
|
-
|
|
3
|
-
type PlaywrightModule = typeof import('playwright');
|
|
4
|
-
type PlaywrightBrowserType = PlaywrightModule['chromium'];
|
|
1
|
+
import type { BrowserProviderContext, BrowserProviderRuntime } from '../index';
|
|
5
2
|
|
|
6
3
|
export async function launchPlaywrightBrowser({
|
|
7
4
|
browserName,
|
|
8
5
|
headless,
|
|
6
|
+
providerOptions,
|
|
9
7
|
}: {
|
|
10
8
|
browserName: 'chromium' | 'firefox' | 'webkit';
|
|
11
9
|
headless: boolean | undefined;
|
|
10
|
+
providerOptions: Record<string, unknown>;
|
|
12
11
|
}): Promise<BrowserProviderRuntime> {
|
|
13
12
|
const playwright = await import('playwright');
|
|
14
|
-
const browserType = playwright[browserName]
|
|
13
|
+
const browserType = playwright[browserName];
|
|
14
|
+
const launchOptions = providerOptions.launch as
|
|
15
|
+
| Record<string, unknown>
|
|
16
|
+
| undefined;
|
|
17
|
+
const launchArgs = Array.isArray(launchOptions?.args)
|
|
18
|
+
? launchOptions.args
|
|
19
|
+
: browserName === 'chromium'
|
|
20
|
+
? [
|
|
21
|
+
'--disable-popup-blocking',
|
|
22
|
+
'--no-first-run',
|
|
23
|
+
'--no-default-browser-check',
|
|
24
|
+
]
|
|
25
|
+
: undefined;
|
|
15
26
|
|
|
16
27
|
const browser = await browserType.launch({
|
|
28
|
+
...launchOptions,
|
|
17
29
|
headless,
|
|
18
|
-
|
|
19
|
-
args:
|
|
20
|
-
browserName === 'chromium'
|
|
21
|
-
? [
|
|
22
|
-
'--disable-popup-blocking',
|
|
23
|
-
'--no-first-run',
|
|
24
|
-
'--no-default-browser-check',
|
|
25
|
-
]
|
|
26
|
-
: undefined,
|
|
30
|
+
args: launchArgs,
|
|
27
31
|
});
|
|
28
32
|
|
|
33
|
+
const wrappedBrowser: BrowserProviderRuntime['browser'] = {
|
|
34
|
+
close: async () => browser.close(),
|
|
35
|
+
newContext: async ({
|
|
36
|
+
providerOptions: contextProviderOptions,
|
|
37
|
+
viewport,
|
|
38
|
+
}) => {
|
|
39
|
+
const contextOptions = contextProviderOptions?.context as
|
|
40
|
+
| Record<string, unknown>
|
|
41
|
+
| undefined;
|
|
42
|
+
const context = await browser.newContext({
|
|
43
|
+
...contextOptions,
|
|
44
|
+
viewport,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return context as unknown as BrowserProviderContext;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
29
51
|
return {
|
|
30
|
-
browser:
|
|
52
|
+
browser: wrappedBrowser,
|
|
31
53
|
};
|
|
32
54
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
/*! LICENSE: 101.82cdbbe145.js.LICENSE.txt */
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
/*! LICENSE: lib-react.ce60b6aea5.js.LICENSE.txt */
|