rwsdk
Version:
Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime
532 lines (531 loc) • 22.9 kB
JavaScript
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,
}),
};
}