@rstest/browser 0.10.3 → 0.10.5

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.
Files changed (64) hide show
  1. package/dist/626.js +2 -1
  2. package/dist/browser-container/container-static/css/index.0521ff95b1.css +1 -0
  3. package/dist/browser-container/container-static/js/{27.099feaf1da.js → 854.c59f71483d.js} +5728 -5756
  4. package/dist/browser-container/container-static/js/854.c59f71483d.js.LICENSE.txt +1 -0
  5. package/dist/browser-container/container-static/js/{index.be8c8d2626.js → index.fedc31dff3.js} +109 -136
  6. package/dist/browser-container/container-static/js/{lib-react.5ae9b90ee5.js → lib-react.62b27a21db.js} +21 -21
  7. package/dist/browser-container/container-static/js/lib-react.62b27a21db.js.LICENSE.txt +1 -0
  8. package/dist/browser-container/index.html +1 -1
  9. package/dist/browser.js +4 -4
  10. package/dist/browserRpcRegistry.d.ts +1 -2
  11. package/dist/client/api.d.ts +1 -1
  12. package/dist/client/dispatchTransport.d.ts +18 -1
  13. package/dist/client/locator.d.ts +2 -3
  14. package/dist/concurrency.d.ts +5 -3
  15. package/dist/configValidation.d.ts +2 -2
  16. package/dist/dispatchCapabilities.d.ts +3 -3
  17. package/dist/dispatchRouter.d.ts +2 -3
  18. package/dist/headedSerialTaskQueue.d.ts +5 -5
  19. package/dist/headlessLatestRerunScheduler.d.ts +2 -3
  20. package/dist/headlessTransport.d.ts +2 -3
  21. package/dist/hostController.d.ts +12 -9
  22. package/dist/index.d.ts +6 -6
  23. package/dist/index.js +169 -216
  24. package/dist/protocol.d.ts +78 -55
  25. package/dist/providers/index.d.ts +18 -20
  26. package/dist/providers/playwright/dispatchBrowserRpc.d.ts +5 -3
  27. package/dist/providers/playwright/expectUtils.d.ts +2 -3
  28. package/dist/providers/playwright/implementation.d.ts +1 -1
  29. package/dist/providers/playwright/runtime.d.ts +1 -1
  30. package/dist/rpcProtocol.d.ts +36 -29
  31. package/dist/runSession.d.ts +3 -5
  32. package/dist/sessionRegistry.d.ts +2 -3
  33. package/dist/sourceMap/sourceMapLoader.d.ts +2 -2
  34. package/dist/viewportPresets.d.ts +7 -3
  35. package/dist/watchCliShortcuts.d.ts +1 -1
  36. package/dist/watchRerunPlanner.d.ts +2 -2
  37. package/package.json +7 -9
  38. package/src/AGENTS.md +7 -1
  39. package/src/browserRpcRegistry.ts +2 -2
  40. package/src/client/dispatchTransport.ts +63 -1
  41. package/src/client/entry.ts +25 -58
  42. package/src/client/public.ts +3 -3
  43. package/src/client/snapshot.ts +24 -22
  44. package/src/client/sourceMapSupport.ts +1 -1
  45. package/src/concurrency.ts +7 -21
  46. package/src/configValidation.ts +2 -2
  47. package/src/dispatchCapabilities.ts +102 -83
  48. package/src/env.d.ts +6 -2
  49. package/src/headedSerialTaskQueue.ts +5 -1
  50. package/src/hostController.ts +85 -43
  51. package/src/index.ts +22 -11
  52. package/src/protocol.ts +66 -22
  53. package/src/providers/index.ts +8 -1
  54. package/src/rpcProtocol.ts +34 -21
  55. package/src/viewportPresets.ts +5 -0
  56. package/src/watchCliShortcuts.ts +2 -4
  57. package/dist/browser-container/container-static/css/index.5c72297783.css +0 -1
  58. package/dist/browser-container/container-static/js/27.099feaf1da.js.LICENSE.txt +0 -1
  59. package/dist/browser-container/container-static/js/lib-react.5ae9b90ee5.js.LICENSE.txt +0 -1
  60. package/dist/client/entry.d.ts +0 -4
  61. package/dist/client/fakeTimersStub.d.ts +0 -25
  62. package/dist/client/public.d.ts +0 -9
  63. package/dist/client/snapshot.d.ts +0 -29
  64. package/dist/client/sourceMapSupport.d.ts +0 -42
