rwsdk 1.0.0-alpha.10 → 1.0.0-alpha.12

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.
@@ -1,20 +1,52 @@
1
- import { test, beforeAll, afterAll, afterEach, } from "vitest";
1
+ import { test, beforeAll, afterAll, afterEach, describe, beforeEach, } from "vitest";
2
2
  import { basename, join as pathJoin, dirname } from "path";
3
- import fs from "node:fs";
4
3
  import { setupTarballEnvironment } from "./tarball.mjs";
5
4
  import { runDevServer } from "./dev.mjs";
6
5
  import { runRelease, deleteWorker, deleteD1Database, isRelatedToTest, } from "./release.mjs";
7
6
  import { launchBrowser } from "./browser.mjs";
8
- const SETUP_PLAYGROUND_ENV_TIMEOUT = 10 * 60 * 1000;
7
+ import puppeteer from "puppeteer-core";
8
+ import fs from "fs-extra";
9
+ import os from "os";
10
+ import path from "path";
11
+ import { poll, pollValue } from "./poll.mjs";
12
+ const SETUP_PLAYGROUND_ENV_TIMEOUT = process.env
13
+ .RWSDK_SETUP_PLAYGROUND_ENV_TIMEOUT
14
+ ? parseInt(process.env.RWSDK_SETUP_PLAYGROUND_ENV_TIMEOUT, 10)
15
+ : 15 * 60 * 1000;
16
+ const DEPLOYMENT_TIMEOUT = process.env.RWSDK_DEPLOYMENT_TIMEOUT
17
+ ? parseInt(process.env.RWSDK_DEPLOYMENT_TIMEOUT, 10)
18
+ : 5 * 60 * 1000;
19
+ const DEPLOYMENT_MIN_TRIES = process.env.RWSDK_DEPLOYMENT_MIN_TRIES
20
+ ? parseInt(process.env.RWSDK_DEPLOYMENT_MIN_TRIES, 10)
21
+ : 5;
22
+ const DEPLOYMENT_CHECK_TIMEOUT = process.env.RWSDK_DEPLOYMENT_CHECK_TIMEOUT
23
+ ? parseInt(process.env.RWSDK_DEPLOYMENT_CHECK_TIMEOUT, 10)
24
+ : 5 * 60 * 1000;
9
25
  const PUPPETEER_TIMEOUT = process.env.RWSDK_PUPPETEER_TIMEOUT
10
26
  ? parseInt(process.env.RWSDK_PUPPETEER_TIMEOUT, 10)
11
- : 60 * 1000; // 60 seconds
27
+ : 60 * 1000 * 2;
28
+ const HYDRATION_TIMEOUT = process.env.RWSDK_HYDRATION_TIMEOUT
29
+ ? parseInt(process.env.RWSDK_HYDRATION_TIMEOUT, 10)
30
+ : 5000;
31
+ const DEV_SERVER_TIMEOUT = process.env.RWSDK_DEV_SERVER_TIMEOUT
32
+ ? parseInt(process.env.RWSDK_DEV_SERVER_TIMEOUT, 10)
33
+ : 5 * 60 * 1000;
34
+ const DEV_SERVER_MIN_TRIES = process.env.RWSDK_DEV_SERVER_MIN_TRIES
35
+ ? parseInt(process.env.RWSDK_DEV_SERVER_MIN_TRIES, 10)
36
+ : 5;
37
+ const SETUP_WAIT_TIMEOUT = process.env.RWSDK_SETUP_WAIT_TIMEOUT
38
+ ? parseInt(process.env.RWSDK_SETUP_WAIT_TIMEOUT, 10)
39
+ : 10 * 60 * 1000;
12
40
  // Environment variable flags for skipping tests
13
41
  const SKIP_DEV_SERVER_TESTS = process.env.RWSDK_SKIP_DEV === "1";
14
42
  const SKIP_DEPLOYMENT_TESTS = process.env.RWSDK_SKIP_DEPLOY === "1";
15
43
  // Global test environment state
16
- let globalPlaygroundEnv = null;
17
- const cleanupTasks = [];
44
+ let globalDevPlaygroundEnv = null;
45
+ let globalDeployPlaygroundEnv = null;
46
+ let globalDevInstancePromise = null;
47
+ let globalDeploymentInstancePromise = null;
48
+ let globalDevInstance = null;
49
+ let globalDeploymentInstance = null;
18
50
  let hooksRegistered = false;
