@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.
@@ -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 * as picomatch from 'picomatch';
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 = Pick<
127
- ProjectContext['normalizedConfig']['browser'],
128
- 'provider' | 'browser' | 'headless' | 'port' | 'strictPort'
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: TestFileResult) => Promise<void>;
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: (testFile: string, testNamePattern?: string) => Promise<void>;
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(wss: WebSocketServer, methods: HostRpcMethods) {
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(methods: HostRpcMethods): void {
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
- ws.on('message', (message) => {
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
- ws.on('close', () => {
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<void> {
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
- logger.debug('[Browser UI] RPC not available, skipping reloadTestFile');
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
- await this.rpc.reloadTestFile(testFile, testNamePattern);
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
- for (const project of browserProjects) {
791
- const {
792
- normalizedConfig: { include, exclude, includeSource, setupFiles },
793
- } = project;
794
-
795
- const tests = await getTestEntries({
796
- include,
797
- exclude: exclude.patterns,
798
- includeSource,
799
- rootPath: context.rootPath,
800
- projectRoot: project.rootPath,
801
- fileFilters: context.fileFilters || [],
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
- projectEntries.push({
807
- project,
808
- setupFiles: Object.values(setup),
809
- testFiles: Object.values(tests),
810
- });
811
- }
894
+ const setup = getSetupFiles(setupFiles, project.rootPath);
812
895
 
813
- return projectEntries;
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
- rpcManager.reloadTestFile(file.testPath, testNamePattern),
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: TestFileResult) {
2769
- await handleTestFileComplete(payload);
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(wss, createRpcMethods());
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({ viewport: null });
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
@@ -10,7 +10,6 @@ import {
10
10
  } from './hostController';
11
11
 
12
12
  export { validateBrowserConfig } from './configValidation';
13
-
14
13
  export {
15
14
  BROWSER_VIEWPORT_PRESET_DIMENSIONS,
16
15
  BROWSER_VIEWPORT_PRESET_IDS,
@@ -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] as PlaywrightBrowserType;
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
- // Chromium-specific args (ignored by other browsers)
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: browser as unknown as BrowserProviderRuntime['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 */