@@ -4,10 +4,15 @@ import type {
4
4
  } from '../protocol';
5
5
  import {
6
6
  DISPATCH_MESSAGE_TYPE,
7
+ DISPATCH_NAMESPACE_RUNNER,
7
8
  DISPATCH_RESPONSE_TYPE,
9
+ DISPATCH_RPC_BRIDGE_NAME,
8
10
  DISPATCH_RPC_REQUEST_TYPE,
9
11
  } from '../protocol';
10
12
 
13
+ // Coincidentally equal to the host-side RUNNER_FRAMES_READY_TIMEOUT_MS and the
14
+ // runner's CONFIG_WAIT_TIMEOUT_MS (entry.ts), but a semantically distinct
15
+ // default in a different runtime, so deliberately not shared with them.
11
16
  const DEFAULT_RPC_TIMEOUT_MS = 30_000;
12
17
 
13
18
  export const getRpcTimeout = (): number => {
@@ -37,6 +42,63 @@ export const createRequestId = (prefix: string): string => {
37
42
  return `${prefix}-${Date.now().toString(36)}-${requestIdCounter.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
38
43
  };
39
44
 
45
+ /**
46
+ * Build a runner-lifecycle dispatch request.
47
+ *
48
+ * Lifecycle events (`file-ready`, `suite-start`, `suite-result`, `case-start`)
49
+ * share the dispatch-rpc envelope but are delivered fire-and-forget via
50
+ * {@link sendRunnerLifecycle}, so the request id only needs to be unique — it is
51
+ * produced by the shared {@link createRequestId} factory rather than a bespoke
52
+ * per-runner counter.
53
+ */
54
+ export const createRunnerLifecycleRequest = (
55
+ method: string,
56
+ args: unknown,
57
+ ): BrowserDispatchRequest => ({
58
+ requestId: createRequestId('runner-lifecycle'),
59
+ namespace: DISPATCH_NAMESPACE_RUNNER,
60
+ method,
61
+ args,
62
+ });
63
+
64
+ /**
65
+ * Deliver a runner-lifecycle request fire-and-forget.
66
+ *
67
+ * Unlike {@link dispatchRpc}, this never awaits, unwraps, id-matches, or times
68
+ * out: the host echoes a response but the runner ignores it. Failures surface
69
+ * only through the optional `onError` hook (debug logging at the call site),
70
+ * keeping the hot test loop non-blocking.
71
+ */
72
+ export const sendRunnerLifecycle = (
73
+ request: BrowserDispatchRequest,
74
+ onError?: (error: unknown) => void,
75
+ ): void => {
76
+ if (window.parent === window) {
77
+ const dispatchBridge = window[DISPATCH_RPC_BRIDGE_NAME];
78
+ if (!dispatchBridge) {
79
+ onError?.(
80
+ new Error('Dispatch RPC bridge is not available in top-level runner.'),
81
+ );
82
+ return;
83
+ }
84
+ void Promise.resolve(dispatchBridge(request)).catch((error: unknown) => {
85
+ onError?.(error);
86
+ });
87
+ return;
88
+ }
89
+
90
+ window.parent.postMessage(
91
+ {
92
+ type: DISPATCH_MESSAGE_TYPE,
93
+ payload: {
94
+ type: DISPATCH_RPC_REQUEST_TYPE,
95
+ payload: request,
96
+ },
97
+ },
98
+ '*',
99
+ );
100
+ };
101
+
40
102
  const isDispatchResponse = (
41
103
  value: unknown,
42
104
  ): value is BrowserDispatchResponse => {
@@ -116,7 +178,7 @@ export const dispatchRpc = <T>({
116
178
  staleMessage: string;
117
179
  }): Promise<T> => {
118
180
  if (window.parent === window) {
119
- const dispatchBridge = window.__rstest_dispatch_rpc__;
181
+ const dispatchBridge = window[DISPATCH_RPC_BRIDGE_NAME];
120
182
  if (!dispatchBridge) {
121
183
  throw new Error(
122
184
  'Dispatch RPC bridge is not available in top-level runner.',
@@ -12,25 +12,26 @@ import type {
12
12
  RunnerHooks,
13
13
  RuntimeConfig,
14
14
  WorkerState,
15
- } from '@rstest/core/browser-runtime';
15
+ } from '@rstest/core/internal/browser-runtime';
16
16
  import {
17
17
  createBrowserTaskContext,
18
18
  createRstestRuntime,
19
19
  globalApis,
20
+ RSTEST_ENV_SYMBOL_KEY,
20
21
  setRealTimers,
21
- } from '@rstest/core/browser-runtime';
22
+ unwrapRegex,
23
+ } from '@rstest/core/internal/browser-runtime';
22
24
  import { normalize } from 'pathe';
23
25
  import type {
24
26
  BrowserClientMessage,
25
- BrowserDispatchRequest,
26
27
  BrowserProjectRuntime,
28
+ RunnerLifecycleMethod,
27
29
  } from '../protocol';
30
+ import { DISPATCH_MESSAGE_TYPE, RSTEST_CONFIG_MESSAGE_TYPE } from '../protocol';
28
31
  import {
29
- DISPATCH_MESSAGE_TYPE,
30
- DISPATCH_NAMESPACE_RUNNER,
31
- DISPATCH_RPC_REQUEST_TYPE,
32
- RSTEST_CONFIG_MESSAGE_TYPE,
33
- } from '../protocol';
32
+ createRunnerLifecycleRequest,
33
+ sendRunnerLifecycle,
34
+ } from './dispatchTransport';
34
35
  import { BrowserSnapshotEnvironment } from './snapshot';
35
36
  import {
36
37
  findNewScriptUrl,
@@ -44,14 +45,6 @@ declare global {
44
45
  var __coverage__: Record<string, unknown> | undefined;
45
46
  }
46
47
 
47
- type RunnerLifecycleMethod =
48
- | 'file-ready'
49
- | 'suite-start'
50
- | 'suite-result'
51
- | 'case-start';
52
-
53
- let runnerDispatchRequestId = 0;
54
-
55
48
  /**
56
49
  * Debug logger for browser client.
57
50
  * Only logs when debug mode is enabled (DEBUG=rstest on server side).
@@ -63,27 +56,13 @@ const debugLog = (...args: unknown[]): void => {
63
56
  };
64
57
 
65
58
  type RuntimeEnvStore = Record<string, string | undefined>;
66
- const RSTEST_ENV_SYMBOL = Symbol.for('rstest.env');
59
+ const RSTEST_ENV_SYMBOL = Symbol.for(RSTEST_ENV_SYMBOL_KEY);
67
60
 
68
61
  type GlobalWithRuntimeEnv = typeof globalThis &
69
62
  Record<symbol, unknown> & {
70
63
  global?: typeof globalThis;
71
64
  };
72
65
 
73
- const REGEXP_FLAG_PREFIX = 'RSTEST_REGEXP:';
74
-
75
- const unwrapRegex = (value: string): string | RegExp => {
76
- if (value.startsWith(REGEXP_FLAG_PREFIX)) {
77
- const raw = value.slice(REGEXP_FLAG_PREFIX.length);
78
- const match = raw.match(/^\/(.+)\/([gimuy]*)$/);
79
- if (match) {
80
- const [, pattern, flags] = match;
81
- return new RegExp(pattern!, flags);
82
- }
83
- }
84
- return value;
85
- };
86
-
87
66
  const restoreRuntimeConfig = (
88
67
  config: BrowserProjectRuntime['runtimeConfig'],
89
68
  ): RuntimeConfig => {
@@ -230,42 +209,30 @@ const send = (message: BrowserClientMessage): void => {
230
209
  }
231
210
  // Fallback: direct call if running outside iframe (not typical)
232
211
  // Note: This binding may not exist if not using Playwright
233
- window.__rstest_dispatch__?.(message);
212
+ window[DISPATCH_MESSAGE_TYPE]?.(message);
234
213
  };
235
214
 
236
215
  const dispatchRunnerLifecycle = (
237
216
  method: RunnerLifecycleMethod,
238
217
  payload: unknown,
239
218
  ): void => {
240
- const request: BrowserDispatchRequest = {
241
- requestId: `runner-lifecycle-${++runnerDispatchRequestId}`,
242
- namespace: DISPATCH_NAMESPACE_RUNNER,
243
- method,
244
- args: payload,
245
- };
246
-
247
- if (window.parent === window) {
248
- const dispatchBridge = window.__rstest_dispatch_rpc__;
249
- if (!dispatchBridge) {
250
- debugLog(
251
- '[Runner] Missing dispatch bridge for lifecycle method:',
252
- method,
253
- );
254
- return;
255
- }
256
- void Promise.resolve(dispatchBridge(request)).catch((error: unknown) => {
219
+ sendRunnerLifecycle(
220
+ createRunnerLifecycleRequest(method, payload),
221
+ (error: unknown) => {
257
222
  debugLog('[Runner] Failed to dispatch lifecycle method:', method, error);
258
- });
259
- return;
260
- }
261
-
262
- send({
263
- type: DISPATCH_RPC_REQUEST_TYPE,
264
- payload: request,
265
- });
223
+ },
224
+ );
266
225
  };
267
226
 
268
- /** Timeout for waiting for browser config from container (30 seconds) */
227
+ /**
228
+ * Timeout for waiting for browser config from container (30 seconds).
229
+ *
230
+ * Coincidentally equal to the RPC default (client/dispatchTransport.ts) and the
231
+ * host's RUNNER_FRAMES_READY_TIMEOUT_MS (hostController.ts), but semantically
232
+ * distinct and in a different runtime, so deliberately not shared. Implicit
233
+ * invariant: this must not exceed the host's frames-ready timeout, or the host
234
+ * declares the runner un-ready before it can even receive its config.
235
+ */
269
236
  const CONFIG_WAIT_TIMEOUT_MS = 30_000;
270
237
 
271
238
  /**
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Re-export runtime API from @rstest/core/browser-runtime for browser use.
2
+ * Re-export runtime API from @rstest/core/internal/browser-runtime for browser use.
3
3
  * This file is used as an alias target for '@rstest/core' in browser mode.
4
4
  *
5
- * Uses @rstest/core/browser-runtime which only exports the test APIs
5
+ * Uses @rstest/core/internal/browser-runtime which only exports the test APIs
6
6
  * (describe, it, expect, etc.) without any Node.js dependencies.
7
7
  */
8
8
 
9
9
  // Re-export types from @rstest/core (these are compile-time only)
10
10
  export type { Assertion, Mock } from '@rstest/core';
11
11
  // Re-export all public test APIs
12
- export * from '@rstest/core/browser-runtime';
12
+ export * from '@rstest/core/internal/browser-runtime';
@@ -1,4 +1,4 @@
1
- import type { BrowserDispatchRequest, SnapshotRpcRequest } from '../protocol';
1
+ import type { BrowserDispatchRequest, SnapshotRpcCall } from '../protocol';
2
2
  import { DISPATCH_NAMESPACE_SNAPSHOT } from '../protocol';
3
3
  import {
4
4
  createRequestId,
@@ -11,41 +11,36 @@ const SNAPSHOT_HEADER = '// Rstest Snapshot';
11
11
 
12
12
  const createSnapshotDispatchRequest = (
13
13
  requestId: string,
14
- method: SnapshotRpcRequest['method'],
15
- args: SnapshotRpcRequest['args'],
14
+ call: SnapshotRpcCall,
16
15
  ): BrowserDispatchRequest => {
17
16
  // Snapshot is just one namespace on the shared dispatch RPC channel.
18
17
  // Keep this mapping explicit so new runner-side RPC clients can mirror it.
19
18
  return {
20
19
  requestId,
21
20
  namespace: DISPATCH_NAMESPACE_SNAPSHOT,
22
- method,
23
- args,
21
+ method: call.method,
22
+ args: call.args,
24
23
  };
25
24
  };
26
25
 
27
26
  /**
28
27
  * Send a snapshot RPC request to the container (parent window).
29
28
  * The container will forward it to the host via WebSocket RPC.
29
+ *
30
+ * `call` is a {@link SnapshotRpcCall}, so `method` and `args` are validated as a
31
+ * pair — a mismatched argument shape fails to compile at the call site.
30
32
  */
31
- const sendRpcRequest = <T>(
32
- method: SnapshotRpcRequest['method'],
33
- args: SnapshotRpcRequest['args'],
34
- ): Promise<T> => {
33
+ const sendRpcRequest = <T>(call: SnapshotRpcCall): Promise<T> => {
35
34
  const requestId = createRequestId('snapshot-rpc');
36
35
  const rpcTimeout = getRpcTimeout();
37
- const dispatchRequest = createSnapshotDispatchRequest(
38
- requestId,
39
- method,
40
- args,
41
- );
36
+ const dispatchRequest = createSnapshotDispatchRequest(requestId, call);
42
37
 
43
38
  return dispatchRpc<T>({
44
39
  requestId,
45
40
  request: dispatchRequest,
46
41
  timeoutMs: rpcTimeout,
47
42
  staleMessage: 'Stale snapshot RPC request ignored.',
48
- timeoutMessage: `Snapshot RPC timeout after ${rpcTimeout / 1000}s: ${method}`,
43
+ timeoutMessage: `Snapshot RPC timeout after ${rpcTimeout / 1000}s: ${call.method}`,
49
44
  });
50
45
  };
51
46
 
@@ -67,8 +62,9 @@ export class BrowserSnapshotEnvironment {
67
62
  }
68
63
 
69
64
  async resolvePath(filepath: string): Promise<string> {
70
- return sendRpcRequest<string>('resolveSnapshotPath', {
71
- testPath: filepath,
65
+ return sendRpcRequest<string>({
66
+ method: 'resolveSnapshotPath',
67
+ args: { testPath: filepath },
72
68
  });
73
69
  }
74
70
 
@@ -77,18 +73,24 @@ export class BrowserSnapshotEnvironment {
77
73
  }
78
74
 
79
75
  async saveSnapshotFile(filepath: string, snapshot: string): Promise<void> {
80
- await sendRpcRequest<void>('saveSnapshotFile', {
81
- filepath,
82
- content: snapshot,
76
+ await sendRpcRequest<void>({
77
+ method: 'saveSnapshotFile',
78
+ args: { filepath, content: snapshot },
83
79
  });
84
80
  }
85
81
 
86
82
  async readSnapshotFile(filepath: string): Promise<string | null> {
87
- return sendRpcRequest<string | null>('readSnapshotFile', { filepath });
83
+ return sendRpcRequest<string | null>({
84
+ method: 'readSnapshotFile',
85
+ args: { filepath },
86
+ });
88
87
  }
89
88
 
90
89
  async removeSnapshotFile(filepath: string): Promise<void> {
91
- await sendRpcRequest<void>('removeSnapshotFile', { filepath });
90
+ await sendRpcRequest<void>({
91
+ method: 'removeSnapshotFile',
92
+ args: { filepath },
93
+ });
92
94
  }
93
95
 
94
96
  /**
@@ -102,7 +102,7 @@ export const preloadTestFileSourceMap = async (
102
102
  * Preload source map for the runner.js file.
103
103
  *
104
104
  * This is essential for inline snapshot support because the snapshot code
105
- * runs in runner.js (which contains @rstest/core/browser-runtime).
105
+ * runs in runner.js (which contains @rstest/core/internal/browser-runtime).
106
106
  * Without this, stack traces from inline snapshots cannot be mapped back
107
107
  * to the original source files.
108
108
  */
@@ -1,5 +1,9 @@
1
- import os from 'node:os';
2
- import type { Rstest } from '@rstest/core/browser';
1
+ import { getNumCpus, parseWorkers } from '@rstest/core/internal/browser';
2
+ import type { Rstest } from '@rstest/core/internal/browser';
3
+
4
+ // Re-export the shared worker primitives so existing consumers (and unit tests
5
+ // importing from `../src/concurrency`) keep a stable import surface.
6
+ export { getNumCpus, parseWorkers };
3
7
 
4
8
  // Shared headless concurrency policy.
5
9
  // Keep this in one place so executors reuse the same worker semantics.
@@ -13,27 +17,9 @@ type HeadlessConcurrencyContext = Pick<Rstest, 'command'> & {
13
17
  };
14
18
  };
15
19
 
16
- const getNumCpus = (): number => {
17
- return os.availableParallelism?.() ?? os.cpus().length;
18
- };
19
-
20
- export const parseWorkers = (
21
- maxWorkers: string | number,
22
- numCpus = getNumCpus(),
23
- ): number => {
24
- const parsed = Number.parseInt(maxWorkers.toString(), 10);
25
-
26
- if (typeof maxWorkers === 'string' && maxWorkers.trim().endsWith('%')) {
27
- const workers = Math.floor((parsed / 100) * numCpus);
28
- return Math.max(workers, 1);
29
- }
30
-
31
- return parsed > 0 ? parsed : 1;
32
- };
33
-
34
20
  export const resolveDefaultHeadlessWorkers = (
35
21
  command: HeadlessConcurrencyContext['command'],
36
- numCpus = getNumCpus(),
22
+ numCpus: number = getNumCpus(),
37
23
  ): number => {
38
24
  const baseWorkers = Math.max(
39
25
  Math.min(DEFAULT_MAX_HEADLESS_WORKERS, numCpus - 1),
@@ -1,4 +1,4 @@
1
- import type { Rstest } from '@rstest/core/browser';
1
+ import type { RstestContext } from '@rstest/core/internal/browser';
2
2
  import { resolveBrowserViewportPreset } from './viewportPresets';
3
3
 
4
4
  const SUPPORTED_PROVIDERS = ['playwright'] as const;
@@ -42,7 +42,7 @@ const validateViewport = (viewport: unknown): void => {
42
42
  );
43
43
  };
44
44
 
45
- export const validateBrowserConfig = (context: Rstest): void => {
45
+ export const validateBrowserConfig = (context: RstestContext): void => {
46
46
  for (const project of context.projects) {
47
47
  const { browser, output } = project.normalizedConfig;
48
48
  if (!browser.enabled) {
@@ -1,9 +1,16 @@
1
- import type { Reporter } from '@rstest/core/browser';
1
+ import type { Reporter } from '@rstest/core/internal/browser';
2
2
  import { HostDispatchRouter } from './dispatchRouter';
3
+ import {
4
+ DISPATCH_NAMESPACE_RUNNER,
5
+ DISPATCH_NAMESPACE_SNAPSHOT,
6
+ } from './protocol';
3
7
  import type {
4
8
  BrowserClientMessage,
5
9
  BrowserDispatchHandler,
6
10
  BrowserDispatchRequest,
11
+ RunnerDispatchMethod,
12
+ SnapshotRpcMethod,
13
+ SnapshotRpcMethodArgs,
7
14
  SnapshotRpcRequest,
8
15
  } from './protocol';
9
16
 
@@ -52,37 +59,50 @@ type CreateHostDispatchRouterOptions = {
52
59
  onDuplicateNamespace?: (namespace: string) => void;
53
60
  };
54
61
 
62
+ /**
63
+ * Builds a typed {@link SnapshotRpcRequest} from the untrusted wire envelope,
64
+ * one builder per method. Keyed by {@link SnapshotRpcMethod}, so adding or
65
+ * renaming a method in the union forces a matching entry here — a stale name
66
+ * becomes a compile error instead of silently falling through to `null`. The
67
+ * `args` casts are the unavoidable trust boundary for inbound wire data.
68
+ */
69
+ const snapshotRequestBuilders: {
70
+ [M in SnapshotRpcMethod]: (
71
+ id: string,
72
+ args: BrowserDispatchRequest['args'],
73
+ ) => Extract<SnapshotRpcRequest, { method: M }>;
74
+ } = {
75
+ resolveSnapshotPath: (id, args) => ({
76
+ id,
77
+ method: 'resolveSnapshotPath',
78
+ args: args as SnapshotRpcMethodArgs['resolveSnapshotPath'],
79
+ }),
80
+ readSnapshotFile: (id, args) => ({
81
+ id,
82
+ method: 'readSnapshotFile',
83
+ args: args as SnapshotRpcMethodArgs['readSnapshotFile'],
84
+ }),
85
+ saveSnapshotFile: (id, args) => ({
86
+ id,
87
+ method: 'saveSnapshotFile',
88
+ args: args as SnapshotRpcMethodArgs['saveSnapshotFile'],
89
+ }),
90
+ removeSnapshotFile: (id, args) => ({
91
+ id,
92
+ method: 'removeSnapshotFile',
93
+ args: args as SnapshotRpcMethodArgs['removeSnapshotFile'],
94
+ }),
95
+ };
96
+
55
97
  const toSnapshotRpcRequest = (
56
98
  request: BrowserDispatchRequest,
57
99
  ): SnapshotRpcRequest | null => {
58
- switch (request.method) {
59
- case 'resolveSnapshotPath':
60
- return {
61
- id: request.requestId,
62
- method: 'resolveSnapshotPath',
63
- args: request.args as { testPath: string },
64
- };
65
- case 'readSnapshotFile':
66
- return {
67
- id: request.requestId,
68
- method: 'readSnapshotFile',
69
- args: request.args as { filepath: string },
70
- };
71
- case 'saveSnapshotFile':
72
- return {
73
- id: request.requestId,
74
- method: 'saveSnapshotFile',
75
- args: request.args as { filepath: string; content: string },
76
- };
77
- case 'removeSnapshotFile':
78
- return {
79
- id: request.requestId,
80
- method: 'removeSnapshotFile',
81
- args: request.args as { filepath: string },
82
- };
83
- default:
84
- return null;
85
- }
100
+ const builder = snapshotRequestBuilders[
101
+ request.method as SnapshotRpcMethod
102
+ ] as
103
+ | ((id: string, args: BrowserDispatchRequest['args']) => SnapshotRpcRequest)
104
+ | undefined;
105
+ return builder ? builder(request.requestId, request.args) : null;
86
106
  };
87
107
 
88
108
  export const createHostDispatchRouter = ({
@@ -94,61 +114,60 @@ export const createHostDispatchRouter = ({
94
114
  }: CreateHostDispatchRouterOptions): HostDispatchRouter => {
95
115
  const router = new HostDispatchRouter(routerOptions);
96
116
 
97
- router.register('runner', async (request: BrowserDispatchRequest) => {
98
- switch (request.method) {
99
- case 'file-start':
100
- await runnerCallbacks.onTestFileStart(
101
- request.args as RunnerPayload<'file-start'>,
102
- );
103
- break;
104
- case 'file-ready':
105
- await runnerCallbacks.onTestFileReady(
106
- request.args as RunnerDispatchFileReadyPayload,
107
- );
108
- break;
109
- case 'suite-start':
110
- await runnerCallbacks.onTestSuiteStart(
111
- request.args as RunnerDispatchSuiteStartPayload,
112
- );
113
- break;
114
- case 'suite-result':
115
- await runnerCallbacks.onTestSuiteResult(
116
- request.args as RunnerDispatchSuiteResultPayload,
117
- );
118
- break;
119
- case 'case-start':
120
- await runnerCallbacks.onTestCaseStart(
121
- request.args as RunnerDispatchCaseStartPayload,
122
- );
123
- break;
124
- case 'case-result':
125
- await runnerCallbacks.onTestCaseResult(
126
- request.args as RunnerPayload<'case-result'>,
127
- );
128
- break;
129
- case 'file-complete':
130
- await runnerCallbacks.onTestFileComplete(
131
- request.args as RunnerPayload<'file-complete'>,
132
- );
133
- break;
134
- case 'log':
135
- await runnerCallbacks.onLog(request.args as RunnerPayload<'log'>);
136
- break;
137
- case 'fatal':
138
- await runnerCallbacks.onFatal(request.args as RunnerPayload<'fatal'>);
139
- break;
140
- default:
141
- break;
142
- }
143
- });
117
+ // Keyed by RunnerDispatchMethod so adding a runner method to that union forces
118
+ // a handler entry here (a missing key is a compile error) — the previous
119
+ // `switch` with `default: break` silently dropped unhandled methods. The
120
+ // `args` casts are checked against each callback's parameter type, so a wrong
121
+ // payload type also fails to compile.
122
+ const runnerMethodHandlers: {
123
+ [M in RunnerDispatchMethod]: (
124
+ args: BrowserDispatchRequest['args'],
125
+ ) => Promise<void>;
126
+ } = {
127
+ 'file-start': (args) =>
128
+ runnerCallbacks.onTestFileStart(args as RunnerPayload<'file-start'>),
129
+ 'file-ready': (args) =>
130
+ runnerCallbacks.onTestFileReady(args as RunnerDispatchFileReadyPayload),
131
+ 'suite-start': (args) =>
132
+ runnerCallbacks.onTestSuiteStart(args as RunnerDispatchSuiteStartPayload),
133
+ 'suite-result': (args) =>
134
+ runnerCallbacks.onTestSuiteResult(
135
+ args as RunnerDispatchSuiteResultPayload,
136
+ ),
137
+ 'case-start': (args) =>
138
+ runnerCallbacks.onTestCaseStart(args as RunnerDispatchCaseStartPayload),
139
+ 'case-result': (args) =>
140
+ runnerCallbacks.onTestCaseResult(args as RunnerPayload<'case-result'>),
141
+ 'file-complete': (args) =>
142
+ runnerCallbacks.onTestFileComplete(
143
+ args as RunnerPayload<'file-complete'>,
144
+ ),
145
+ log: (args) => runnerCallbacks.onLog(args as RunnerPayload<'log'>),
146
+ fatal: (args) => runnerCallbacks.onFatal(args as RunnerPayload<'fatal'>),
147
+ };
144
148
 
145
- router.register('snapshot', async (request: BrowserDispatchRequest) => {
146
- const snapshotRequest = toSnapshotRpcRequest(request);
147
- if (!snapshotRequest) {
148
- return undefined;
149
- }
150
- return runSnapshotRpc(snapshotRequest);
151
- });
149
+ router.register(
150
+ DISPATCH_NAMESPACE_RUNNER,
151
+ async (request: BrowserDispatchRequest) => {
152
+ // `request.method` is untrusted wire data, so the lookup may miss. Unknown
153
+ // methods are ignored for forward-compatibility with newer runners,
154
+ // matching the previous `default: break`.
155
+ await runnerMethodHandlers[request.method as RunnerDispatchMethod]?.(
156
+ request.args,
157
+ );
158
+ },
159
+ );
160
+
161
+ router.register(
162
+ DISPATCH_NAMESPACE_SNAPSHOT,
163
+ async (request: BrowserDispatchRequest) => {
164
+ const snapshotRequest = toSnapshotRpcRequest(request);
165
+ if (!snapshotRequest) {
166
+ return undefined;
167
+ }
168
+ return runSnapshotRpc(snapshotRequest);
169
+ },
170
+ );
152
171
 
153
172
  for (const [namespace, handler] of extensionHandlers ?? []) {
154
173
  if (router.has(namespace)) {
package/src/env.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { DISPATCH_MESSAGE_TYPE, DISPATCH_RPC_BRIDGE_NAME } from './protocol';
1
2
  import type {
2
3
  BrowserClientMessage,
3
4
  BrowserDispatchRequest,
@@ -38,8 +39,11 @@ declare global {
38
39
 
39
40
  interface Window {
40
41
  __RSTEST_BROWSER_OPTIONS__?: BrowserHostConfig;
41
- __rstest_dispatch__?: (message: BrowserClientMessage) => void;
42
- __rstest_dispatch_rpc__?: (
42
+ // Keyed by the sentinel constants so the declared global name and the
43
+ // constant are the same source — renaming a constant moves this key with it,
44
+ // and every `window[CONST]` access site stays in lockstep automatically.
45
+ [DISPATCH_MESSAGE_TYPE]?: (message: BrowserClientMessage) => void;
46
+ [DISPATCH_RPC_BRIDGE_NAME]?: (
43
47
  request: BrowserDispatchRequest,
44
48
  ) => Promise<unknown>;
45
49
  __rstest_container_dispatch__?: (data: unknown) => void;
@@ -1,10 +1,14 @@
1
1
  type HeadedSerialTask = () => Promise<void>;
2
2
 
3
+ type HeadedSerialTaskQueue = {
4
+ enqueue: (task: HeadedSerialTask) => Promise<void>;
5
+ };
6
+
3
7
  /**
4
8
  * Serializes headed browser file execution so only one task runs at a time.
5
9
  * The queue keeps draining even if an earlier task rejects.
6
10
  */
7
- export const createHeadedSerialTaskQueue = () => {
11
+ export const createHeadedSerialTaskQueue = (): HeadedSerialTaskQueue => {
8
12
  let queue: Promise<void> = Promise.resolve();
9
13
 
10
14
  const enqueue = (task: HeadedSerialTask): Promise<void> => {