19
51
  /**
20
52
  * Registers global cleanup hooks automatically
@@ -22,49 +54,29 @@ let hooksRegistered = false;
22
54
  function ensureHooksRegistered() {
23
55
  if (hooksRegistered)
24
56
  return;
25
- // Register global afterEach to clean up resources created during tests
26
- afterEach(async () => {
27
- const tasksToCleanup = [...cleanupTasks];
28
- cleanupTasks.length = 0; // Clear the array
29
- for (const task of tasksToCleanup) {
30
- try {
31
- await task.cleanup();
32
- }
33
- catch (error) {
34
- console.warn(`Failed to cleanup ${task.type} ${task.id}:`, error);
35
- }
36
- }
37
- });
38
57
  // Register global afterAll to clean up the playground environment
39
58
  afterAll(async () => {
40
- if (globalPlaygroundEnv) {
41
- try {
42
- await globalPlaygroundEnv.cleanup();
43
- globalPlaygroundEnv = null;
44
- }
45
- catch (error) {
46
- console.warn("Failed to cleanup playground environment:", error);
47
- }
59
+ const cleanupPromises = [];
60
+ if (globalDevInstance) {
61
+ cleanupPromises.push(globalDevInstance.stopDev());
62
+ }
63
+ if (globalDeploymentInstance) {
64
+ cleanupPromises.push(globalDeploymentInstance.cleanup());
48
65
  }
66
+ if (globalDevPlaygroundEnv) {
67
+ cleanupPromises.push(globalDevPlaygroundEnv.cleanup());
68
+ }
69
+ if (globalDeployPlaygroundEnv) {
70
+ cleanupPromises.push(globalDeployPlaygroundEnv.cleanup());
71
+ }
72
+ await Promise.all(cleanupPromises);
73
+ globalDevInstance = null;
74
+ globalDeploymentInstance = null;
75
+ globalDevPlaygroundEnv = null;
76
+ globalDeployPlaygroundEnv = null;
49
77
  });
50
78
  hooksRegistered = true;
51
79
  }
52
- /**
53
- * Registers a cleanup task to be executed automatically
54
- */
55
- function registerCleanupTask(task) {
56
- ensureHooksRegistered();
57
- cleanupTasks.push(task);
58
- }
59
- /**
60
- * Removes a cleanup task from the registry (when manually cleaned up)
61
- */
62
- function unregisterCleanupTask(id) {
63
- const index = cleanupTasks.findIndex((task) => task.id === id);
64
- if (index !== -1) {
65
- cleanupTasks.splice(index, 1);
66
- }
67
- }
68
80
  /**
69
81
  * Get the project directory for the current test by looking at the call stack
70
82
  */
@@ -92,12 +104,13 @@ function getPlaygroundDirFromImportMeta(importMetaUrl) {
92
104
  `Failed to find a package.json in any parent directory.`);
93
105
  }
94
106
  /**
95
- * Sets up a playground environment for the entire test suite.
96
- * Automatically registers beforeAll and afterAll hooks.
97
- *
98
- * @param sourceProjectDir - Explicit path to playground directory, or import.meta.url to auto-detect
107
+ * A Vitest hook that sets up a playground environment for a test file.
108
+ * It creates a temporary directory, copies the playground project into it,
109
+ * and installs dependencies using a tarball of the SDK.
110
+ * This ensures that tests run in a clean, isolated environment.
99
111
  */
