@rstest/browser 0.11.5 → 0.11.6

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.
@@ -0,0 +1,566 @@
1
+ import type {
2
+ RstestContext,
3
+ TestFileResult,
4
+ } from '@rstest/core/internal/browser';
5
+ import { color, logger } from '@rstest/core/internal/browser';
6
+ import { normalize } from 'pathe';
7
+ import {
8
+ type BrowserRuntime,
9
+ drainPendingAffectedTestFiles,
10
+ mapViewportByProject,
11
+ serializeForInlineScript,
12
+ } from './browserRsbuild';
13
+ import { getHeadlessConcurrency } from './concurrency';
14
+ import type { HostDispatchRouterOptions } from './dispatchCapabilities';
15
+ import type { HostDispatchRouter } from './dispatchRouter';
16
+ import { attachHeadlessRunnerTransport } from './headlessTransport';
17
+ import {
18
+ createDeferredPromise,
19
+ getFileTaskId,
20
+ type FatalPayload,
21
+ toError,
22
+ } from './hostPayloads';
23
+ import type {
24
+ BrowserClientMessage,
25
+ BrowserHostConfig,
26
+ BrowserProjectRuntime,
27
+ TestFileInfo,
28
+ } from './protocol';
29
+ import { DISPATCH_NAMESPACE_RUNNER } from './protocol';
30
+ import type {
31
+ BrowserProviderBrowser,
32
+ BrowserProviderContext,
33
+ BrowserProviderPage,
34
+ } from './providers';
35
+ import {
36
+ createRunSession,
37
+ type RunSession,
38
+ RunSessionLifecycle,
39
+ } from './runSession';
40
+ import { RunnerSessionRegistry } from './sessionRegistry';
41
+ import { collectDeletedTestPaths, planWatchRerun } from './watchRerunPlanner';
42
+ import type {
43
+ BrowserWatchSession,
44
+ DispatchPageResolver,
45
+ SchedulerRunResult,
46
+ } from './schedulerSeam';
47
+ import type { WatchSignals } from './watchSignals';
48
+
49
+ type HeadlessSchedulerContext = Pick<
50
+ RstestContext,
51
+ 'command' | 'snapshotManager' | 'stateManager' | 'updateReporterResultState'
52
+ > & {
53
+ normalizedConfig: Pick<RstestContext['normalizedConfig'], 'bail' | 'pool'>;
54
+ };
55
+
56
+ type HeadlessSchedulerDeps = {
57
+ context: HeadlessSchedulerContext;
58
+ browser: BrowserProviderBrowser;
59
+ browserLaunchOptions: BrowserRuntime['browserLaunchOptions'];
60
+ projectServers: BrowserRuntime['projectServers'];
61
+ allTestFiles: TestFileInfo[];
62
+ projectRuntimeConfigs: BrowserProjectRuntime[];
63
+ hostOptions: BrowserHostConfig;
64
+ watchState: BrowserRuntime['watchState'];
65
+ isWatchMode: boolean;
66
+ createDispatchRouter: (
67
+ options?: HostDispatchRouterOptions,
68
+ ) => HostDispatchRouter;
69
+ handlers: {
70
+ handleFatal: (payload: FatalPayload) => Promise<void>;
71
+ handleTestFileComplete: (payload: TestFileResult) => Promise<void>;
72
+ };
73
+ watchSignals: Pick<
74
+ WatchSignals,
75
+ 'setDispatchRerun' | 'setInterrupt' | 'signalInvalidation'
76
+ >;
77
+ setDispatchPageResolver: (resolver: DispatchPageResolver) => void;
78
+ createWatchSession: (
79
+ execute: (testPaths: string[]) => Promise<void>,
80
+ ) => BrowserWatchSession;
81
+ collectProjectEntries: () => Promise<
82
+ Parameters<typeof planWatchRerun>[0]['projectEntries']
83
+ >;
84
+ logWatchReady: () => void;
85
+ destroyRuntime: () => Promise<void>;
86
+ };
87
+
88
+ export const createHeadlessScheduler = async ({
89
+ context,
90
+ browser,
91
+ browserLaunchOptions,
92
+ projectServers,
93
+ allTestFiles,
94
+ projectRuntimeConfigs,
95
+ hostOptions,
96
+ watchState,
97
+ isWatchMode,
98
+ createDispatchRouter,
99
+ handlers: { handleFatal, handleTestFileComplete },
100
+ watchSignals,
101
+ setDispatchPageResolver,
102
+ createWatchSession,
103
+ collectProjectEntries,
104
+ logWatchReady,
105
+ destroyRuntime,
106
+ }: HeadlessSchedulerDeps): Promise<SchedulerRunResult> => {
107
+ // Session-based scheduling path: lifecycle + session index + dispatch routing.
108
+ type ActiveHeadlessRun = RunSession & {
109
+ contexts: Set<BrowserProviderContext>;
110
+ };
111
+
112
+ const viewportByProject = mapViewportByProject(projectRuntimeConfigs);
113
+ const runLifecycle = new RunSessionLifecycle<ActiveHeadlessRun>();
114
+ const sessionRegistry = new RunnerSessionRegistry();
115
+ setDispatchPageResolver((target) => ({
116
+ runnerPage: target?.sessionId
117
+ ? sessionRegistry.getById(target.sessionId)?.page
118
+ : undefined,
119
+ }));
120
+ let dispatchRequestCounter = 0;
121
+
122
+ const nextDispatchRequestId = (namespace: string): string => {
123
+ return `${namespace}-${++dispatchRequestCounter}`;
124
+ };
125
+
126
+ const closeContextSafely = async (
127
+ browserContext: BrowserProviderContext,
128
+ ): Promise<void> => {
129
+ try {
130
+ await browserContext.close();
131
+ } catch {
132
+ // ignore
133
+ }
134
+ };
135
+
136
+ const cancelRun = async (
137
+ run: ActiveHeadlessRun,
138
+ waitForDone = true,
139
+ ): Promise<void> => {
140
+ await runLifecycle.cancel(run, {
141
+ waitForDone,
142
+ onCancel: async (session) => {
143
+ await Promise.all(
144
+ Array.from(session.contexts).map((browserContext) =>
145
+ closeContextSafely(browserContext),
146
+ ),
147
+ );
148
+ },
149
+ });
150
+ };
151
+
152
+ const dispatchRouter = createDispatchRouter({
153
+ isRunTokenStale: (runToken) => runLifecycle.isTokenStale(runToken),
154
+ onStale: (request) => {
155
+ if (request.namespace === DISPATCH_NAMESPACE_RUNNER) {
156
+ logger.debug(
157
+ `[Headless] Dropped stale message "${request.method}" for ${request.target?.testFile ?? 'unknown'}`,
158
+ );
159
+ }
160
+ },
161
+ });
162
+
163
+ const dispatchRunnerMessage = async (
164
+ run: ActiveHeadlessRun,
165
+ file: TestFileInfo,
166
+ sessionId: string,
167
+ message: BrowserClientMessage,
168
+ ): Promise<void> => {
169
+ const response = await dispatchRouter.dispatch({
170
+ requestId: nextDispatchRequestId(DISPATCH_NAMESPACE_RUNNER),
171
+ runToken: run.token,
172
+ namespace: DISPATCH_NAMESPACE_RUNNER,
173
+ method: message.type,
174
+ args: 'payload' in message ? message.payload : undefined,
175
+ target: {
176
+ sessionId,
177
+ testFile: file.testPath,
178
+ projectName: file.projectName,
179
+ },
180
+ });
181
+
182
+ if (response.stale) {
183
+ return;
184
+ }
185
+
186
+ if (response.error) {
187
+ throw new Error(response.error);
188
+ }
189
+ };
190
+
191
+ const runSingleFile = async (
192
+ run: ActiveHeadlessRun,
193
+ file: TestFileInfo,
194
+ ): Promise<void> => {
195
+ if (run.cancelled || runLifecycle.isTokenStale(run.token)) {
196
+ return;
197
+ }
198
+
199
+ const viewport = viewportByProject.get(file.projectName);
200
+ const browserContext = await browser.newContext({
201
+ providerOptions: browserLaunchOptions.providerOptions,
202
+ viewport: viewport ?? null,
203
+ });
204
+ run.contexts.add(browserContext);
205
+
206
+ let page: BrowserProviderPage | null = null;
207
+ let sessionId: string | null = null;
208
+ let settled = false;
209
+ let resolveDone: (() => void) | null = null;
210
+
211
+ const markDone = (): void => {
212
+ if (!settled) {
213
+ settled = true;
214
+ resolveDone?.();
215
+ }
216
+ };
217
+
218
+ const donePromise = new Promise<void>((resolve) => {
219
+ resolveDone = resolve;
220
+ });
221
+
222
+ // Event-driven death detection (vitest-style): a renderer crash or an
223
+ // unexpected page close produces no further messages, so fail the file at
224
+ // once. Per-test/hook timeouts are enforced inside the runner, so the host
225
+ // deliberately keeps no execution-duration watchdog. Our own teardown
226
+ // close is ignored because `settled`/`run.cancelled` are set by then.
227
+ const crashDeferred = createDeferredPromise<string>();
228
+ const onPageDead = (reason: string): void => {
229
+ if (settled || run.cancelled || !runLifecycle.isTokenActive(run.token)) {
230
+ return;
231
+ }
232
+ settled = true;
233
+ crashDeferred.resolve(reason);
234
+ };
235
+
236
+ try {
237
+ page = await browserContext.newPage();
238
+ page.on('crash', () =>
239
+ onPageDead(`Browser page crashed while running ${file.testPath}.`),
240
+ );
241
+ page.on('close', () =>
242
+ onPageDead(
243
+ `Browser page closed unexpectedly while running ${file.testPath}.`,
244
+ ),
245
+ );
246
+
247
+ const session = sessionRegistry.register({
248
+ testFile: file.testPath,
249
+ projectName: file.projectName,
250
+ runToken: run.token,
251
+ mode: 'headless-page',
252
+ context: browserContext,
253
+ page,
254
+ });
255
+ sessionId = session.id;
256
+
257
+ await attachHeadlessRunnerTransport(page, {
258
+ onDispatchMessage: async (message) => {
259
+ try {
260
+ await dispatchRunnerMessage(run, file, session.id, message);
261
+ if (
262
+ message.type === 'file-complete' ||
263
+ message.type === 'complete'
264
+ ) {
265
+ markDone();
266
+ } else if (message.type === 'fatal') {
267
+ markDone();
268
+ await cancelRun(run, false);
269
+ }
270
+ } catch (error) {
271
+ const formatted = toError(error);
272
+ await handleFatal({
273
+ message: formatted.message,
274
+ stack: formatted.stack,
275
+ });
276
+ markDone();
277
+ await cancelRun(run, false);
278
+ }
279
+ },
280
+ onDispatchRpc: async (request) => {
281
+ return dispatchRouter.dispatch({
282
+ ...request,
283
+ runToken: run.token,
284
+ target: {
285
+ sessionId: session.id,
286
+ testFile: file.testPath,
287
+ projectName: file.projectName,
288
+ ...request.target,
289
+ },
290
+ });
291
+ },
292
+ });
293
+
294
+ const inlineOptions: BrowserHostConfig = {
295
+ ...hostOptions,
296
+ // Read live per page load, not from the construction-time
297
+ // `hostOptions` value: the 'u' shortcut flips
298
+ // `snapshotManager.options` between reruns.
299
+ snapshot: {
300
+ updateSnapshot: context.snapshotManager.options.updateSnapshot,
301
+ },
302
+ testFile: file.testPath,
303
+ runId: `${run.token}:${session.id}`,
304
+ };
305
+ const serializedOptions = serializeForInlineScript(inlineOptions);
306
+ await page.addInitScript(
307
+ `window.__RSTEST_BROWSER_OPTIONS__ = ${serializedOptions};`,
308
+ );
309
+
310
+ const projectServer = projectServers.get(file.projectName);
311
+ if (!projectServer) {
312
+ throw new Error(
313
+ `No browser dev server for project "${file.projectName}" (test file: ${file.testPath}).`,
314
+ );
315
+ }
316
+ await page.goto(`http://localhost:${projectServer.port}/runner.html`, {
317
+ waitUntil: 'load',
318
+ });
319
+
320
+ const state = await Promise.race([
321
+ donePromise.then(() => ({ type: 'done' as const })),
322
+ crashDeferred.promise.then((reason) => ({
323
+ type: 'crash' as const,
324
+ reason,
325
+ })),
326
+ run.cancelSignal.then(() => ({ type: 'cancelled' as const })),
327
+ ]);
328
+
329
+ if (state.type === 'cancelled') {
330
+ return;
331
+ }
332
+
333
+ if (
334
+ state.type === 'crash' &&
335
+ runLifecycle.isTokenActive(run.token) &&
336
+ !run.cancelled
337
+ ) {
338
+ await handleFatal({ message: state.reason });
339
+ await cancelRun(run, false);
340
+ }
341
+ } catch (error) {
342
+ if (runLifecycle.isTokenActive(run.token) && !run.cancelled) {
343
+ const formatted = toError(error);
344
+ await handleFatal({
345
+ message: formatted.message,
346
+ stack: formatted.stack,
347
+ });
348
+ await cancelRun(run, false);
349
+ }
350
+ } finally {
351
+ // A superseded run can hold a renderer that will never answer again:
352
+ // its test file may have been deleted mid-flight, leaving the page
353
+ // waiting on a chunk the bundler will never produce, and closing such a
354
+ // page blocks for as long as the renderer stays wedged. The cycle waits
355
+ // on this teardown, so for an abandoned run it is detached — its
356
+ // results are already discarded, and the replacement cycle must not be
357
+ // held up by a page nobody is reading. A run that ends normally closes
358
+ // in band, which is what keeps the open-context count at the
359
+ // concurrency limit.
360
+ const abandoned = run.cancelled || runLifecycle.isTokenStale(run.token);
361
+ const teardown = async (): Promise<void> => {
362
+ if (page) {
363
+ try {
364
+ await page.close();
365
+ } catch {
366
+ // ignore
367
+ }
368
+ }
369
+ await closeContextSafely(browserContext);
370
+ };
371
+ if (sessionId) {
372
+ sessionRegistry.deleteById(sessionId);
373
+ }
374
+ run.contexts.delete(browserContext);
375
+ if (abandoned) {
376
+ void teardown();
377
+ } else {
378
+ await teardown();
379
+ }
380
+ }
381
+ };
382
+
383
+ // Bailed files never run, so they carry no case results — mirror the node
384
+ // pool's skip result (`runInPool.ts`) so the summary reports them as skipped
385
+ // rather than dropping them silently.
386
+ const makeSkippedFileResult = (file: TestFileInfo): TestFileResult => ({
387
+ testId: getFileTaskId(file.testPath),
388
+ status: 'skip',
389
+ name: '',
390
+ testPath: file.testPath,
391
+ project: file.projectName,
392
+ results: [],
393
+ });
394
+
395
+ const runFilesWithPool = async (files: TestFileInfo[]): Promise<void> => {
396
+ if (files.length === 0) {
397
+ return;
398
+ }
399
+
400
+ const previous = runLifecycle.activeSession;
401
+ if (previous) {
402
+ await cancelRun(previous);
403
+ }
404
+
405
+ const run = runLifecycle.createSession((token) => ({
406
+ ...createRunSession(token),
407
+ contexts: new Set<BrowserProviderContext>(),
408
+ }));
409
+
410
+ const queue = [...files];
411
+ const concurrency = getHeadlessConcurrency(context, queue.length);
412
+ const bail = context.normalizedConfig.bail;
413
+
414
+ const worker = async (): Promise<void> => {
415
+ while (
416
+ queue.length > 0 &&
417
+ !run.cancelled &&
418
+ runLifecycle.isTokenActive(run.token)
419
+ ) {
420
+ // Cross-file bail gate (parity with the node pool's pickup-time skip
421
+ // at `runInPool.ts`): once the cycle-wide failed count reaches `bail`,
422
+ // drain the remaining files as skipped instead of running them. The
423
+ // count is cycle-scoped because core clears `stateManager` ahead of
424
+ // every cycle, a watch session's first one included — so a mixed
425
+ // launch cannot drain this queue on the node initial cycle's
426
+ // failures.
427
+ if (bail && context.stateManager.getCountOfFailedTests() >= bail) {
428
+ let skipped = queue.shift();
429
+ while (skipped) {
430
+ await handleTestFileComplete(makeSkippedFileResult(skipped));
431
+ skipped = queue.shift();
432
+ }
433
+ return;
434
+ }
435
+ const next = queue.shift();
436
+ if (!next) {
437
+ return;
438
+ }
439
+ await runSingleFile(run, next);
440
+ }
441
+ };
442
+
443
+ run.done = Promise.all(
444
+ Array.from(
445
+ { length: Math.min(queue.length, Math.max(concurrency, 1)) },
446
+ () => worker(),
447
+ ),
448
+ ).then(() => {});
449
+
450
+ await run.done;
451
+ runLifecycle.clearIfActive(run);
452
+ };
453
+
454
+ const testStart = Date.now();
455
+ await runFilesWithPool(allTestFiles);
456
+ const testTime = Date.now() - testStart;
457
+
458
+ let watchSession: BrowserWatchSession | undefined;
459
+ if (isWatchMode) {
460
+ // A queued scope can go stale before its cycle is dequeued — a later
461
+ // trigger may have rebuilt the file set without one of these files — so a
462
+ // path that no longer resolves is skipped rather than failing the cycle
463
+ // beside its still-valid siblings.
464
+ const runScope = async (testPaths: string[]): Promise<void> => {
465
+ const pathSet = new Set(testPaths.map((testPath) => normalize(testPath)));
466
+ await runFilesWithPool(
467
+ watchState.lastTestFiles.filter((file) => pathSet.has(file.testPath)),
468
+ );
469
+ };
470
+
471
+ // Cutting the in-flight run short lets its cycle finalize with what it had
472
+ // and the queued replacement start immediately; invalidating the token
473
+ // first makes every late dispatch from it a no-op. Deliberately not
474
+ // awaiting `run.done` — the cancelled run's own cycle is what awaits it.
475
+ //
476
+ // Unlike every other cancel this one does not tear the run's browser
477
+ // contexts down, because a rebuild trigger reaches it from inside the
478
+ // bundler's dev-compile hook: a page still fetching from the dev server
479
+ // that same hook is holding up cannot be closed, and the run it belongs
480
+ // to then never ends. Signalling the cancel is enough — the run's own
481
+ // teardown closes page and context as soon as it unwinds, and every page
482
+ // operation it can be sitting in is bounded by the driver's own timeout.
483
+ watchSignals.setInterrupt(async () => {
484
+ const active = runLifecycle.activeSession;
485
+ if (!active || active.cancelled) {
486
+ return;
487
+ }
488
+ runLifecycle.invalidateActiveToken();
489
+ await runLifecycle.cancel(active, { waitForDone: false });
490
+ });
491
+
492
+ watchSignals.setDispatchRerun(async () => {
493
+ const newProjectEntries = await collectProjectEntries();
494
+ const rerunPlan = planWatchRerun({
495
+ projectEntries: newProjectEntries,
496
+ previousTestFiles: watchState.lastTestFiles,
497
+ affectedTestFiles: drainPendingAffectedTestFiles(watchState),
498
+ });
499
+
500
+ if (rerunPlan.filesChanged) {
501
+ const deletedTestPaths = collectDeletedTestPaths(
502
+ watchState.lastTestFiles,
503
+ rerunPlan.currentTestFiles,
504
+ );
505
+ if (deletedTestPaths.length > 0) {
506
+ context.updateReporterResultState([], [], deletedTestPaths);
507
+ }
508
+ watchState.lastTestFiles = rerunPlan.currentTestFiles;
509
+ if (rerunPlan.currentTestFiles.length === 0) {
510
+ logger.log(
511
+ color.cyan('No browser test files remain after update.\n'),
512
+ );
513
+ // Still one cycle: core's finalize reports the emptied run.
514
+ await watchSignals.signalInvalidation([]);
515
+ return;
516
+ }
517
+
518
+ logger.log(
519
+ color.cyan(
520
+ `Test file set changed, re-running ${rerunPlan.currentTestFiles.length} file(s)...\n`,
521
+ ),
522
+ );
523
+ await watchSignals.signalInvalidation([
524
+ ...new Set(rerunPlan.currentTestFiles.map((file) => file.testPath)),
525
+ ]);
526
+ return;
527
+ }
528
+
529
+ if (rerunPlan.affectedTestFiles.length === 0) {
530
+ logger.log(
531
+ color.cyan(
532
+ 'No affected browser test files detected, skipping re-run.\n',
533
+ ),
534
+ );
535
+ logWatchReady();
536
+ return;
537
+ }
538
+
539
+ logger.log(
540
+ color.cyan(
541
+ `Re-running ${rerunPlan.affectedTestFiles.length} affected test file(s)...\n`,
542
+ ),
543
+ );
544
+ await watchSignals.signalInvalidation([
545
+ ...new Set(rerunPlan.affectedTestFiles.map((file) => file.testPath)),
546
+ ]);
547
+ });
548
+
549
+ watchSession = createWatchSession(runScope);
550
+ }
551
+
552
+ const closeHeadlessRuntime = !isWatchMode
553
+ ? async () => {
554
+ sessionRegistry.clear();
555
+ await destroyRuntime();
556
+ }
557
+ : undefined;
558
+
559
+ return {
560
+ testTime,
561
+ watchSession,
562
+ // `closeHeadlessRuntime` is already `undefined` in watch mode: the watch
563
+ // runtime outlives the cycle and is torn down through `executor.close()`.
564
+ close: closeHeadlessRuntime,
565
+ };
566
+ };