UNPKG

rwsdk

Version:

Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime

532 lines (531 loc) 22.9 kB
import fs from "fs-extra"; import path, { basename, dirname, join as pathJoin } from "path"; import puppeteer from "puppeteer-core"; import { afterAll, afterEach, beforeAll, beforeEach, describe, test, } from "vitest"; import { launchBrowser } from "./browser.mjs"; import { ensureTmpDir } from "./utils.mjs"; import { DEPLOYMENT_CHECK_TIMEOUT, DEPLOYMENT_MIN_TRIES, DEPLOYMENT_TIMEOUT, DEV_SERVER_MIN_TRIES, DEV_SERVER_TIMEOUT, HYDRATION_TIMEOUT, INSTALL_DEPENDENCIES_RETRIES, PUPPETEER_TIMEOUT, SETUP_PLAYGROUND_ENV_TIMEOUT, SETUP_WAIT_TIMEOUT, TEST_MAX_RETRIES, TEST_MAX_RETRIES_PER_CODE, } from "./constants.mjs"; import { runDevServer } from "./dev.mjs"; import { poll, pollValue } from "./poll.mjs"; import { deleteD1Database, deleteWorker, isRelatedToTest, runRelease, } from "./release.mjs"; import { setupTarballEnvironment } from "./tarball.mjs"; import { fileURLToPath } from "url"; export { DEPLOYMENT_CHECK_TIMEOUT, DEPLOYMENT_MIN_TRIES, DEPLOYMENT_TIMEOUT, DEV_SERVER_MIN_TRIES, DEV_SERVER_TIMEOUT, HYDRATION_TIMEOUT, INSTALL_DEPENDENCIES_RETRIES, PUPPETEER_TIMEOUT, SETUP_PLAYGROUND_ENV_TIMEOUT, SETUP_WAIT_TIMEOUT, TEST_MAX_RETRIES, TEST_MAX_RETRIES_PER_CODE, }; // Environment variable flags for skipping tests const SKIP_DEV_SERVER_TESTS = process.env.RWSDK_SKIP_DEV === "1"; const SKIP_DEPLOYMENT_TESTS = process.env.RWSDK_SKIP_DEPLOY === "1"; // Global test environment state let globalDevPlaygroundEnv = null; let globalDeployPlaygroundEnv = null; let globalDevInstancePromise = null; let globalDeploymentInstancePromise = null; let globalDevInstance = null; let globalDeploymentInstance = null; const devInstances = []; const deploymentInstances = []; let hooksRegistered = false; /** * Registers global cleanup hooks automatically */ function ensureHooksRegistered() { if (hooksRegistered) return; // Register global afterAll to clean up the playground environment afterAll(async () => { const cleanupPromises = []; for (const instance of devInstances) { cleanupPromises.push(instance.stopDev().catch((error) => { // Suppress all cleanup errors - they don't affect test results console.warn(`Suppressing error during dev server cleanup: ${error instanceof Error ? error.message : String(error)}`); })); } for (const instance of deploymentInstances) { cleanupPromises.push(instance.cleanup().catch((error) => { // Suppress all cleanup errors - they don't affect test results console.warn(`Suppressing error during deployment cleanup: ${error instanceof Error ? error.message : String(error)}`); })); } if (globalDevPlaygroundEnv) { cleanupPromises.push(globalDevPlaygroundEnv.cleanup().catch((error) => { // Suppress all cleanup errors - they don't affect test results console.warn(`Suppressing error during dev environment cleanup: ${error instanceof Error ? error.message : String(error)}`); })); } if (globalDeployPlaygroundEnv) { cleanupPromises.push(globalDeployPlaygroundEnv.cleanup().catch((error) => { // Suppress all cleanup errors - they don't affect test results console.warn(`Suppressing error during deploy environment cleanup: ${error instanceof Error ? error.message : String(error)}`); })); } await Promise.all(cleanupPromises); devInstances.length = 0; deploymentInstances.length = 0; globalDevPlaygroundEnv = null; globalDeployPlaygroundEnv = null; }); hooksRegistered = true; } /** * Get the project directory for the current test by looking at the call stack */ function getProjectDirectory() { // For now, let's hardcode this to '../playground/hello-world' since we only have one project // TODO: Make this more dynamic when we have multiple playground projects return "../playground/hello-world"; } /** * Derive the playground directory from import.meta.url by finding the nearest package.json */ function getPlaygroundDirFromImportMeta(importMetaUrl) { const testFilePath = fileURLToPath(importMetaUrl); let currentDir = dirname(testFilePath); // Walk up the tree from the test file's directory // Stop when the parent directory is the same as the current directory (we've reached the root) while (dirname(currentDir) !== currentDir) { // Check if a package.json exists in the current directory if (fs.existsSync(pathJoin(currentDir, "package.json"))) { return currentDir; } currentDir = dirname(currentDir); } throw new Error(`Could not determine playground directory from import.meta.url: ${importMetaUrl}. ` + `Failed to find a package.json in any parent directory.`); } /** * A Vitest hook that sets up a playground environment for a test file. * It creates a temporary directory, copies the playground project into it, * and installs dependencies using a tarball of the SDK. * This ensures that tests run in a clean, isolated environment. */ export function setupPlaygroundEnvironment(options) { const { sourceProjectDir, monorepoRoot, dev = true, deploy = true, autoStartDevServer = true, } = typeof options === "string" ? { sourceProjectDir: options, autoStartDevServer: true } : options; ensureHooksRegistered(); beforeAll(async () => { let projectDir; if (!sourceProjectDir) { projectDir = getProjectDirectory(); } else if (sourceProjectDir.startsWith("file://")) { // This is import.meta.url, derive the playground directory projectDir = getPlaygroundDirFromImportMeta(sourceProjectDir); } else { // This is an explicit path projectDir = sourceProjectDir; } console.log(`Setting up playground environment from ${projectDir}...`); if (dev) { const devEnv = await setupTarballEnvironment({ projectDir, monorepoRoot, packageManager: process.env.PACKAGE_MANAGER || "pnpm", }); globalDevPlaygroundEnv = { projectDir: devEnv.targetDir, cleanup: devEnv.cleanup, }; if (autoStartDevServer) { const devControl = createDevServer(); globalDevInstancePromise = devControl.start().then((instance) => { globalDevInstance = instance; return instance; }); // Prevent unhandled promise rejections. The error will be handled inside // the test's beforeEach hook where this promise is awaited. globalDevInstancePromise.catch(() => { }); } } else { globalDevPlaygroundEnv = null; } if (deploy && !SKIP_DEPLOYMENT_TESTS) { const deployEnv = await setupTarballEnvironment({ projectDir, monorepoRoot, packageManager: process.env.PACKAGE_MANAGER || "pnpm", }); globalDeployPlaygroundEnv = { projectDir: deployEnv.targetDir, cleanup: deployEnv.cleanup, }; const deployControl = createDeployment(); globalDeploymentInstancePromise = deployControl .start() .then((instance) => { globalDeploymentInstance = instance; return instance; }); // Prevent unhandled promise rejections globalDeploymentInstancePromise.catch(() => { }); } else { globalDeployPlaygroundEnv = null; } }, SETUP_PLAYGROUND_ENV_TIMEOUT); } /** * Creates a dev server instance using the shared playground environment. * Automatically registers cleanup to run after the test. */ export function createDevServer() { ensureHooksRegistered(); if (!globalDevPlaygroundEnv) { throw new Error("Dev playground environment not initialized. Enable `dev: true` in setupPlaygroundEnvironment."); } const { projectDir } = globalDevPlaygroundEnv; const packageManager = process.env.PACKAGE_MANAGER || "pnpm"; let instance = null; return { projectDir, start: async () => { if (instance) return instance; if (SKIP_DEV_SERVER_TESTS) { throw new Error("Dev server tests are skipped via RWSDK_SKIP_DEV=1"); } const devResult = await pollValue(() => runDevServer(packageManager, projectDir), { timeout: DEV_SERVER_TIMEOUT, minTries: DEV_SERVER_MIN_TRIES, onRetry: (error, tries) => { console.log(`Retrying dev server creation (attempt ${tries})... Error: ${error.message}`); }, }); instance = { url: devResult.url, projectDir, stopDev: devResult.stopDev, }; devInstances.push(instance); return instance; }, }; } /** * Creates a deployment instance using the shared playground environment. * Automatically registers cleanup to run after the test. */ export function createDeployment() { ensureHooksRegistered(); if (!globalDeployPlaygroundEnv) { throw new Error("Deploy playground environment not initialized. Enable `deploy: true` in setupPlaygroundEnvironment."); } const { projectDir } = globalDeployPlaygroundEnv; let instance = null; return { projectDir, start: async () => { if (instance) return instance; if (SKIP_DEPLOYMENT_TESTS) { throw new Error("Deployment tests are skipped via RWSDK_SKIP_DEPLOY=1"); } const newInstance = await pollValue(async () => { const dirName = basename(projectDir); const match = dirName.match(/-e2e-test-([a-f0-9]+)$/); const resourceUniqueKey = match ? match[1] : Math.random().toString(36).substring(2, 15); const deployResult = await runRelease(projectDir, projectDir, resourceUniqueKey); await poll(async () => { try { const response = await fetch(deployResult.url); return response.status > 0; } catch (e) { return false; } }, { timeout: DEPLOYMENT_CHECK_TIMEOUT, }); const cleanup = async () => { const performCleanup = async () => { if (isRelatedToTest(deployResult.workerName, resourceUniqueKey)) { await deleteWorker(deployResult.workerName, projectDir, resourceUniqueKey); } await deleteD1Database(resourceUniqueKey, projectDir, resourceUniqueKey); }; performCleanup().catch((error) => { console.warn(`Warning: Background deployment cleanup failed: ${error.message}`); }); return Promise.resolve(); }; return { url: deployResult.url, workerName: deployResult.workerName, resourceUniqueKey, projectDir: projectDir, cleanup, }; }, { timeout: DEPLOYMENT_TIMEOUT, minTries: DEPLOYMENT_MIN_TRIES, onRetry: (error, tries) => { console.log(`Retrying deployment creation (attempt ${tries})... Error: ${error.message}`); }, }); deploymentInstances.push(newInstance); return newInstance; }, }; } /** * Executes a test function with a retry mechanism for specific error codes. * @param name - The name of the test, used for logging. * @param attemptFn - A function that executes one attempt of the test. * It should set up resources, run the test logic, and * return a cleanup function. The cleanup function will be * called automatically on failure. */ export async function runTestWithRetries(name, attemptFn) { const retryCounts = {}; let attempt = 0; let lastError; while (attempt < TEST_MAX_RETRIES) { attempt++; try { await attemptFn(); if (attempt > 1) { console.log(`[runTestWithRetries] Test "${name}" succeeded on attempt ${attempt}.`); } return; // Success } catch (e) { lastError = e; const errorCode = e?.code; if (typeof errorCode === "string" && errorCode) { const count = (retryCounts[errorCode] || 0) + 1; retryCounts[errorCode] = count; if (count > TEST_MAX_RETRIES_PER_CODE) { console.error(`[runTestWithRetries] Test "${name}" failed with code ${errorCode} after ${count - 1} retries. Max per-code retries (${TEST_MAX_RETRIES_PER_CODE}) exceeded.`); throw e; // Give up for this specific error code } } if (attempt < TEST_MAX_RETRIES) { console.log(`[runTestWithRetries] Attempt ${attempt}/${TEST_MAX_RETRIES} for "${name}" failed. Retrying...`); await new Promise((resolve) => setTimeout(resolve, 1000)); } else { console.error(`[runTestWithRetries] Test "${name}" failed after ${attempt} attempts.`); } } } throw lastError; } function createTestRunner(testFn, envType) { return (name, testLogic) => { if ((envType === "dev" && SKIP_DEV_SERVER_TESTS) || (envType === "deploy" && SKIP_DEPLOYMENT_TESTS)) { test.skip(`${name} (${envType})`, () => { }); return; } describe.concurrent(name, () => { let page; let instance; let browser; beforeAll(async () => { const tempDir = path.join(await ensureTmpDir(), "rwsdk-e2e-tests"); const wsEndpointFile = path.join(tempDir, "wsEndpoint"); try { const wsEndpoint = await fs.readFile(wsEndpointFile, "utf-8"); browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint }); } catch (error) { console.warn("Failed to connect to existing browser instance. " + "This might happen if you are running a single test file. " + "Launching a new browser instance instead."); browser = await launchBrowser(); } }, SETUP_WAIT_TIMEOUT); afterAll(async () => { if (browser) { await browser.disconnect(); } }); beforeEach(async () => { const instancePromise = envType === "dev" ? globalDevInstancePromise : globalDeploymentInstancePromise; if (!instancePromise) { throw new Error("Test environment promises not initialized. Call setupPlaygroundEnvironment() in your test file."); } [instance] = await Promise.all([instancePromise]); if (!instance) { throw new Error(`No ${envType} instance found. Make sure to enable it in setupPlaygroundEnvironment.`); } page = await browser.newPage(); page.setDefaultTimeout(PUPPETEER_TIMEOUT); }, SETUP_WAIT_TIMEOUT); afterEach(async () => { if (page) { try { await page.close(); } catch (error) { // Suppress errors during page close, as the browser might already be disconnecting // due to the test suite finishing. console.warn(`Suppressing error during page.close() in test "${name}":`, error); } } }); testFn(">", async () => { if (!instance || !browser) { throw new Error("Test environment not ready."); } await runTestWithRetries(name, async () => { await testLogic({ [envType === "dev" ? "devServer" : "deployment"]: instance, browser: browser, page: page, url: instance.url, projectDir: instance .projectDir, }); }); }); }); }; } /** * Creates a low-level test runner that provides utilities for creating * tests that need to perform setup actions before the server starts. */ function createSDKTestRunner() { const internalRunner = (testFn) => { return (name, testLogic) => { describe.concurrent(name, () => { let page; let browser; beforeAll(async () => { const tempDir = path.join(await ensureTmpDir(), "rwsdk-e2e-tests"); const wsEndpointFile = path.join(tempDir, "wsEndpoint"); try { const wsEndpoint = await fs.readFile(wsEndpointFile, "utf-8"); browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint, }); } catch (error) { console.warn("Failed to connect to existing browser instance. " + "This might happen if you are running a single test file. " + "Launching a new browser instance instead."); browser = await launchBrowser(); } }, SETUP_WAIT_TIMEOUT); afterAll(async () => { if (browser) { await browser.disconnect(); } }); beforeEach(async () => { if (!globalDevPlaygroundEnv && !globalDeployPlaygroundEnv) { throw new Error("Test environment not initialized. Call setupPlaygroundEnvironment() in your test file."); } page = await browser.newPage(); page.setDefaultTimeout(PUPPETEER_TIMEOUT); }, SETUP_WAIT_TIMEOUT); afterEach(async () => { if (page) { try { await page.close(); } catch (error) { console.warn(`Suppressing error during page.close() in test "${name}":`, error); } } }); testFn(">", async () => { if (!browser) { throw new Error("Test environment not ready."); } await runTestWithRetries(name, async () => { await testLogic({ browser: browser, page: page, projectDir: globalDevPlaygroundEnv?.projectDir || globalDeployPlaygroundEnv?.projectDir || "", }); }); }); }); }; }; const main = internalRunner(test); return Object.assign(main, { only: internalRunner(test.only), skip: test.skip, }); } export const testSDK = createSDKTestRunner(); /** * High-level test wrapper for dev server tests. * Automatically skips if RWSDK_SKIP_DEV=1 */ export function testDev(...args) { return createTestRunner(test.concurrent, "dev")(...args); } testDev.skip = (name, testFn) => { test.skip(name, testFn || (() => { })); }; testDev.only = createTestRunner(test.only, "dev"); /** * High-level test wrapper for deployment tests. * Automatically skips if RWSDK_SKIP_DEPLOY=1 */ export function testDeploy(...args) { return createTestRunner(test.concurrent, "deploy")(...args); } testDeploy.skip = (name, testFn) => { test.skip(name, testFn || (() => { })); }; testDeploy.only = createTestRunner(test.only, "deploy"); /** * Unified test function that runs the same test against both dev server and deployment. * Automatically skips based on environment variables. */ export function testDevAndDeploy(name, testFn) { testDev(`${name} (dev)`, testFn); testDeploy(`${name} (deployment)`, testFn); } /** * Skip version of testDevAndDeploy */ testDevAndDeploy.skip = (name, testFn) => { test.skip(name, testFn || (() => { })); }; testDevAndDeploy.only = (name, testFn) => { testDev.only(`${name} (dev)`, testFn); testDeploy.only(`${name} (deployment)`, testFn); }; /** * Waits for the page to be fully loaded and hydrated. * This should be used before any user interaction is simulated. */ export async function waitForHydration(page) { // 1. Wait for the document to be fully loaded. await page.waitForFunction('document.readyState === "complete"'); // 2. Wait a short, fixed amount of time for client-side hydration to finish. // This is a pragmatic approach to ensure React has mounted. await new Promise((resolve) => setTimeout(resolve, HYDRATION_TIMEOUT)); } export function trackPageErrors(page) { const consoleErrors = []; const failedRequests = []; page.on("requestfailed", (request) => { failedRequests.push(`${request.url()} | ${request.failure()?.errorText}`); }); page.on("console", (msg) => { if (msg.type() === "error") { consoleErrors.push(msg.text()); } }); return { get: () => ({ // context(justinvdm, 25 Sep 2025): Filter out irrelevant 404s (e.g. favicon) consoleErrors: consoleErrors.filter((e) => !e.includes("404")), failedRequests, }), }; }