100
- export function setupPlaygroundEnvironment(sourceProjectDir) {
112
+ export function setupPlaygroundEnvironment(options = {}) {
113
+ const { sourceProjectDir, monorepoRoot, dev = true, deploy = true, } = typeof options === "string" ? { sourceProjectDir: options } : options;
101
114
  ensureHooksRegistered();
102
115
  beforeAll(async () => {
103
116
  let projectDir;
@@ -113,159 +126,129 @@ export function setupPlaygroundEnvironment(sourceProjectDir) {
113
126
  projectDir = sourceProjectDir;
114
127
  }
115
128
  console.log(`Setting up playground environment from ${projectDir}...`);
116
- const tarballEnv = await setupTarballEnvironment({
117
- projectDir,
118
- packageManager: process.env.PACKAGE_MANAGER || "pnpm",
119
- });
120
- globalPlaygroundEnv = {
121
- projectDir: tarballEnv.targetDir,
122
- cleanup: tarballEnv.cleanup,
123
- };
129
+ if (dev) {
130
+ const devEnv = await setupTarballEnvironment({
131
+ projectDir,
132
+ monorepoRoot,
133
+ packageManager: process.env.PACKAGE_MANAGER || "pnpm",
134
+ });
135
+ globalDevPlaygroundEnv = {
136
+ projectDir: devEnv.targetDir,
137
+ cleanup: devEnv.cleanup,
138
+ };
139
+ globalDevInstancePromise = createDevServer(devEnv.targetDir).then((instance) => {
140
+ globalDevInstance = instance;
141
+ return instance;
142
+ });
143
+ // Prevent unhandled promise rejections. The error will be handled inside
144
+ // the test's beforeEach hook where this promise is awaited.
145
+ globalDevInstancePromise.catch(() => { });
146
+ }
147
+ else {
148
+ globalDevInstancePromise = Promise.resolve(null);
149
+ }
150
+ if (deploy) {
151
+ const deployEnv = await setupTarballEnvironment({
152
+ projectDir,
153
+ monorepoRoot,
154
+ packageManager: process.env.PACKAGE_MANAGER || "pnpm",
155
+ });
156
+ globalDeployPlaygroundEnv = {
157
+ projectDir: deployEnv.targetDir,
158
+ cleanup: deployEnv.cleanup,
159
+ };
160
+ globalDeploymentInstancePromise = createDeployment(deployEnv.targetDir).then((instance) => {
161
+ globalDeploymentInstance = instance;
162
+ return instance;
163
+ });
164
+ // Prevent unhandled promise rejections
165
+ globalDeploymentInstancePromise.catch(() => { });
166
+ }
167
+ else {
168
+ globalDeploymentInstancePromise = Promise.resolve(null);
169
+ }
124
170
  }, SETUP_PLAYGROUND_ENV_TIMEOUT);
125
171
  }
126
- /**
127
- * Gets the current playground environment.
128
- * Throws if no environment has been set up.
129
- */
130
- export function getPlaygroundEnvironment() {
131
- if (!globalPlaygroundEnv) {
132
- throw new Error("No playground environment set up. Call setupPlaygroundEnvironment() in beforeAll()");
133
- }
134
- return globalPlaygroundEnv;
135
- }
136
172
  /**
137
173
  * Creates a dev server instance using the shared playground environment.
138
174
  * Automatically registers cleanup to run after the test.
139
175
  */
140
- export async function createDevServer() {
176
+ export async function createDevServer(projectDir) {
141
177
  if (SKIP_DEV_SERVER_TESTS) {
142
178
  throw new Error("Dev server tests are skipped via RWSDK_SKIP_DEV=1");
143
179
  }
144
- const env = getPlaygroundEnvironment();
145
180
  const packageManager = process.env.PACKAGE_MANAGER || "pnpm";
146
- const devResult = await runDevServer(packageManager, env.projectDir);
147
- const serverId = `devServer-${Date.now()}-${Math.random()
148
- .toString(36)
149
- .substring(2, 9)}`;
150
- // Register automatic cleanup
151
- registerCleanupTask({
152
- id: serverId,
153
- type: "devServer",
154
- cleanup: devResult.stopDev,
181
+ const devResult = await pollValue(() => runDevServer(packageManager, projectDir), {
182
+ timeout: DEV_SERVER_TIMEOUT,
183
+ minTries: DEV_SERVER_MIN_TRIES,
184
+ onRetry: (error, tries) => {
185
+ console.log(`Retrying dev server creation (attempt ${tries})... Error: ${error.message}`);
186
+ },
155
187
  });
156
188
  return {
157
189
  url: devResult.url,
158
- stopDev: async () => {
159
- await devResult.stopDev();
160
- unregisterCleanupTask(serverId); // Remove from auto-cleanup since manually cleaned
161
- },
190
+ stopDev: devResult.stopDev,
162
191
  };
163
192
  }
164
193
  /**
165
194
  * Creates a deployment instance using the shared playground environment.
166
195
  * Automatically registers cleanup to run after the test.
167
196
  */
168
- export async function createDeployment() {
197
+ export async function createDeployment(projectDir) {
169
198
  if (SKIP_DEPLOYMENT_TESTS) {
170
199
  throw new Error("Deployment tests are skipped via RWSDK_SKIP_DEPLOY=1");
171
200
  }
172
- const env = getPlaygroundEnvironment();
173
- // Extract the unique key from the project directory name instead of generating a new one
174
- // The directory name format is: {projectName}-e2e-test-{randomId}
175
- const dirName = basename(env.projectDir);
176
- const match = dirName.match(/-e2e-test-([a-f0-9]+)$/);
177
- const resourceUniqueKey = match
178
- ? match[1]
179
- : Math.random().toString(36).substring(2, 15);
180
- const deployResult = await runRelease(env.projectDir, env.projectDir, resourceUniqueKey);
181
- // Poll the URL to ensure it's live before proceeding
182
- await poll(async () => {
183
- try {
184
- const response = await fetch(deployResult.url);
185
- // We consider any response (even 4xx or 5xx) as success,
186
- // as it means the worker is routable.
187
- return response.status > 0;
188
- }
189
- catch (e) {
190
- return false;
191
- }
192
- }, 60000);
193
- const deploymentId = `deployment-${Date.now()}-${Math.random()
194
- .toString(36)
195
- .substring(2, 9)}`;
196
- // Register automatic cleanup (non-blocking for deployments)
197
- registerCleanupTask({
198
- id: deploymentId,
199
- type: "deployment",
200
- cleanup: async () => {
201
+ return await pollValue(async () => {
202
+ // Extract the unique key from the project directory name instead of generating a new one
203
+ // The directory name format is: {projectName}-e2e-test-{randomId}
204
+ const dirName = basename(projectDir);
205
+ const match = dirName.match(/-e2e-test-([a-f0-9]+)$/);
206
+ const resourceUniqueKey = match
207
+ ? match[1]
208
+ : Math.random().toString(36).substring(2, 15);
209
+ const deployResult = await runRelease(projectDir, projectDir, resourceUniqueKey);
210
+ // Poll the URL to ensure it's live before proceeding
211
+ await poll(async () => {
212
+ try {
213
+ const response = await fetch(deployResult.url);
214
+ // We consider any response (even 4xx or 5xx) as success,
215
+ // as it means the worker is routable.
216
+ return response.status > 0;
217
+ }
218
+ catch (e) {
219
+ return false;
220
+ }
221
+ }, {
222
+ timeout: DEPLOYMENT_CHECK_TIMEOUT,
223
+ });
224
+ const cleanup = async () => {
201
225
  // Run deployment cleanup in background without blocking
202
226
  const performCleanup = async () => {
203
227
  if (isRelatedToTest(deployResult.workerName, resourceUniqueKey)) {
204
- await deleteWorker(deployResult.workerName, env.projectDir, resourceUniqueKey);
228
+ await deleteWorker(deployResult.workerName, projectDir, resourceUniqueKey);
205
229
  }
206
- await deleteD1Database(resourceUniqueKey, env.projectDir, resourceUniqueKey);
230
+ await deleteD1Database(resourceUniqueKey, projectDir, resourceUniqueKey);
207
231
  };
208
232
  // Start cleanup in background and return immediately
209
233
  performCleanup().catch((error) => {
210
234
  console.warn(`Warning: Background deployment cleanup failed: ${error.message}`);
211
235
  });
212
236
  return Promise.resolve();
237
+ };
238
+ return {
239
+ url: deployResult.url,
240
+ workerName: deployResult.workerName,
241
+ resourceUniqueKey,
242
+ projectDir: projectDir,
243
+ cleanup,
244
+ };
245
+ }, {
246
+ timeout: DEPLOYMENT_TIMEOUT,
247
+ minTries: DEPLOYMENT_MIN_TRIES,
248
+ onRetry: (error, tries) => {
249
+ console.log(`Retrying deployment creation (attempt ${tries})... Error: ${error.message}`);
213
250
  },
214
251
  });
215
- return {
216
- url: deployResult.url,
217
- workerName: deployResult.workerName,
218
- resourceUniqueKey,
219
- projectDir: env.projectDir,
220
- };
221
- }
222
- /**
223
- * Manually cleans up a deployment instance (deletes worker and D1 database).
224
- * This is optional since cleanup happens automatically after each test.
225
- */
226
- export async function cleanupDeployment(deployment) {
227
- console.log(`🧹 Cleaning up deployment: ${deployment.workerName} (${deployment.resourceUniqueKey})`);
228
- const env = getPlaygroundEnvironment();
229
- if (isRelatedToTest(deployment.workerName, deployment.resourceUniqueKey)) {
230
- await deleteWorker(deployment.workerName, env.projectDir, deployment.resourceUniqueKey);
231
- }
232
- await deleteD1Database(deployment.resourceUniqueKey, env.projectDir, deployment.resourceUniqueKey);
233
- // Remove from auto-cleanup registry since manually cleaned
234
- const deploymentId = cleanupTasks.find((task) => task.type === "deployment" &&
235
- task.id.includes(deployment.resourceUniqueKey))?.id;
236
- if (deploymentId) {
237
- unregisterCleanupTask(deploymentId);
238
- }
239
- }
240
- /**
241
- * Creates a browser instance for testing.
242
- * Automatically registers cleanup to run after the test.
243
- */
244
- export async function createBrowser() {
245
- // Check if we should run in headed mode for debugging
246
- const headless = process.env.RWSDK_HEADLESS !== "false";
247
- const browser = await launchBrowser(undefined, headless);
248
- const browserId = `browser-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
249
- // Register automatic cleanup
250
- registerCleanupTask({
251
- id: browserId,
252
- type: "browser",
253
- cleanup: async () => {
254
- try {
255
- await browser.close();
256
- }
257
- catch (error) {
258
- // Browser might already be closed, ignore the error
259
- }
260
- },
261
- });
262
- // Wrap the close method to handle cleanup registration
263
- const originalClose = browser.close.bind(browser);
264
- browser.close = async () => {
265
- await originalClose();
266
- unregisterCleanupTask(browserId); // Remove from auto-cleanup since manually closed
267
- };
268
- return browser;
269
252
  }
270
253
  /**
271
254
  * Executes a test function with a retry mechanism for specific error codes.
@@ -281,23 +264,14 @@ export async function runTestWithRetries(name, attemptFn) {
281
264
  let attempt = 0;
282
265
  while (true) {
283
266
  attempt++;
284
- let cleanup;
285
267
  try {
286
- const res = await attemptFn();
287
- cleanup = res.cleanup;
268
+ await attemptFn();
288
269
  if (attempt > 1) {
289
270
  console.log(`[runTestWithRetries] Test "${name}" succeeded on attempt ${attempt}.`);
290
271
  }
291
- // On success, we don't run cleanup here. It will be handled by afterEach.
292
272
  return; // Success
293
273
  }
294
274
  catch (e) {
295
- // On failure, run the cleanup from the failed attempt.
296
- // The cleanup function is attached to the error object on failure.
297
- const errorCleanup = e.cleanup;
298
- if (typeof errorCleanup === "function") {
299
- await errorCleanup().catch((err) => console.warn(`[runTestWithRetries] Cleanup failed for "${name}" during retry:`, err));
300
- }
301
275
  const errorCode = e?.code;
302
276
  if (typeof errorCode === "string" && errorCode) {
303
277
  const count = (retryCounts[errorCode] || 0) + 1;
@@ -319,154 +293,100 @@ export async function runTestWithRetries(name, attemptFn) {
319
293
  }
320
294
  }
321
295
  }
322
- /**
323
- * High-level test wrapper for dev server tests.
324
- * Automatically skips if RWSDK_SKIP_DEV=1
325
- */
326
- export function testDev(name, testFn) {
327
- if (SKIP_DEV_SERVER_TESTS) {
328
- test.skip(name, testFn);
329
- return;
330
- }
331
- test(name, async () => {
332
- await runTestWithRetries(name, async () => {
333
- const devServer = await createDevServer();
334
- const browser = await createBrowser();
335
- const page = await browser.newPage();
336
- page.setDefaultTimeout(PUPPETEER_TIMEOUT);
337
- const cleanup = async () => {
338
- await browser.close();
339
- await devServer.stopDev();
340
- };
341
- try {
342
- await testFn({
343
- devServer,
344
- browser,
345
- page,
346
- url: devServer.url,
296
+ function createTestRunner(testFn, envType) {
297
+ return (name, testLogic) => {
298
+ if ((envType === "dev" && SKIP_DEV_SERVER_TESTS) ||
299
+ (envType === "deploy" && SKIP_DEPLOYMENT_TESTS)) {
300
+ test.skip(name, () => { });
301
+ return;
302
+ }
303
+ describe.concurrent(name, () => {
304
+ let page;
305
+ let instance;
306
+ let browser;
307
+ beforeAll(async () => {
308
+ const tempDir = path.join(os.tmpdir(), "rwsdk-e2e-tests");
309
+ const wsEndpointFile = path.join(tempDir, "wsEndpoint");
310
+ try {
311
+ const wsEndpoint = await fs.readFile(wsEndpointFile, "utf-8");
312
+ browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint });
313
+ }
314
+ catch (error) {
315
+ console.warn("Failed to connect to existing browser instance. " +
316
+ "This might happen if you are running a single test file. " +
317
+ "Launching a new browser instance instead.");
318
+ browser = await launchBrowser();
319
+ }
320
+ }, SETUP_WAIT_TIMEOUT);
321
+ afterAll(async () => {
322
+ if (browser) {
323
+ await browser.disconnect();
324
+ }
325
+ });
326
+ beforeEach(async () => {
327
+ const instancePromise = envType === "dev"
328
+ ? globalDevInstancePromise
329
+ : globalDeploymentInstancePromise;
330
+ if (!instancePromise) {
331
+ throw new Error("Test environment promises not initialized. Call setupPlaygroundEnvironment() in your test file.");
332
+ }
333
+ [instance] = await Promise.all([instancePromise]);
334
+ if (!instance) {
335
+ throw new Error(`No ${envType} instance found. Make sure to enable it in setupPlaygroundEnvironment.`);
336
+ }
337
+ page = await browser.newPage();
338
+ page.setDefaultTimeout(PUPPETEER_TIMEOUT);
339
+ }, SETUP_WAIT_TIMEOUT);
340
+ afterEach(async () => {
341
+ if (page) {
342
+ try {
343
+ await page.close();
344
+ }
345
+ catch (error) {
346
+ // Suppress errors during page close, as the browser might already be disconnecting
347
+ // due to the test suite finishing.
348
+ console.warn(`Suppressing error during page.close() in test "${name}":`, error);
349
+ }
350
+ }
351
+ });
352
+ testFn(">", async () => {
353
+ if (!instance || !browser) {
354
+ throw new Error("Test environment not ready.");
355
+ }
356
+ await runTestWithRetries(name, async () => {
357
+ await testLogic({
358
+ [envType === "dev" ? "devServer" : "deployment"]: instance,
359
+ browser: browser,
360
+ page: page,
361
+ url: instance.url,
362
+ });
347
363
  });
348
- return { cleanup };
349
- }
350
- catch (error) {
351
- // Ensure cleanup is available to the retry wrapper even if testFn fails.
352
- // We re-throw the error to be handled by runTestWithRetries.
353
- throw Object.assign(error, { cleanup });
354
- }
364
+ });
355
365
  });
356
- });
366
+ };
357
367
  }
358
368
  /**
359
- * Skip version of testDev
369
+ * High-level test wrapper for dev server tests.
370
+ * Automatically skips if RWSDK_SKIP_DEV=1
360
371
  */
372
+ export function testDev(...args) {
373
+ return createTestRunner(test.concurrent, "dev")(...args);
374
+ }
361
375
  testDev.skip = (name, testFn) => {
362
376
  test.skip(name, testFn || (() => { }));
363
377
  };
364
- testDev.only = (name, testFn) => {
365
- if (SKIP_DEV_SERVER_TESTS) {
366
- test.skip(name, () => { });
367
- return;
368
- }
369
- test.only(name, async () => {
370
- await runTestWithRetries(name, async () => {
371
- const devServer = await createDevServer();
372
- const browser = await createBrowser();
373
- const page = await browser.newPage();
374
- page.setDefaultTimeout(PUPPETEER_TIMEOUT);
375
- const cleanup = async () => {
376
- await browser.close();
377
- await devServer.stopDev();
378
- };
379
- try {
380
- await testFn({
381
- devServer,
382
- browser,
383
- page,
384
- url: devServer.url,
385
- });
386
- return { cleanup };
387
- }
388
- catch (error) {
389
- // Ensure cleanup is available to the retry wrapper even if testFn fails.
390
- // We re-throw the error to be handled by runTestWithRetries.
391
- throw Object.assign(error, { cleanup });
392
- }
393
- });
394
- });
395
- };
378
+ testDev.only = createTestRunner(test.only, "dev");
396
379
  /**
397
380
  * High-level test wrapper for deployment tests.
398
381
  * Automatically skips if RWSDK_SKIP_DEPLOY=1
399
382
  */
400
- export function testDeploy(name, testFn) {
401
- if (SKIP_DEPLOYMENT_TESTS) {
402
- test.skip(name, testFn);
403
- return;
404
- }
405
- test(name, async () => {
406
- await runTestWithRetries(name, async () => {
407
- const deployment = await createDeployment();
408
- const browser = await createBrowser();
409
- const page = await browser.newPage();
410
- page.setDefaultTimeout(PUPPETEER_TIMEOUT);
411
- const cleanup = async () => {
412
- // We don't await this because we want to let it run in the background
413
- // The afterEach hook for deployments already does this.
414
- await cleanupDeployment(deployment);
415
- await browser.close();
416
- };
417
- try {
418
- await testFn({
419
- deployment,
420
- browser,
421
- page,
422
- url: deployment.url,
423
- });
424
- return { cleanup };
425
- }
426
- catch (error) {
427
- throw Object.assign(error, { cleanup });
428
- }
429
- });
430
- });
383
+ export function testDeploy(...args) {
384
+ return createTestRunner(test.concurrent, "deploy")(...args);
431
385
  }
432
- /**
433
- * Skip version of testDeploy
434
- */
435
386
  testDeploy.skip = (name, testFn) => {
436
387
  test.skip(name, testFn || (() => { }));
437
388
  };
438
- testDeploy.only = (name, testFn) => {
439
- if (SKIP_DEPLOYMENT_TESTS) {
440
- test.skip(name, () => { });
441
- return;
442
- }
443
- test.only(name, async () => {
444
- await runTestWithRetries(name, async () => {
445
- const deployment = await createDeployment();
446
- const browser = await createBrowser();
447
- const page = await browser.newPage();
448
- page.setDefaultTimeout(PUPPETEER_TIMEOUT);
449
- const cleanup = async () => {
450
- // We don't await this because we want to let it run in the background
451
- // The afterEach hook for deployments already does this.
452
- await cleanupDeployment(deployment);
453
- await browser.close();
454
- };
455
- try {
456
- await testFn({
457
- deployment,
458
- browser,
459
- page,
460
- url: deployment.url,
461
- });
462
- return { cleanup };
463
- }
464
- catch (error) {
465
- throw Object.assign(error, { cleanup });
466
- }
467
- });
468
- });
469
- };
389
+ testDeploy.only = createTestRunner(test.only, "deploy");
470
390
  /**
471
391
  * Unified test function that runs the same test against both dev server and deployment.
472
392
  * Automatically skips based on environment variables.
@@ -486,22 +406,32 @@ testDevAndDeploy.only = (name, testFn) => {
486
406
  testDeploy.only(`${name} (deployment)`, testFn);
487
407
  };
488
408
  /**
489
- * Utility function for polling/retrying assertions
409
+ * Waits for the page to be fully loaded and hydrated.
410
+ * This should be used before any user interaction is simulated.
490
411
  */
491
- export async function poll(fn, timeout = 2 * 60 * 1000, // 2 minutes
492
- interval = 100) {
493
- const startTime = Date.now();
494
- while (Date.now() - startTime < timeout) {
495
- try {
496
- const result = await fn();
497
- if (result) {
498
- return;
499
- }
500
- }
501
- catch (error) {
502
- // Continue polling on errors
412
+ export async function waitForHydration(page) {
413
+ // 1. Wait for the document to be fully loaded.
414
+ await page.waitForFunction('document.readyState === "complete"');
415
+ // 2. Wait a short, fixed amount of time for client-side hydration to finish.
416
+ // This is a pragmatic approach to ensure React has mounted.
417
+ await new Promise((resolve) => setTimeout(resolve, HYDRATION_TIMEOUT));
418
+ }
419
+ export function trackPageErrors(page) {
420
+ const consoleErrors = [];
421
+ const failedRequests = [];
422
+ page.on("requestfailed", (request) => {
423
+ failedRequests.push(`${request.url()} | ${request.failure()?.errorText}`);
424
+ });
425
+ page.on("console", (msg) => {
426
+ if (msg.type() === "error") {
427
+ consoleErrors.push(msg.text());
503
428
  }
504
- await new Promise((resolve) => setTimeout(resolve, interval));
505
- }
506
- throw new Error(`Polling timed out after ${timeout}ms`);
429
+ });
430
+ return {
431
+ get: () => ({
432
+ // context(justinvdm, 25 Sep 2025): Filter out irrelevant 404s (e.g. favicon)
433
+ consoleErrors: consoleErrors.filter((e) => !e.includes("404")),
434
+ failedRequests,
435
+ }),
436
+ };
507
437
  }