@rstest/browser 0.11.4 → 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.
- package/dist/browser-container/container-static/js/683.4f821ce8f1.js +194 -0
- package/dist/browser-container/container-static/js/index.568aa7cb35.js +1 -0
- package/dist/browser-container/container-static/js/lib-react.e24a1d366b.js +2 -0
- package/dist/browser-container/index.html +1 -1
- package/dist/browserExecutor.d.ts +7 -5
- package/dist/browserRsbuild.d.ts +119 -0
- package/dist/containerRpc.d.ts +52 -0
- package/dist/dispatchCapabilities.d.ts +1 -2
- package/dist/headedScheduler.d.ts +58 -0
- package/dist/headlessScheduler.d.ts +37 -0
- package/dist/hostController.d.ts +12 -47
- package/dist/hostPayloads.d.ts +30 -0
- package/dist/index.js +1738 -1793
- package/dist/protocol.d.ts +5 -0
- package/dist/schedulerSeam.d.ts +37 -0
- package/dist/watchRerunPlanner.d.ts +5 -0
- package/dist/watchRuntime.d.ts +21 -0
- package/dist/watchSignals.d.ts +22 -0
- package/package.json +5 -5
- package/src/browserExecutor.ts +90 -8
- package/src/browserRsbuild.ts +1927 -0
- package/src/client/entry.ts +3 -0
- package/src/containerRpc.ts +206 -0
- package/src/dispatchCapabilities.ts +1 -6
- package/src/headedScheduler.ts +664 -0
- package/src/headlessScheduler.ts +566 -0
- package/src/hostController.ts +855 -4163
- package/src/hostPayloads.ts +62 -0
- package/src/protocol.ts +6 -0
- package/src/schedulerSeam.ts +49 -0
- package/src/watchRerunPlanner.ts +18 -7
- package/src/watchRuntime.ts +83 -0
- package/src/watchSignals.ts +93 -0
- package/dist/browser-container/container-static/js/243.a8eed2b9e7.js +0 -27406
- package/dist/browser-container/container-static/js/243.a8eed2b9e7.js.LICENSE.txt +0 -1
- package/dist/browser-container/container-static/js/index.84aaafaf21.js +0 -3058
- package/dist/browser-container/container-static/js/lib-react.62b27a21db.js +0 -8454
- package/dist/browser-container/container-static/js/lib-react.62b27a21db.js.LICENSE.txt +0 -1
- package/dist/headlessLatestRerunScheduler.d.ts +0 -18
- package/src/headlessLatestRerunScheduler.ts +0 -76
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RstestContext,
|
|
3
|
+
TestFileResult,
|
|
4
|
+
TestResult,
|
|
5
|
+
} from '@rstest/core/internal/browser';
|
|
6
|
+
import { color, logger } from '@rstest/core/internal/browser';
|
|
7
|
+
import { normalize, relative } from 'pathe';
|
|
8
|
+
import {
|
|
9
|
+
type BrowserRuntime,
|
|
10
|
+
drainPendingAffectedTestFiles,
|
|
11
|
+
} from './browserRsbuild';
|
|
12
|
+
import { ContainerRpcManager, type HostRpcMethods } from './containerRpc';
|
|
13
|
+
import type { HostDispatchRouter } from './dispatchRouter';
|
|
14
|
+
import { createHeadedSerialTaskQueue } from './headedSerialTaskQueue';
|
|
15
|
+
import {
|
|
16
|
+
createDeferredPromise,
|
|
17
|
+
type DeferredPromise,
|
|
18
|
+
type FatalPayload,
|
|
19
|
+
type HeadedTestFileCompletePayload,
|
|
20
|
+
type LogPayload,
|
|
21
|
+
type ReloadTestFileAck,
|
|
22
|
+
type TestFileStartPayload,
|
|
23
|
+
toError,
|
|
24
|
+
} from './hostPayloads';
|
|
25
|
+
import type {
|
|
26
|
+
BrowserDispatchRequest,
|
|
27
|
+
BrowserHostConfig,
|
|
28
|
+
TestFileInfo,
|
|
29
|
+
} from './protocol';
|
|
30
|
+
import type { BrowserProviderContext, BrowserProviderPage } from './providers';
|
|
31
|
+
import { collectDeletedTestPaths, planWatchRerun } from './watchRerunPlanner';
|
|
32
|
+
import type {
|
|
33
|
+
BrowserWatchSession,
|
|
34
|
+
DispatchPageResolver,
|
|
35
|
+
SchedulerRunResult,
|
|
36
|
+
} from './schedulerSeam';
|
|
37
|
+
import type { WatchSignals } from './watchSignals';
|
|
38
|
+
|
|
39
|
+
type HeadedSchedulerContext = Pick<
|
|
40
|
+
RstestContext,
|
|
41
|
+
'rootPath' | 'snapshotManager' | 'updateReporterResultState'
|
|
42
|
+
> & {
|
|
43
|
+
normalizedConfig: Pick<RstestContext['normalizedConfig'], 'name'>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type HeadedSchedulerDeps = {
|
|
47
|
+
context: HeadedSchedulerContext;
|
|
48
|
+
runtime: BrowserRuntime;
|
|
49
|
+
allTestFiles: TestFileInfo[];
|
|
50
|
+
hostOptions: BrowserHostConfig;
|
|
51
|
+
isWatchMode: boolean;
|
|
52
|
+
createDispatchRouter: () => HostDispatchRouter;
|
|
53
|
+
handlers: {
|
|
54
|
+
handleTestFileStart: (payload: TestFileStartPayload) => Promise<void>;
|
|
55
|
+
handleTestCaseResult: (payload: TestResult) => Promise<void>;
|
|
56
|
+
handleTestFileComplete: (payload: TestFileResult) => Promise<void>;
|
|
57
|
+
handleLog: (payload: LogPayload) => Promise<void>;
|
|
58
|
+
handleFatal: (payload: FatalPayload) => Promise<void>;
|
|
59
|
+
};
|
|
60
|
+
fatalErrorRef: { current: Error | null };
|
|
61
|
+
watchSignals: Pick<
|
|
62
|
+
WatchSignals,
|
|
63
|
+
'setDispatchRerun' | 'signalInvalidation' | 'awaitSignalledCycle'
|
|
64
|
+
>;
|
|
65
|
+
setDispatchPageResolver: (resolver: DispatchPageResolver) => void;
|
|
66
|
+
createWatchSession: (
|
|
67
|
+
execute: (testPaths: string[]) => Promise<void>,
|
|
68
|
+
) => BrowserWatchSession;
|
|
69
|
+
collectProjectEntries: () => Promise<
|
|
70
|
+
Parameters<typeof planWatchRerun>[0]['projectEntries']
|
|
71
|
+
>;
|
|
72
|
+
logWatchReady: () => void;
|
|
73
|
+
destroyRuntime: () => Promise<void>;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A headed cycle's work list: the files of its scope that still exist, each
|
|
78
|
+
* paired with the test-name pattern whichever trigger put it in scope asked for.
|
|
79
|
+
*
|
|
80
|
+
* Both halves are resolved in one pass, synchronously, at the top of the cycle.
|
|
81
|
+
* A queued scope can go stale before its cycle is dequeued — a later trigger may
|
|
82
|
+
* have rebuilt the file set without one of these files — and it is skipped the
|
|
83
|
+
* way the headless twin skips it; throwing would abandon the still-valid files
|
|
84
|
+
* beside it and fail the run. The patterns are claimed here, and only for the
|
|
85
|
+
* paths in this scope, so a click landing once the cycle is under way keeps its
|
|
86
|
+
* pattern for the cycle it signalled instead of losing it to this one mid-loop.
|
|
87
|
+
*
|
|
88
|
+
* A skipped path keeps its pattern too, for the same reason: consuming it on the
|
|
89
|
+
* way past would leave the next cycle that does run the file — the file set can
|
|
90
|
+
* be rebuilt back — running it unfiltered, so the user's click would silently
|
|
91
|
+
* become a full-file rerun. The cost is one map entry per path that never comes
|
|
92
|
+
* back, which the next launch drops with the map.
|
|
93
|
+
*/
|
|
94
|
+
export const claimHeadedCycleScope = (
|
|
95
|
+
testPaths: string[],
|
|
96
|
+
currentTestFiles: TestFileInfo[],
|
|
97
|
+
pendingTestNamePatterns: Map<string, string>,
|
|
98
|
+
): { file: TestFileInfo; testNamePattern?: string }[] => {
|
|
99
|
+
const scope: { file: TestFileInfo; testNamePattern?: string }[] = [];
|
|
100
|
+
const filesByPath = new Map(
|
|
101
|
+
currentTestFiles.map((file) => [file.testPath, file]),
|
|
102
|
+
);
|
|
103
|
+
for (const testPath of testPaths) {
|
|
104
|
+
const normalizedTestPath = normalize(testPath);
|
|
105
|
+
const file = filesByPath.get(normalizedTestPath);
|
|
106
|
+
if (file) {
|
|
107
|
+
scope.push({
|
|
108
|
+
file,
|
|
109
|
+
testNamePattern: pendingTestNamePatterns.get(normalizedTestPath),
|
|
110
|
+
});
|
|
111
|
+
pendingTestNamePatterns.delete(normalizedTestPath);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return scope;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const createHeadedScheduler = async ({
|
|
118
|
+
context,
|
|
119
|
+
runtime,
|
|
120
|
+
allTestFiles,
|
|
121
|
+
hostOptions,
|
|
122
|
+
isWatchMode,
|
|
123
|
+
createDispatchRouter,
|
|
124
|
+
handlers: {
|
|
125
|
+
handleTestFileStart,
|
|
126
|
+
handleTestCaseResult,
|
|
127
|
+
handleTestFileComplete,
|
|
128
|
+
handleLog,
|
|
129
|
+
handleFatal,
|
|
130
|
+
},
|
|
131
|
+
fatalErrorRef,
|
|
132
|
+
watchSignals,
|
|
133
|
+
setDispatchPageResolver,
|
|
134
|
+
createWatchSession,
|
|
135
|
+
collectProjectEntries,
|
|
136
|
+
logWatchReady,
|
|
137
|
+
destroyRuntime,
|
|
138
|
+
}: HeadedSchedulerDeps): Promise<SchedulerRunResult> => {
|
|
139
|
+
const { browser, browserLaunchOptions, watchState, wss } = runtime;
|
|
140
|
+
let currentTestFiles = allTestFiles;
|
|
141
|
+
// Coincidentally equal to the runner-side CONFIG_WAIT_TIMEOUT_MS and
|
|
142
|
+
// DEFAULT_RPC_TIMEOUT_MS (client/entry.ts, client/dispatchTransport.ts) but
|
|
143
|
+
// semantically distinct and in a different runtime, so deliberately NOT shared
|
|
144
|
+
// with them. Invariant worth preserving: a runner must be able to receive its
|
|
145
|
+
// config (config-wait) before the host declares its frames un-ready, i.e.
|
|
146
|
+
// CONFIG_WAIT_TIMEOUT_MS <= RUNNER_FRAMES_READY_TIMEOUT_MS.
|
|
147
|
+
const RUNNER_FRAMES_READY_TIMEOUT_MS = 30_000;
|
|
148
|
+
let currentRunnerFramesSignature: string | null = null;
|
|
149
|
+
const runnerFramesWaiters = new Map<string, Set<() => void>>();
|
|
150
|
+
|
|
151
|
+
const createTestFilesSignature = (testFiles: readonly string[]): string => {
|
|
152
|
+
return JSON.stringify(testFiles.map((testFile) => normalize(testFile)));
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const markRunnerFramesReady = (testFiles: string[]): void => {
|
|
156
|
+
const signature = createTestFilesSignature(testFiles);
|
|
157
|
+
currentRunnerFramesSignature = signature;
|
|
158
|
+
const waiters = runnerFramesWaiters.get(signature);
|
|
159
|
+
if (!waiters) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
runnerFramesWaiters.delete(signature);
|
|
163
|
+
for (const waiter of waiters) {
|
|
164
|
+
waiter();
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const waitForRunnerFramesReady = async (
|
|
169
|
+
testFiles: readonly string[],
|
|
170
|
+
): Promise<void> => {
|
|
171
|
+
const signature = createTestFilesSignature(testFiles);
|
|
172
|
+
if (currentRunnerFramesSignature === signature) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
await new Promise<void>((resolve, reject) => {
|
|
177
|
+
const waiters =
|
|
178
|
+
runnerFramesWaiters.get(signature) ?? new Set<() => void>();
|
|
179
|
+
|
|
180
|
+
const cleanup = () => {
|
|
181
|
+
const currentWaiters = runnerFramesWaiters.get(signature);
|
|
182
|
+
if (!currentWaiters) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
currentWaiters.delete(onReady);
|
|
186
|
+
if (currentWaiters.size === 0) {
|
|
187
|
+
runnerFramesWaiters.delete(signature);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const onReady = () => {
|
|
192
|
+
if (timeoutId) {
|
|
193
|
+
clearTimeout(timeoutId);
|
|
194
|
+
}
|
|
195
|
+
cleanup();
|
|
196
|
+
resolve();
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const timeoutId = setTimeout(() => {
|
|
200
|
+
cleanup();
|
|
201
|
+
reject(
|
|
202
|
+
new Error(
|
|
203
|
+
`Timed out waiting for headed runner frames to be ready for ${testFiles.length} file(s).`,
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
}, RUNNER_FRAMES_READY_TIMEOUT_MS);
|
|
207
|
+
|
|
208
|
+
waiters.add(onReady);
|
|
209
|
+
runnerFramesWaiters.set(signature, waiters);
|
|
210
|
+
|
|
211
|
+
if (currentRunnerFramesSignature === signature) {
|
|
212
|
+
onReady();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const getTestFileInfo = (testFile: string): TestFileInfo => {
|
|
218
|
+
const normalizedTestFile = normalize(testFile);
|
|
219
|
+
const fileInfo = currentTestFiles.find(
|
|
220
|
+
(file) => file.testPath === normalizedTestFile,
|
|
221
|
+
);
|
|
222
|
+
if (!fileInfo) {
|
|
223
|
+
throw new Error(`Unknown browser test file: ${JSON.stringify(testFile)}`);
|
|
224
|
+
}
|
|
225
|
+
return fileInfo;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// Open a container page for user to view (reuse in watch mode)
|
|
229
|
+
let containerContext: BrowserProviderContext;
|
|
230
|
+
let containerPage: BrowserProviderPage;
|
|
231
|
+
let isNewPage = false;
|
|
232
|
+
|
|
233
|
+
if (isWatchMode && runtime.containerPage && runtime.containerContext) {
|
|
234
|
+
containerContext = runtime.containerContext;
|
|
235
|
+
containerPage = runtime.containerPage;
|
|
236
|
+
logger.log(color.gray('\n[Watch] Reusing existing container page\n'));
|
|
237
|
+
} else {
|
|
238
|
+
isNewPage = true;
|
|
239
|
+
containerContext = await browser.newContext({
|
|
240
|
+
providerOptions: browserLaunchOptions.providerOptions,
|
|
241
|
+
viewport: null,
|
|
242
|
+
});
|
|
243
|
+
containerPage = await containerContext.newPage();
|
|
244
|
+
|
|
245
|
+
// Prevent popup windows from being created
|
|
246
|
+
containerPage.on('popup', async (popup: BrowserProviderPage) => {
|
|
247
|
+
await popup.close().catch(() => {});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
containerContext.on('page', async (page: BrowserProviderPage) => {
|
|
251
|
+
if (page !== containerPage) {
|
|
252
|
+
await page.close().catch(() => {});
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
if (isWatchMode) {
|
|
257
|
+
runtime.containerPage = containerPage;
|
|
258
|
+
runtime.containerContext = containerContext;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Forward browser console to terminal
|
|
262
|
+
containerPage.on('console', (msg) => {
|
|
263
|
+
const text = msg.text();
|
|
264
|
+
if (text.startsWith('[Container]') || text.startsWith('[Runner]')) {
|
|
265
|
+
logger.log(color.gray(`[Browser Console] ${text}`));
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
setDispatchPageResolver(() => ({ containerPage }));
|
|
271
|
+
|
|
272
|
+
const dispatchRouter = createDispatchRouter();
|
|
273
|
+
const headedReloadQueue = createHeadedSerialTaskQueue();
|
|
274
|
+
const pendingHeadedReloads = new Map<
|
|
275
|
+
string,
|
|
276
|
+
{
|
|
277
|
+
runId: string;
|
|
278
|
+
deferred: DeferredPromise<void>;
|
|
279
|
+
}
|
|
280
|
+
>();
|
|
281
|
+
let enqueueHeadedReload = async (
|
|
282
|
+
_file: TestFileInfo,
|
|
283
|
+
_testNamePattern?: string,
|
|
284
|
+
): Promise<void> => {
|
|
285
|
+
throw new Error('Headed reload queue is not initialized');
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const rejectPendingHeadedReload = (
|
|
289
|
+
testPath: string,
|
|
290
|
+
error: Error,
|
|
291
|
+
runId?: string,
|
|
292
|
+
): void => {
|
|
293
|
+
const pending = pendingHeadedReloads.get(testPath);
|
|
294
|
+
if (!pending) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (runId && pending.runId !== runId) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
pendingHeadedReloads.delete(testPath);
|
|
301
|
+
pending.deferred.reject(error);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const rejectAllPendingHeadedReloads = (error: Error): void => {
|
|
305
|
+
for (const [testPath, pending] of pendingHeadedReloads) {
|
|
306
|
+
pendingHeadedReloads.delete(testPath);
|
|
307
|
+
pending.deferred.reject(error);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const registerPendingHeadedReload = (
|
|
312
|
+
testPath: string,
|
|
313
|
+
runId: string,
|
|
314
|
+
): Promise<void> => {
|
|
315
|
+
const previousPending = pendingHeadedReloads.get(testPath);
|
|
316
|
+
if (previousPending) {
|
|
317
|
+
previousPending.deferred.reject(
|
|
318
|
+
new Error(
|
|
319
|
+
`Reload for "${testPath}" was superseded by a newer request.`,
|
|
320
|
+
),
|
|
321
|
+
);
|
|
322
|
+
pendingHeadedReloads.delete(testPath);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const deferred = createDeferredPromise<void>();
|
|
326
|
+
pendingHeadedReloads.set(testPath, {
|
|
327
|
+
runId,
|
|
328
|
+
deferred,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
return deferred.promise;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const resolvePendingHeadedReload = (
|
|
335
|
+
testPath: string,
|
|
336
|
+
runId?: string,
|
|
337
|
+
): void => {
|
|
338
|
+
const pending = pendingHeadedReloads.get(testPath);
|
|
339
|
+
if (!pending) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (runId && pending.runId !== runId) {
|
|
343
|
+
logger.debug(
|
|
344
|
+
`[Browser UI] Ignoring stale file-complete for ${testPath}. current=${pending.runId}, incoming=${runId}`,
|
|
345
|
+
);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
pendingHeadedReloads.delete(testPath);
|
|
349
|
+
pending.deferred.resolve();
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// No execution-duration watchdog: per-test/hook timeouts are enforced inside
|
|
353
|
+
// the runner, and a dead container is caught event-driven by the WebSocket
|
|
354
|
+
// `close` handler, which rejects every pending reload via `onDisconnect`.
|
|
355
|
+
const reloadTestFileAndWait = async (
|
|
356
|
+
file: TestFileInfo,
|
|
357
|
+
testNamePattern?: string,
|
|
358
|
+
): Promise<void> => {
|
|
359
|
+
let reloadAck: ReloadTestFileAck | undefined;
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
reloadAck = await rpcManager.reloadTestFile(
|
|
363
|
+
file.testPath,
|
|
364
|
+
testNamePattern,
|
|
365
|
+
);
|
|
366
|
+
await registerPendingHeadedReload(file.testPath, reloadAck.runId);
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (reloadAck?.runId) {
|
|
369
|
+
rejectPendingHeadedReload(
|
|
370
|
+
file.testPath,
|
|
371
|
+
toError(error),
|
|
372
|
+
reloadAck.runId,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
throw error;
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// The in-page rerun button is a watch trigger like any other, so once the
|
|
380
|
+
// watch session exists it routes through core's cycle instead of reloading
|
|
381
|
+
// the frame behind core's back. Until then (during the initial cycle) the
|
|
382
|
+
// direct reload is all there is.
|
|
383
|
+
let runUiRequestedRerun = async (
|
|
384
|
+
file: TestFileInfo,
|
|
385
|
+
testNamePattern?: string,
|
|
386
|
+
): Promise<void> => {
|
|
387
|
+
await enqueueHeadedReload(file, testNamePattern);
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// Create RPC methods that can access test state variables
|
|
391
|
+
const createRpcMethods = (): HostRpcMethods => ({
|
|
392
|
+
async rerunTest(testFile: string, testNamePattern?: string) {
|
|
393
|
+
const projectName = context.normalizedConfig.name || 'project';
|
|
394
|
+
const relativePath = relative(context.rootPath, testFile);
|
|
395
|
+
const displayPath = `<${projectName}>/${relativePath}`;
|
|
396
|
+
logger.log(
|
|
397
|
+
color.cyan(
|
|
398
|
+
`\nRe-running test: ${displayPath}${testNamePattern ? ` (pattern: ${testNamePattern})` : ''}\n`,
|
|
399
|
+
),
|
|
400
|
+
);
|
|
401
|
+
await runUiRequestedRerun(getTestFileInfo(testFile), testNamePattern);
|
|
402
|
+
},
|
|
403
|
+
async getTestFiles() {
|
|
404
|
+
return currentTestFiles;
|
|
405
|
+
},
|
|
406
|
+
async onRunnerFramesReady(testFiles: string[]) {
|
|
407
|
+
markRunnerFramesReady(testFiles);
|
|
408
|
+
},
|
|
409
|
+
async onTestFileStart(payload: TestFileStartPayload) {
|
|
410
|
+
await handleTestFileStart(payload);
|
|
411
|
+
},
|
|
412
|
+
async onTestCaseResult(payload: TestResult) {
|
|
413
|
+
await handleTestCaseResult(payload);
|
|
414
|
+
},
|
|
415
|
+
async onTestFileComplete(payload: HeadedTestFileCompletePayload) {
|
|
416
|
+
try {
|
|
417
|
+
await handleTestFileComplete(payload);
|
|
418
|
+
resolvePendingHeadedReload(payload.testPath, payload.runId);
|
|
419
|
+
} catch (error) {
|
|
420
|
+
rejectPendingHeadedReload(
|
|
421
|
+
payload.testPath,
|
|
422
|
+
toError(error),
|
|
423
|
+
payload.runId,
|
|
424
|
+
);
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
async onLog(payload: LogPayload) {
|
|
429
|
+
await handleLog(payload);
|
|
430
|
+
},
|
|
431
|
+
async onFatal(payload: FatalPayload) {
|
|
432
|
+
const error = new Error(payload.message);
|
|
433
|
+
error.stack = payload.stack;
|
|
434
|
+
rejectAllPendingHeadedReloads(error);
|
|
435
|
+
await handleFatal(payload);
|
|
436
|
+
},
|
|
437
|
+
async dispatch(request: BrowserDispatchRequest) {
|
|
438
|
+
// Headed/container path now shares the same dispatch contract as headless.
|
|
439
|
+
return dispatchRouter.dispatch(request);
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// Setup RPC manager
|
|
444
|
+
let rpcManager: ContainerRpcManager;
|
|
445
|
+
|
|
446
|
+
if (isWatchMode && runtime.rpcManager) {
|
|
447
|
+
rpcManager = runtime.rpcManager;
|
|
448
|
+
// Update methods with new test state (caseResults, completedTests, etc.)
|
|
449
|
+
rpcManager.updateMethods(createRpcMethods(), rejectAllPendingHeadedReloads);
|
|
450
|
+
// Reattach if we have an existing WebSocket
|
|
451
|
+
const existingWs = rpcManager.currentWebSocket;
|
|
452
|
+
if (existingWs) {
|
|
453
|
+
rpcManager.reattach(existingWs);
|
|
454
|
+
}
|
|
455
|
+
} else {
|
|
456
|
+
rpcManager = new ContainerRpcManager(
|
|
457
|
+
wss,
|
|
458
|
+
createRpcMethods(),
|
|
459
|
+
rejectAllPendingHeadedReloads,
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
if (isWatchMode) {
|
|
463
|
+
runtime.rpcManager = rpcManager;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Only navigate on first creation
|
|
468
|
+
if (isNewPage) {
|
|
469
|
+
const pagePath = '/';
|
|
470
|
+
const containerPort = runtime.containerServer.port;
|
|
471
|
+
await containerPage.goto(`http://localhost:${containerPort}${pagePath}`, {
|
|
472
|
+
waitUntil: 'load',
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
logger.log(
|
|
476
|
+
color.cyan(
|
|
477
|
+
`\nBrowser mode opened at http://localhost:${containerPort}${pagePath}\n`,
|
|
478
|
+
),
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
enqueueHeadedReload = async (
|
|
483
|
+
file: TestFileInfo,
|
|
484
|
+
testNamePattern?: string,
|
|
485
|
+
): Promise<void> => {
|
|
486
|
+
return headedReloadQueue.enqueue(async () => {
|
|
487
|
+
if (fatalErrorRef.current) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
await reloadTestFileAndWait(file, testNamePattern);
|
|
491
|
+
});
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
let testTime = 0;
|
|
495
|
+
if (currentTestFiles.length > 0) {
|
|
496
|
+
const testStart = Date.now();
|
|
497
|
+
try {
|
|
498
|
+
await waitForRunnerFramesReady(
|
|
499
|
+
currentTestFiles.map((file) => file.testPath),
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
for (const file of currentTestFiles) {
|
|
503
|
+
await enqueueHeadedReload(file);
|
|
504
|
+
if (fatalErrorRef.current) {
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
} catch (error) {
|
|
509
|
+
// The fatal error rides the returned result into the cycle outcome, and
|
|
510
|
+
// core's `finalizeRunCycle` raises the exit code from it.
|
|
511
|
+
fatalErrorRef.current = fatalErrorRef.current ?? toError(error);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
testTime = Date.now() - testStart;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
let watchSession: BrowserWatchSession | undefined;
|
|
518
|
+
if (isWatchMode) {
|
|
519
|
+
// Set by the in-page rerun trigger and consumed by the cycle that reloads
|
|
520
|
+
// that file — the pattern is a headed-UI concept core's cycle options
|
|
521
|
+
// cannot carry, so it travels beside the scope rather than inside it.
|
|
522
|
+
// Keyed by test path rather than held in one slot: core queues cycles, so
|
|
523
|
+
// an unrelated trigger can be dequeued between the click and its own cycle,
|
|
524
|
+
// and a single slot would hand the pattern to whichever cycle ran first.
|
|
525
|
+
// An entry is written with its own signal and read at a cycle's first
|
|
526
|
+
// synchronous step, so the cycle that takes it is the one that signal
|
|
527
|
+
// started — or, when the file was already in a queued scope, the one it
|
|
528
|
+
// folded into, which is the cycle that runs the file. That holds as long as
|
|
529
|
+
// nothing yields between the two: core closes the fold window and then
|
|
530
|
+
// awaits `notifyReportersOnTestRunStart` before this cycle claims, so a user
|
|
531
|
+
// reporter with an async `onTestRunStart` hook is the one thing that can
|
|
532
|
+
// stretch the gap wide enough for another signal to land in it. A tracked
|
|
533
|
+
// gap, not a choice: a second click on the same file inside that window
|
|
534
|
+
// overwrites the entry, so the earlier cycle claims the newer pattern and
|
|
535
|
+
// the later one finds it gone and reloads the file unfiltered. Closing it
|
|
536
|
+
// means the pattern crossing the seam inside the queued cycle's own
|
|
537
|
+
// options instead of traveling beside the scope.
|
|
538
|
+
const pendingTestNamePatterns = new Map<string, string>();
|
|
539
|
+
|
|
540
|
+
const runScope = async (testPaths: string[]): Promise<void> => {
|
|
541
|
+
// Claimed in this synchronous prefix, before `runCycle` suspends, so
|
|
542
|
+
// nothing this cycle does can change what it runs or which patterns it
|
|
543
|
+
// takes.
|
|
544
|
+
const cycleScope = claimHeadedCycleScope(
|
|
545
|
+
testPaths,
|
|
546
|
+
currentTestFiles,
|
|
547
|
+
pendingTestNamePatterns,
|
|
548
|
+
);
|
|
549
|
+
for (const { file, testNamePattern } of cycleScope) {
|
|
550
|
+
await enqueueHeadedReload(file, testNamePattern);
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Re-deliver the host config so runner iframes reloaded by the next cycle
|
|
556
|
+
* observe live per-rerun values ('u' flips updateSnapshot between reruns);
|
|
557
|
+
* `setContainerOptions` keeps full container reloads in sync.
|
|
558
|
+
*/
|
|
559
|
+
const refreshHostConfig = async (): Promise<void> => {
|
|
560
|
+
const refreshedHostOptions: BrowserHostConfig = {
|
|
561
|
+
...hostOptions,
|
|
562
|
+
snapshot: {
|
|
563
|
+
updateSnapshot: context.snapshotManager.options.updateSnapshot,
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
runtime.setContainerOptions(refreshedHostOptions);
|
|
567
|
+
await rpcManager.updateHostConfig(refreshedHostOptions);
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
watchSignals.setDispatchRerun(async () => {
|
|
571
|
+
// Independent: config push to the container vs. local entry collection.
|
|
572
|
+
const [, newProjectEntries] = await Promise.all([
|
|
573
|
+
refreshHostConfig(),
|
|
574
|
+
collectProjectEntries(),
|
|
575
|
+
]);
|
|
576
|
+
const rerunPlan = planWatchRerun({
|
|
577
|
+
projectEntries: newProjectEntries,
|
|
578
|
+
previousTestFiles: watchState.lastTestFiles,
|
|
579
|
+
affectedTestFiles: drainPendingAffectedTestFiles(watchState),
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
if (rerunPlan.filesChanged) {
|
|
583
|
+
const deletedTestPaths = collectDeletedTestPaths(
|
|
584
|
+
watchState.lastTestFiles,
|
|
585
|
+
rerunPlan.currentTestFiles,
|
|
586
|
+
);
|
|
587
|
+
if (deletedTestPaths.length > 0) {
|
|
588
|
+
context.updateReporterResultState([], [], deletedTestPaths);
|
|
589
|
+
}
|
|
590
|
+
watchState.lastTestFiles = rerunPlan.currentTestFiles;
|
|
591
|
+
currentTestFiles = rerunPlan.currentTestFiles;
|
|
592
|
+
await rpcManager.notifyTestFileUpdate(currentTestFiles);
|
|
593
|
+
if (currentTestFiles.length === 0) {
|
|
594
|
+
logger.log(
|
|
595
|
+
color.cyan('No browser test files remain after update.\n'),
|
|
596
|
+
);
|
|
597
|
+
logWatchReady();
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
await waitForRunnerFramesReady(
|
|
601
|
+
currentTestFiles.map((file) => file.testPath),
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (rerunPlan.normalizedAffectedTestFiles.length > 0) {
|
|
606
|
+
logger.log(
|
|
607
|
+
color.cyan(
|
|
608
|
+
`Re-running ${rerunPlan.normalizedAffectedTestFiles.length} affected test file(s)...\n`,
|
|
609
|
+
),
|
|
610
|
+
);
|
|
611
|
+
await watchSignals.signalInvalidation(
|
|
612
|
+
rerunPlan.normalizedAffectedTestFiles,
|
|
613
|
+
);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (!rerunPlan.filesChanged) {
|
|
618
|
+
logger.log(color.cyan('Tests will be re-executed automatically\n'));
|
|
619
|
+
}
|
|
620
|
+
logWatchReady();
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
runUiRequestedRerun = async (file, testNamePattern) => {
|
|
624
|
+
await refreshHostConfig();
|
|
625
|
+
await watchSignals.signalInvalidation([file.testPath], () => {
|
|
626
|
+
if (testNamePattern === undefined) {
|
|
627
|
+
pendingTestNamePatterns.delete(normalize(file.testPath));
|
|
628
|
+
} else {
|
|
629
|
+
pendingTestNamePatterns.set(
|
|
630
|
+
normalize(file.testPath),
|
|
631
|
+
testNamePattern,
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
await watchSignals.awaitSignalledCycle();
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
watchSession = createWatchSession(runScope);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const closeContainerRuntime = !isWatchMode
|
|
642
|
+
? async () => {
|
|
643
|
+
try {
|
|
644
|
+
await containerPage.close();
|
|
645
|
+
} catch {
|
|
646
|
+
// ignore
|
|
647
|
+
}
|
|
648
|
+
try {
|
|
649
|
+
await containerContext.close();
|
|
650
|
+
} catch {
|
|
651
|
+
// ignore
|
|
652
|
+
}
|
|
653
|
+
await destroyRuntime();
|
|
654
|
+
}
|
|
655
|
+
: undefined;
|
|
656
|
+
|
|
657
|
+
return {
|
|
658
|
+
testTime,
|
|
659
|
+
watchSession,
|
|
660
|
+
// `closeContainerRuntime` is already `undefined` in watch mode: the watch
|
|
661
|
+
// runtime outlives the cycle and is torn down through `executor.close()`.
|
|
662
|
+
close: closeContainerRuntime,
|
|
663
|
+
};
|
|
664
|
+
};
|