UNPKG

rwsdk

Version:

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

1,051 lines • 56.7 kB
import * as os from "os"; import { join } from "path"; import { pathExists } from "fs-extra"; import { log } from "./constants.mjs"; import { mkdirp } from "fs-extra"; import { install, resolveBuildId, computeExecutablePath, detectBrowserPlatform, Browser as PuppeteerBrowser, } from "@puppeteer/browsers"; import puppeteer from "puppeteer-core"; import { takeScreenshot } from "./artifacts.mjs"; import { RETRIES } from "./constants.mjs"; import { $ } from "../$.mjs"; import { fail, withRetries } from "./utils.mjs"; import { reportSmokeTestResult } from "./reporting.mjs"; import { updateTestStatus } from "./state.mjs"; import * as fs from "fs/promises"; import { template as urlStylesTemplate } from "./templates/smokeTestUrlStyles.css.template"; import { template as clientStylesTemplate } from "./templates/smokeTestClientStyles.module.css.template"; export async function checkUrlStyles(page, expectedColor) { const selector = '[data-testid="smoke-test-url-styles"]'; log(`Checking for element with selector: ${selector}`); const element = await page.waitForSelector(selector); if (!element) { throw new Error(`URL styles element not found with selector: ${selector}`); } const expectedRgb = expectedColor === "red" ? "rgb(255, 0, 0)" : "rgb(0, 128, 0)"; log(`Waiting for URL styles to apply with expected color: ${expectedRgb}`); try { // Wait for the background color to match the expected value with a timeout await page.waitForFunction((expectedRgb) => { const element = document.querySelector('[data-testid="smoke-test-url-styles"]'); if (!element) return false; const backgroundColor = window.getComputedStyle(element).backgroundColor; return backgroundColor === expectedRgb; }, { timeout: 10000 }, // 10 second timeout expectedRgb); // Get the final background color for logging const backgroundColor = await page.evaluate(() => window.getComputedStyle(document.querySelector('[data-testid="smoke-test-url-styles"]')).backgroundColor); log(`URL-based stylesheet check passed: background color is ${backgroundColor}`); } catch (error) { // Get the actual background color for better error reporting const actualBackgroundColor = await page.evaluate(() => { const element = document.querySelector('[data-testid="smoke-test-url-styles"]'); return element ? window.getComputedStyle(element).backgroundColor : "element not found"; }); throw new Error(`URL-based stylesheet check failed: expected background color ${expectedRgb}, but got ${actualBackgroundColor} (timeout after 10 seconds)`); } } export async function checkClientModuleStyles(page, expectedColor) { const selector = '[data-testid="smoke-test-client-styles"]'; log(`Checking for element with selector: ${selector}`); const element = await page.waitForSelector(selector); if (!element) { throw new Error(`Client module styles element not found with selector: ${selector}`); } const expectedRgb = expectedColor === "blue" ? "rgb(0, 0, 255)" : "rgb(0, 128, 0)"; log(`Waiting for client module styles to apply with expected color: ${expectedRgb}`); try { // Wait for the background color to match the expected value with a timeout await page.waitForFunction((expectedRgb) => { const element = document.querySelector('[data-testid="smoke-test-client-styles"]'); if (!element) return false; const backgroundColor = window.getComputedStyle(element).backgroundColor; return backgroundColor === expectedRgb; }, { timeout: 10000 }, // 10 second timeout expectedRgb); // Get the final background color for logging const backgroundColor = await page.evaluate(() => window.getComputedStyle(document.querySelector('[data-testid="smoke-test-client-styles"]')).backgroundColor); log(`Client module stylesheet check passed: background color is ${backgroundColor}`); } catch (error) { // Get the actual background color for better error reporting const actualBackgroundColor = await page.evaluate(() => { const element = document.querySelector('[data-testid="smoke-test-client-styles"]'); return element ? window.getComputedStyle(element).backgroundColor : "element not found"; }); throw new Error(`Client module stylesheet check failed: expected background color ${expectedRgb}, but got ${actualBackgroundColor} (timeout after 10 seconds)`); } } /** * Launch a browser instance */ export async function launchBrowser(browserPath, headless = true) { // Get browser path if not provided if (!browserPath) { log("Getting browser executable path"); browserPath = await getBrowserPath(); } console.log(`šŸš€ Launching browser from ${browserPath} (headless: ${headless})`); log("Starting browser with puppeteer"); return await puppeteer.launch({ executablePath: browserPath, headless, args: ["--no-sandbox", "--disable-setuid-sandbox"], }); } /** * Get the browser executable path */ export async function getBrowserPath(testOptions) { console.log("Finding Chrome executable..."); // First try using environment variable if set if (process.env.CHROME_PATH) { console.log(`Using Chrome from environment variable: ${process.env.CHROME_PATH}`); return process.env.CHROME_PATH; } // Detect platform log("Detecting platform"); const platform = detectBrowserPlatform(); if (!platform) { log("ERROR: Failed to detect browser platform"); throw new Error("Failed to detect browser platform"); } log("Detected platform: %s", platform); // Define a consistent cache directory path in system temp folder const rwCacheDir = join(os.tmpdir(), "redwoodjs-smoke-test-cache"); await mkdirp(rwCacheDir); log("Using cache directory: %s", rwCacheDir); // Determine browser type based on headless option const browser = testOptions?.headless === false ? PuppeteerBrowser.CHROME : PuppeteerBrowser.CHROMEHEADLESSSHELL; log(`Using browser type: ${browser}`); // Resolve the buildId for the stable Chrome version - do this once log("Resolving Chrome buildId for stable channel"); const buildId = await resolveBuildId(browser, platform, "stable"); log("Resolved buildId: %s", buildId); // Create installation options - use them consistently const installOptions = { browser, platform, cacheDir: rwCacheDir, buildId, unpack: true, }; try { // Try to compute the path first (this will check if it's installed) log("Attempting to find existing Chrome installation"); const path = computeExecutablePath(installOptions); if (await pathExists(path)) { console.log(`Found existing Chrome at: ${path}`); return path; } else { throw new Error("Chrome not found at: " + path); } } catch (error) { // If path computation fails, install Chrome console.log("No Chrome installation found. Installing Chrome..."); // Add better error handling for the install step try { console.log("Downloading Chrome (this may take a few minutes)..."); await install(installOptions); console.log("āœ… Chrome installation completed successfully"); // Now compute the path for the installed browser const path = computeExecutablePath(installOptions); console.log(`Installed and using Chrome at: ${path}`); return path; } catch (installError) { // Provide more detailed error about the browser download failure log("ERROR: Failed to download/install Chrome: %O", installError); console.error(`āŒ Failed to download/install Chrome browser.`); console.error(`This is likely a network issue or the browser download URL is unavailable.`); console.error(`Error details: ${installError instanceof Error ? installError.message : String(installError)}`); // For debug builds, show the full error stack if available if (installError instanceof Error && installError.stack) { log("Error stack: %s", installError.stack); } console.log("\nPossible solutions:"); console.log("1. Check your internet connection"); console.log("2. Set CHROME_PATH environment variable to an existing Chrome installation"); console.log("3. Install Chrome manually and run the tests again"); throw new Error(`Failed to install Chrome browser: ${installError instanceof Error ? installError.message : String(installError)}`); } } } /** * Check a URL by performing smoke tests and realtime upgrade */ export async function checkUrl(url, artifactDir, browserPath, headless = true, bail = false, skipClient = false, environment = "Development", realtime = false, targetDir, skipHmr = false, skipStyleTests = false) { console.log(`šŸ” Testing URL: ${url}`); log("Launching browser"); let browser; try { browser = await launchBrowser(browserPath, headless); } catch (error) { // Use fail() directly for browser launch errors await fail(error, 1, `${environment} - Browser Launch`); return; // This will never be reached due to fail() exiting } // Track failures to report at the end let hasFailures = false; let initialTestError = null; let realtimeTestError = null; // Store timestamp values between test phases const timestampState = { initialServerValue: 23, // This is the default initial module-level value clientUpdatedValue: null, }; try { log("Opening new page"); const page = await browser.newPage(); page.setDefaultNavigationTimeout(30000); log("Set navigation timeout: %dms", 30000); if (realtime) { // If realtime flag is set, use the simplified flow that only does realtime testing log("Using realtime-only flow (--realtime option enabled)"); console.log("ā© Skipping initial smoke tests and upgrade step (--realtime option enabled)"); // Skip upgradeToRealtime and just run the realtime tests directly try { log("Performing realtime-only smoke test"); await realtimeOnlyFlow(page, url, artifactDir, bail, skipClient, environment, targetDir, skipHmr, skipStyleTests); // Take a screenshot of the realtime test await takeScreenshot(page, url, artifactDir, `${environment.toLowerCase()}-realtime-passed`); } catch (error) { hasFailures = true; realtimeTestError = error instanceof Error ? error : new Error(String(error)); log("Error during realtime-only test: %O", error); console.error(`āŒ Realtime test failed: ${realtimeTestError.message}`); // Take a failure screenshot await takeScreenshot(page, url, artifactDir, `${environment.toLowerCase()}-realtime-failed`).catch((e) => log("Failed to take error screenshot: %O", e)); // If bail is true, propagate the error if (bail) { throw error; } } } else { // Normal flow with both initial and realtime tests // Initial smoke test log("Performing initial smoke test"); let initialTestStatus = "passed"; try { await checkUrlSmoke(page, url, false, bail, skipClient, environment, timestampState, targetDir, skipHmr, skipStyleTests); } catch (error) { hasFailures = true; initialTestStatus = "failed"; initialTestError = error instanceof Error ? error : new Error(String(error)); log("Error during initial smoke test: %O", error); console.error(`āŒ Initial smoke test failed: ${error instanceof Error ? error.message : String(error)}`); // If bail is true, stop the tests if (bail) { throw error; } console.log("Continuing with realtime upgrade test since --bail is not enabled..."); } // Take a screenshot after initial test await takeScreenshot(page, url, artifactDir, `${environment.toLowerCase()}-initial-${initialTestStatus}`); // Upgrade to realtime and check again log("Upgrading to realtime"); let realtimeTestStatus = "passed"; try { await upgradeToRealtime(page, environment, bail); log("Performing post-upgrade smoke test"); await checkUrlSmoke(page, url, true, bail, skipClient, environment, timestampState, targetDir, skipHmr, skipStyleTests); } catch (error) { hasFailures = true; realtimeTestStatus = "failed"; realtimeTestError = error instanceof Error ? error : new Error(String(error)); log("Error during realtime smoke test: %O", error); console.error(`āŒ Realtime smoke test failed: ${error instanceof Error ? error.message : String(error)}`); // If bail is true, stop the tests if (bail) { throw error; } } // Take a screenshot after realtime test await takeScreenshot(page, url, artifactDir, `${environment.toLowerCase()}-realtime-${realtimeTestStatus}`); } // If there were failures, propagate them after taking screenshots if (hasFailures) { // Combine errors or just throw the one that happened if (initialTestError && realtimeTestError) { throw new Error(`Multiple test failures: Initial test: ${initialTestError.message}, Realtime test: ${realtimeTestError.message}`); } else if (initialTestError) { throw initialTestError; } else if (realtimeTestError) { throw realtimeTestError; } } } catch (error) { log("Error during URL check: %O", error); await browser .close() .catch((e) => log("Error closing browser: %O", e)); throw error; } finally { log("Closing browser"); await browser .close() .catch((e) => log("Error closing browser: %O", e)); } log("URL check completed successfully"); } /** * Check smoke test status for a specific URL */ export async function checkUrlSmoke(page, url, isRealtime, bail = false, skipClient = false, environment = "Development", timestampState, targetDir, skipHmr = false, skipStyleTests = false) { const phase = isRealtime ? "Post-upgrade" : "Initial"; console.log(`šŸ” Testing ${phase} smoke tests at ${url}`); // Navigate to smoke test page console.log(`šŸ” Accessing smoke test page: ${url}`); await page.goto(url, { waitUntil: "networkidle0" }); log("Page loaded successfully"); // Track failures to report at the end let hasFailures = false; let initialServerTestError = null; let clientTestError = null; let serverRenderCheckError = null; let hmrTestError = null; let stylesheetTestError = null; // Step 1: Run initial server-side smoke test to check the server state log("Running initial server-side smoke test"); let initialServerResult; try { // For initial checks: use the fixed initial value (23) // For realtime checks: if we've previously updated the value, use that instead of 23 // For HMR tests: if HMR happened, we'll be back to the initial value (23) const expectedValue = isRealtime && timestampState.clientUpdatedValue !== null && skipHmr ? timestampState.clientUpdatedValue : timestampState.initialServerValue; // Check that the server is returning the expected value initialServerResult = await checkServerSmoke(page, phase, environment, bail, expectedValue, false); log(`${phase} server-side check passed - module variable has expected value ${expectedValue}`); // Store the current timestamp for potential future reference if (initialServerResult && initialServerResult.timestamp) { timestampState.initialServerValue = initialServerResult.timestamp; } } catch (error) { hasFailures = true; initialServerTestError = error instanceof Error ? error : new Error(String(error)); log("Error during initial server-side smoke test: %O", error); console.error(`āŒ ${phase} server-side smoke test failed: ${error instanceof Error ? error.message : String(error)}`); // If bail is true, stop the tests if (bail) { throw error; } console.log("Continuing with client-side smoke test since --bail is not enabled..."); } // Skip client tests if requested if (skipClient) { log("Skipping client-side smoke test as requested"); console.log("ā© Skipping client-side smoke test as requested"); // If we're running HMR tests and have a target directory if (!skipHmr && targetDir) { try { // Run server HMR test if client tests are skipped log("Running server HMR test"); await testServerComponentHmr(page, targetDir, phase, environment, bail); } catch (error) { hasFailures = true; hmrTestError = error instanceof Error ? error : new Error(String(error)); log("Error during HMR test: %O", error); if (bail) { throw error; } } } return; } // Step 1.5: Run stylesheet checks if (!skipStyleTests) { const env = environment === "Development" ? "dev" : "production"; const urlStylesKey = isRealtime ? "realtimeUrlStyles" : "initialUrlStyles"; const clientModuleStylesKey = isRealtime ? "realtimeClientModuleStyles" : "initialClientModuleStyles"; try { await withRetries(() => checkUrlStyles(page, "red"), "URL styles check"); updateTestStatus(env, urlStylesKey, "PASSED"); log(`${phase} URL styles check passed`); } catch (error) { hasFailures = true; updateTestStatus(env, urlStylesKey, "FAILED"); stylesheetTestError = error instanceof Error ? error : new Error(String(error)); log("Error during URL styles check: %O", error); console.error(`āŒ URL styles check failed: ${error instanceof Error ? error.message : String(error)}`); if (bail) { throw error; } } try { await withRetries(() => checkClientModuleStyles(page, "blue"), "Client module styles check"); updateTestStatus(env, clientModuleStylesKey, "PASSED"); log(`${phase} client module styles check passed`); } catch (error) { hasFailures = true; updateTestStatus(env, clientModuleStylesKey, "FAILED"); if (!stylesheetTestError) { stylesheetTestError = error instanceof Error ? error : new Error(String(error)); } log("Error during client module styles check: %O", error); console.error(`āŒ Client module styles check failed: ${error instanceof Error ? error.message : String(error)}`); if (bail) { throw error; } } } else { log("Skipping stylesheet checks as requested"); console.log("ā© Skipping stylesheet checks as requested"); } // Step 2: Run client-side smoke test to update the server timestamp log("Running client-side smoke test"); let clientResult; try { clientResult = await checkClientSmoke(page, phase, environment, bail); // Store the client-updated timestamp for further tests if (clientResult && clientResult.clientTimestamp) { timestampState.clientUpdatedValue = clientResult.clientTimestamp; log(`Saved client timestamp ${clientResult.clientTimestamp} for verification`); } } catch (error) { hasFailures = true; clientTestError = error instanceof Error ? error : new Error(String(error)); log("Error during client-side smoke test: %O", error); console.error(`āŒ Client-side smoke test failed: ${error instanceof Error ? error.message : String(error)}`); // If bail is true, stop the tests if (bail) { throw error; } console.log("Continuing with server render check since --bail is not enabled..."); } // Step 3: Check if the server has rendered with the updated timestamp (server render check) if (clientResult && clientResult.clientTimestamp) { log("Running server render check with client timestamp"); // Wait a moment for any server renders to complete await new Promise((resolve) => setTimeout(resolve, 500)); try { await checkServerSmoke(page, phase, environment, bail, clientResult.clientTimestamp, // Expected to match the client-set timestamp true); log("Server render check passed - server has updated with client timestamp"); } catch (error) { hasFailures = true; serverRenderCheckError = error instanceof Error ? error : new Error(String(error)); log("Error during server render check: %O", error); console.error(`āŒ Server render check failed: ${error instanceof Error ? error.message : String(error)}`); // If bail is true, stop the tests if (bail) { throw error; } } } else { log("Skipping server render check - no client timestamp available"); // Update test status for server render check as skipped const env = environment === "Development" ? "dev" : "production"; const serverRenderKey = phase === "Initial" || !phase ? "initialServerRenderCheck" : "realtimeServerRenderCheck"; updateTestStatus(env, serverRenderKey, "SKIPPED"); } // Step 4: Run HMR tests if target directory is provided and HMR tests are not skipped if (!skipHmr && targetDir && environment === "Development") { log(`Starting HMR tests for ${phase} phase`); console.log(`\nšŸ”„ Running HMR tests for ${phase} phase...`); try { // Test server component HMR await testServerComponentHmr(page, targetDir, phase, environment, bail); // Test client component HMR if client tests aren't skipped if (!skipClient) { await testClientComponentHmr(page, targetDir, phase, environment, bail); // Test style HMR if style tests aren't skipped if (!skipStyleTests) { await withRetries(() => testStyleHMR(page, targetDir), "Style HMR test", async () => { // This logic runs before each retry of testStyleHMR const urlStylePath = join(targetDir, "src", "app", "smokeTestUrlStyles.css"); const clientStylePath = join(targetDir, "src", "app", "components", "smokeTestClientStyles.module.css"); // Restore original styles before re-running HMR test await fs.writeFile(urlStylePath, urlStylesTemplate); await fs.writeFile(clientStylePath, clientStylesTemplate); }); } else { log("Skipping style HMR test as requested"); console.log("ā© Skipping style HMR test as requested"); } } } catch (error) { hasFailures = true; hmrTestError = error instanceof Error ? error : new Error(String(error)); log("Error during HMR tests: %O", error); console.error(`āŒ HMR tests failed: ${hmrTestError.message}`); // If bail is true, stop the tests if (bail) { throw error; } } } else { log("Skipping HMR tests - targetDir not provided or skipHmr is true or not in Development environment"); if (skipHmr) { console.log("ā© Skipping HMR tests as requested"); } else if (!targetDir) { console.log("ā© Skipping HMR tests - target directory not provided"); } else if (environment !== "Development") { console.log(`ā© Skipping HMR tests - not applicable in ${environment} environment`); } // Update test status for HMR tests as skipped const env = environment === "Development" ? "dev" : "production"; updateTestStatus(env, phase === "Initial" ? "initialServerHmr" : "realtimeServerHmr", "SKIPPED"); updateTestStatus(env, phase === "Initial" ? "initialClientHmr" : "realtimeClientHmr", "SKIPPED"); } // If there were failures, propagate them if (hasFailures) { // Combine errors const errors = []; if (initialServerTestError) { errors.push(`Initial server test: ${initialServerTestError.message}`); } if (clientTestError) { errors.push(`Client test: ${clientTestError.message}`); } if (serverRenderCheckError) { errors.push(`Server render check: ${serverRenderCheckError.message}`); } if (hmrTestError) { errors.push(`HMR test: ${hmrTestError.message}`); } if (stylesheetTestError) { errors.push(`Stylesheet test: ${stylesheetTestError.message}`); } throw new Error(`Multiple test failures: ${errors.join(", ")}`); } log("URL smoke test completed successfully"); } /** * Check server-side smoke test status */ export async function checkServerSmoke(page, phase = "", environment = "Development", bail = false, expectedTimestamp, isServerRenderCheck = false) { const checkType = isServerRenderCheck ? "Server Render Check" : "Initial Check"; console.log(`šŸ” Testing server-side smoke test ${phase ? `(${phase})` : ""} - ${checkType}`); // Determine the environment and test key for state update const env = environment === "Development" ? "dev" : "production"; // Determine the appropriate test key based on phase and whether this is a server render check let testKey; if (isServerRenderCheck) { // This is a server render check - use the appropriate key based on phase testKey = phase === "Initial" || !phase ? "initialServerRenderCheck" : "realtimeServerRenderCheck"; } else { // Regular server-side check testKey = phase === "Initial" || !phase ? "initialServerSide" : "realtimeServerSide"; } const result = await page.evaluate(async (expectedTimestamp) => { try { // Look for smoke test status indicator in the page const smokeElement = document.querySelector('[data-testid="health-status"]'); if (!smokeElement) { return { status: "error", verificationPassed: false, error: "Smoke test status element not found in the page", }; } // Check if required attributes exist const status = smokeElement.getAttribute("data-status"); if (status === null) { return { status: "error", verificationPassed: false, error: "data-status attribute is missing on health-status element", }; } const timestamp = parseInt(smokeElement.getAttribute("data-timestamp") || "0", 10); const serverTimestamp = parseInt(smokeElement.getAttribute("data-server-timestamp") || "0", 10); // If an expected timestamp is provided, verify it matches let verificationPassed = true; let verificationError; if (expectedTimestamp) { verificationPassed = timestamp === expectedTimestamp; if (!verificationPassed) { verificationError = `Server timestamp (${timestamp}) does not match expected (${expectedTimestamp})`; } } return { status: status || "error", verificationPassed: status === "ok" && verificationPassed, timestamp, serverTimestamp, error: verificationError || (status !== "ok" ? "Smoke test did not return ok status" : undefined), }; } catch (error) { return { status: "error", verificationPassed: false, error: error instanceof Error ? error.message : String(error), }; } }, expectedTimestamp); log("Server-side smoke test result: %O", result); // Update test status based on result updateTestStatus(env, testKey, result.verificationPassed ? "PASSED" : "FAILED"); // Report the result (this no longer throws errors) reportSmokeTestResult(result, isServerRenderCheck ? "Server Render Check" : "Server-side", phase, environment); // Handle the error if verification failed if (!result.verificationPassed) { const errorMessage = `${environment} - ${phase ? `(${phase}) ` : ""}${isServerRenderCheck ? "Server Render Check" : "Server-side smoke test"} failed. Status: ${result.status}${result.error ? `. Error: ${result.error}` : ""}`; if (bail) { // If bail is true, call fail() which will exit the process await fail(new Error(errorMessage), 1, `${environment} - ${isServerRenderCheck ? "Server Render Check" : "Server-side Smoke Test"} (${phase})`); } else { // Otherwise throw an error that can be caught by the caller throw new Error(errorMessage); } } return result; } /** * Check client-side smoke test if refresh button is available */ export async function checkClientSmoke(page, phase = "", environment = "Development", bail = false) { console.log(`šŸ” Testing client-side smoke test ${phase ? `(${phase})` : ""}`); // Determine the environment and test key for state update const env = environment === "Development" ? "dev" : "production"; const testKey = phase === "Initial" || !phase ? "initialClientSide" : "realtimeClientSide"; // Check if refresh button exists log("Checking for refresh button"); const refreshButtonExists = await page.evaluate(() => { const button = document.querySelector('[data-testid="refresh-health"]'); return !!button; }); if (!refreshButtonExists) { log("No client-side refresh button found"); console.warn("āš ļø No client-side refresh button found - this is expected only if testing a non-smoke test page"); // Look for any other evidence that the page is working log("Checking if page content is valid HTML"); const pageContent = await page.content(); if (!pageContent.includes("<!DOCTYPE html>")) { log("ERROR: Page doesn't appear to be a valid HTML document"); throw new Error("Page doesn't appear to be a valid HTML document"); } // Check if we're on a smoke test page - in which case missing the refresh button is a failure const currentUrl = page.url(); log("Current URL: %s", currentUrl); if (!currentUrl.includes("/__smoke_test")) { log("ERROR: Smoke test page is not the current URL"); throw new Error("Smoke test page is not the current URL - this is a test failure"); } console.log("ā„¹ļø Basic page structure verified, continuing without client-side smoke test"); return null; } log("Clicking refresh button to trigger client-side smoke test"); await page.click('[data-testid="refresh-health"]'); // Wait for client-side update to complete log("Waiting for client-side test to complete"); try { await page.waitForFunction(() => { const clientIndicator = document.querySelector("#smoke-test-client-timestamp"); return (clientIndicator && +(clientIndicator.getAttribute("data-client-timestamp") ?? "0") > 0); }, { timeout: 5000 }); log("Client-side test completed"); } catch (error) { log("ERROR: Timed out waiting for client-side smoke test to complete: %O", error); throw new Error(`Timed out waiting for client-side smoke test to complete: ${error instanceof Error ? error.message : String(error)}`); } const result = await page.evaluate(async () => { try { const smokeElement = document.querySelector('[data-testid="health-status"]'); if (!smokeElement) { return { status: "error", verificationPassed: false, error: "Smoke test status element not found in the page", }; } // Check if required client attributes exist const clientTimestampElement = document.querySelector("#smoke-test-client-timestamp"); if (!clientTimestampElement) { return { status: "error", verificationPassed: false, error: "Client timestamp element not found in the page", }; } // Check if required client attributes exist const clientTimestampAttr = clientTimestampElement.getAttribute("data-client-timestamp"); if (clientTimestampAttr === null) { return { status: "error", verificationPassed: false, error: "data-client-timestamp attribute is missing", }; } const clientTimestamp = parseInt(clientTimestampAttr, 10); // Check if status attribute exists const clientStatus = clientTimestampElement.getAttribute("data-status"); if (clientStatus === null) { return { status: "error", verificationPassed: false, error: "data-status attribute is missing on client timestamp element", }; } // Check if verified attribute exists if (clientTimestampElement.getAttribute("data-verified") === null) { return { status: "error", verificationPassed: false, error: "data-verified attribute is missing on client timestamp element", }; } // Get client verification result directly const verificationPassed = clientTimestampElement.getAttribute("data-verified") === "true"; return { status: clientStatus, verificationPassed: clientStatus === "ok" && verificationPassed, clientTimestamp, error: clientStatus !== "ok" ? "Client smoke test did not return ok status" : undefined, }; } catch (error) { return { status: "error", verificationPassed: false, error: error instanceof Error ? error.message : String(error), }; } }); log("Client-side smoke test result: %O", result); // Update test status based on result updateTestStatus(env, testKey, result.verificationPassed ? "PASSED" : "FAILED"); // Report the result (this no longer throws errors) reportSmokeTestResult(result, "Client-side", phase, environment); // Handle the error if verification failed if (!result.verificationPassed) { const errorMessage = `${environment} - ${phase ? `(${phase}) ` : ""}Client-side smoke test failed. Status: ${result.status}${result.error ? `. Error: ${result.error}` : ""}`; if (bail) { // If bail is true, call fail() which will exit the process await fail(new Error(errorMessage), 1, `${environment} - Client-side Smoke Test (${phase})`); } else { // Otherwise throw an error that can be caught by the caller throw new Error(errorMessage); } } return result; } /** * Upgrade to realtime mode */ export async function upgradeToRealtime(page, environment = "Development", // Add environment parameter with default bail = false) { console.log("\nšŸ“” Upgrading to realtime mode"); // Determine the environment for state update const env = environment === "Development" ? "dev" : "production"; const upgradeResult = await page.evaluate(async () => { try { // Check if __rw API exists if (typeof window.__rw !== "object" || typeof window.__rw.upgradeToRealtime !== "function") { return { success: false, message: "The __rw API or upgradeToRealtime method is not available", }; } await window.__rw.upgradeToRealtime(); // If we get here, it succeeded return { success: true }; } catch (error) { return { success: false, message: error instanceof Error ? error.message : String(error), }; } }); if (!upgradeResult.success) { log("ERROR: Failed to upgrade to realtime mode: %s", upgradeResult.message); const errorMessage = `Failed to upgrade to realtime mode: ${upgradeResult.message}`; // Update test status to FAILED updateTestStatus(env, "realtimeUpgrade", "FAILED"); if (bail) { // If bail is true, call fail() which will exit the process await fail(new Error(errorMessage), 1, `${environment} - Realtime Upgrade`); return; // This will never be reached due to fail() exiting } else { // Otherwise throw an error that can be caught by the caller throw new Error(errorMessage); } } // Update test status to PASSED updateTestStatus(env, "realtimeUpgrade", "PASSED"); console.log("āœ… Successfully upgraded to realtime mode"); } /** * DRY: checkServerUp now checks both root and custom path if needed */ export async function checkServerUp(baseUrl, customPath = "/", retries = RETRIES, bail = false) { // Always check root first, then custom path if different const pathsToCheck = ["/"]; if (customPath !== "/" && customPath !== "") { pathsToCheck.push(customPath); } for (const path of pathsToCheck) { const url = baseUrl + (path.startsWith("/") ? path : "/" + path); log("Checking if server is up at %s (max retries: %d)", url, retries); let up = false; for (let i = 0; i < retries; i++) { try { log("Attempt %d/%d to check server at %s", i + 1, retries, url); console.log(`Checking if server is up at ${url} (attempt ${i + 1}/${retries})...`); await $ `curl -s -o /dev/null -w "%{http_code}" ${url}`; log("Server is up at %s", url); console.log(`āœ… Server is up at ${url}`); up = true; break; } catch (error) { if (i === retries - 1) { log("ERROR: Server at %s did not become available after %d attempts", url, retries); const errorMessage = `Server at ${url} did not become available after ${retries} attempts`; if (bail) { // If bail is true, call fail() which will exit the process await fail(new Error(errorMessage), 1, `Server Availability Check: ${url}`); return false; // This will never be reached due to fail() exiting } else { // Otherwise throw an error that can be caught by the caller throw new Error(errorMessage); } } log("Server not up yet, retrying in 2 seconds"); console.log(`Server not up yet, retrying in 2 seconds...`); await new Promise((resolve) => setTimeout(() => resolve(), 2000)); } } if (!up) return false; } return true; } /** * Perform only the realtime upgrade and tests without doing initial checks */ async function realtimeOnlyFlow(page, url, artifactDir, bail, skipClient, environment, targetDir, skipHmr = false, skipStyleTests = false) { let hasFailures = false; let realtimeError = null; // Create timestamp state for the realtime-only flow const timestampState = { initialServerValue: 23, // This is the default initial module-level value clientUpdatedValue: null, }; try { log("Performing realtime-only smoke test"); await checkUrlSmoke(page, url, true, bail, skipClient, environment, timestampState, targetDir, skipHmr, skipStyleTests); // Take a screenshot of the realtime test await takeScreenshot(page, url, artifactDir, `${environment.toLowerCase()}-realtime-passed`); } catch (error) { hasFailures = true; realtimeError = error instanceof Error ? error : new Error(String(error)); log("Error during realtime-only test: %O", error); console.error(`āŒ Realtime test failed: ${realtimeError.message}`); // Take a failure screenshot await takeScreenshot(page, url, artifactDir, `${environment.toLowerCase()}-realtime-failed`).catch((e) => log("Failed to take error screenshot: %O", e)); // If bail is true, propagate the error if (bail) { throw error; } } return { hasFailures, error: realtimeError }; } /** * HMR test for server component * Updates the server component and verifies that HMR applies the changes */ export async function testServerComponentHmr(page, targetDir, phase = "", environment = "Development", bail = false) { const testPhase = phase ? phase : "Initial"; // Skip HMR tests in production environments if (environment !== "Development") { console.log(`ā© Skipping server HMR test in ${environment} environment`); // Update test status to SKIPPED const env = environment === "Development" ? "dev" : "production"; const testKey = testPhase === "Initial" || !testPhase ? "initialServerHmr" : "realtimeServerHmr"; updateTestStatus(env, testKey, "SKIPPED"); return false; } console.log(`šŸ”„ Testing ${testPhase} Server Component HMR`); // Determine the environment and test key for state update const env = environment === "Development" ? "dev" : "production"; const testKey = testPhase === "Initial" || !testPhase ? "initialServerHmr" : "realtimeServerHmr"; try { // First, verify the server HMR marker exists log("Checking for server HMR marker"); const markerExists = await page.evaluate(() => { const marker = document.querySelector('[data-testid="server-hmr-marker"]'); return !!marker; }); if (!markerExists) { log("Server HMR marker not found"); console.warn("āš ļø Server HMR marker not found in the page - skipping server HMR test"); updateTestStatus(env, testKey, "SKIPPED"); return false; } // Get the initial attributes before making changes const initialAttributes = await page.evaluate(() => { const marker = document.querySelector('[data-testid="server-hmr-marker"]'); if (!marker) return null; return { text: marker.getAttribute("data-hmr-text"), timestamp: marker.getAttribute("data-hmr-timestamp"), content: marker.textContent, }; }); log("Initial server HMR marker state: %O", initialAttributes); // Find the SmokeTest.tsx file path const smokePath = join(targetDir, "src", "app", "components", "__SmokeTest.tsx"); log("Looking for smoke test file at: %s", smokePath); // Read the current file content const fs = await import("fs/promises"); const fileContent = await fs.readFile(smokePath, "utf-8"); // Define the new content with updated HMR marker const newTimestamp = Date.now(); const updatedContent = fileContent .replace(/data-hmr-text="[^"]*"/g, `data-hmr-text="updated-${newTimestamp}"`) .replace(/data-hmr-timestamp=\{[^}]*\}/g, `data-hmr-timestamp={${newTimestamp}}`) .replace(/Server Component HMR: <span>[^<]*<\/span>/g, `Server Component HMR: <span>Updated Text ${newTimestamp}</span>`); // Write the updated file log("Writing updated server component content"); await fs.writeFile(smokePath, updatedContent, "utf-8"); // Wait for HMR to apply changes console.log("Waiting for server HMR to update component..."); // Wait for the data-hmr-text attribute to change log("Waiting for server HMR update to apply"); try { await page.waitForFunction((timestamp) => { const marker = document.querySelector('[data-testid="server-hmr-marker"]'); if (!marker) return false; const currentText = marker.getAttribute("data-hmr-text"); return (currentText && currentText.includes("updated-") && currentText.includes(timestamp)); }, { timeout: 10000 }, newTimestamp.toString()); log("Server HMR update detected"); } catch (error) { log("ERROR: Server HMR update not detected: %O", error); updateTestStatus(env, testKey, "FAILED"); if (bail) { await fail(new Error(`Server HMR test failed: Update not detected within timeout`), 1, `${environment} - Server HMR Test (${testPhase})`); } return false; } // Verify final state const updatedAttributes = await page.evaluate(() => { const marker = document.querySelector('[data-testid="server-hmr-marker"]'); if (!marker) return null; return { text: marker.getAttribute("data-hmr-text"), timestamp: marker.getAttribute("data-hmr-timestamp"), content: marker.textContent, }; }); log("Updated server HMR marker state: %O", updatedAttributes); const hmrSuccess = updatedAttributes && updatedAttributes.text && updatedAttributes.text.includes("updated-"); updateTestStatus(env, testKey, hmrSuccess ? "PASSED" : "FAILED"); if (hmrSuccess) { console.log("āœ… Server component HMR test passed"); return true; } else { console.error("āŒ Server component HMR test failed: Content did not update properly"); if (bail) { await fail(new Error(`Server HMR test failed: Content did not update properly`), 1, `${environment} - Server HMR Test (${testPhase})`); } return false; } } catch (error) { log("Error during server HMR test: %O", error); console.error(`āŒ Server HMR test failed: ${error instanceof Error ? error.message : String(error)}`); updateTestStatus(env, testKey, "FAILED"); if (bail) { await fail(error instanceof Error ? error : new Error(String(error)), 1, `${environment} - Server HMR Test (${testPhase})`); } return false; } } /** * HMR test for client component * Updates the client component and verifies that HMR applies the changes */ export async function testClientComponentHmr(page, targetDir, phase = "", environment = "Development", bail = false) { const testPhase = phase ? phase : "Initial"; // Skip HMR tests in production environments if (environment !== "Development") { console.log(`ā© Skipping client HMR test in ${environment} environment`); // Update test status to SKIPPED const env = environment === "Development" ? "dev" : "production"; const testKey = testPhase === "Initial" || !testPhase ? "initialClientHmr" : "realtimeClientHmr"; updat