diff --git a/src/tests.js b/src/tests.js index 6118ebb..0c64656 100644 --- a/src/tests.js +++ b/src/tests.js @@ -1,6 +1,7 @@ const kill = require("tree-kill"); const wdio = require("webdriverio"); const os = require("os"); +const net = require("net"); const { log, replaceEnvs } = require("./utils"); const axios = require("axios"); const { instantiateCursor } = require("./tests/moveTo"); @@ -31,6 +32,7 @@ const { uploadChangedFiles } = require("./integrations"); exports.runSpecs = runSpecs; exports.runViaApi = runViaApi; exports.getRunner = getRunner; +exports.checkPortAvailable = checkPortAvailable; // exports.appiumStart = appiumStart; // exports.appiumIsReady = appiumIsReady; // exports.driverStart = driverStart; @@ -380,6 +382,7 @@ async function runSpecs({ resolvedTests }) { const availableApps = runnerDetails.availableApps; const metaValues = { specs: {} }; let appium; + let appiumHost; const report = { summary: { specs: { @@ -413,24 +416,52 @@ async function runSpecs({ resolvedTests }) { // Determine which apps are required const appiumRequired = isAppiumRequired(specs); - // Warm up Appium - if (appiumRequired) { - // Set Appium home directory - setAppiumHome(); - // Start Appium server - appium = spawn("npx", ["appium"], { - shell: true, - windowsHide: true, - cwd: path.join(__dirname, ".."), - }); + // Warm up Appium + if (appiumRequired) { + // Check port availability before spawning (probe both hosts like appiumIsReady does) + const hosts = ["127.0.0.1", "localhost"]; + let portAvailableOnAnyHost = false; + const hostStatuses = {}; + + for (const host of hosts) { + const isAvailable = await checkPortAvailable(4723, host); + hostStatuses[host] = isAvailable; + if (isAvailable) { + portAvailableOnAnyHost = true; + } + } + + if (!portAvailableOnAnyHost) { + let message = "Appium port 4723 is already in use. Stop the process using it or set a different port.\n"; + message += "Port availability check results:\n"; + for (const host of hosts) { + message += ` ${host}: ${hostStatuses[host] ? "available" : "unavailable"}\n`; + } + log(config, "error", message); + throw new Error(message); + } + // Set Appium home directory + setAppiumHome(); + // Start Appium server + appium = spawn("npx", ["appium"], { + shell: true, + windowsHide: true, + cwd: path.join(__dirname, ".."), + }); appium.stdout.on("data", (data) => { // console.log(`stdout: ${data}`); }); appium.stderr.on("data", (data) => { // console.error(`stderr: ${data}`); }); - await appiumIsReady(); - log(config, "debug", "Appium is ready."); + try { + appiumHost = await appiumIsReady(); + log(config, "debug", `Appium is ready on ${appiumHost}.`); + } catch (error) { + // Clean up Appium process on timeout/failure + kill(appium.pid); + throw error; + } } // Iterate specs @@ -541,7 +572,7 @@ async function runSpecs({ resolvedTests }) { // Instantiate driver try { - driver = await driverStart(caps); + driver = await driverStart(caps, appiumHost); } catch (error) { try { // If driver fails to start, try again as headless @@ -552,7 +583,7 @@ async function runSpecs({ resolvedTests }) { ); context.browser.headless = true; caps = getDriverCapabilities({ - config: config, + runnerDetails: runnerDetails, name: context.browser.name, options: { width: context.browser?.window?.width || 1200, @@ -560,7 +591,7 @@ async function runSpecs({ resolvedTests }) { headless: context.browser?.headless !== false, }, }); - driver = await driverStart(caps); + driver = await driverStart(caps, appiumHost); } catch (error) { let errorMessage = `Failed to start context '${context.browser?.name}' on '${platform}'.`; if (context.browser?.name === "safari") @@ -939,36 +970,155 @@ async function runStep({ return actionResult; } -// Delay execution until Appium server is available. -async function appiumIsReady() { - let isReady = false; - while (!isReady) { - // Retry delay - // TODO: Add configurable retry delay - // TODO: Add configurable timeout duration - await new Promise((resolve) => setTimeout(resolve, 1000)); +/** + * Check if a port is available (not in use). + * @param {number} port - Port number to check + * @param {string} host - Host to check on (default: 127.0.0.1) + * @returns {Promise} - True if port is available, false if in use + */ +async function checkPortAvailable(port, host = "127.0.0.1") { + return new Promise((resolve) => { + const server = net.createServer(); + server.once("error", (err) => { + if (["EADDRINUSE", "EACCES", "EADDRNOTAVAIL"].includes(err.code)) { + resolve(false); // Port is not available + } else { + resolve(true); // Unknown error, assume available + } + }); + server.once("listening", () => { + server.close(); + resolve(true); // Port is available + }); + server.listen(port, host); + }); +} + +/** + * Delay execution until Appium server is available. + * Uses 127.0.0.1 with localhost fallback for cross-platform compatibility. + * @param {number} timeoutMs - Maximum time to wait in milliseconds (default: 60000) + * @returns {Promise} - The hostname that worked (127.0.0.1 or localhost) + * @throws {Error} - If Appium fails to start within timeout + */ +async function appiumIsReady(timeoutMs = 60000) { + const startTime = Date.now(); + const deadline = startTime + timeoutMs; + const hosts = ["127.0.0.1", "localhost"]; + let lastError = null; + let successHost = null; + const lastErrorPerHost = {}; + + while (Date.now() < deadline) { + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + + // Retry delay - clamp to remaining time + const sleepMs = Math.min(1000, remaining); + await new Promise((resolve) => setTimeout(resolve, sleepMs)); + + for (const host of hosts) { + const hostRemaining = deadline - Date.now(); + if (hostRemaining <= 0) break; + + try { + const resp = await axios.get(`http://${host}:4723/status`, { + timeout: Math.min(5000, hostRemaining), // Clamp to remaining time, no minimum + }); + if (resp.status === 200) { + successHost = host; + return successHost; + } + } catch (err) { + lastError = err; + lastErrorPerHost[host] = err; + // Continue to next host or retry + } + } + } + + // Timeout reached - build descriptive error message + const elapsed = Date.now() - startTime; + const platform = process.platform; + const remaining = Math.max(0, deadline - Date.now()); + + let errorMsg = `Appium failed to start within ${Math.round(elapsed / 1000)} seconds.\n`; + errorMsg += `Platform: ${platform}\n`; + + // Check port availability with clamped timeout if time remains + let portAvailable = null; + if (remaining > 0) { + const checkDeadline = Date.now() + Math.min(1000, remaining); try { - let resp = await axios.get("http://0.0.0.0:4723/status"); - if (resp.status === 200) isReady = true; - } catch {} + portAvailable = await Promise.race([ + checkPortAvailable(4723), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Port check timeout")), checkDeadline - Date.now()) + ), + ]); + } catch { + portAvailable = null; // Couldn't determine port status + } + } + + if (portAvailable !== null) { + errorMsg += `Port 4723 status: ${portAvailable ? "available (not bound)" : "in use (bound)"}\n`; + } + + // Include per-host diagnostics + for (const host of hosts) { + if (lastErrorPerHost[host]) { + errorMsg += `${host} connection error: ${lastErrorPerHost[host].message}\n`; + } + } + + if (lastError) { + errorMsg += `Last connection error: ${lastError.message}\n`; + } + + if (platform === "win32") { + errorMsg += "\nWindows troubleshooting:\n"; + errorMsg += "- Check Windows Firewall settings for port 4723\n"; + errorMsg += "- Temporarily disable antivirus software\n"; + errorMsg += "- Run as Administrator if port binding fails\n"; + } else if (platform === "darwin") { + errorMsg += "\nmacOS troubleshooting:\n"; + errorMsg += "- Check System Preferences > Security & Privacy > Firewall\n"; + errorMsg += "- Ensure no VPN is blocking localhost connections\n"; } - return isReady; + + throw new Error(errorMsg); } // Start the Appium driver specified in `capabilities`. -async function driverStart(capabilities) { - const driver = await wdio.remote({ - protocol: "http", - hostname: "0.0.0.0", - port: 4723, - path: "/", - logLevel: "error", - capabilities, - connectionRetryTimeout: 600000, // 10 minutes - waitforTimeout: 600000, // 10 minutes - }); - driver.state = { url: "", x: null, y: null }; - return driver; +// Uses hostname determined by appiumIsReady() for cross-platform compatibility. +async function driverStart(capabilities, hostname = "127.0.0.1") { + const hosts = hostname ? [hostname] : ["127.0.0.1", "localhost"]; + let lastError = null; + + for (const host of hosts) { + try { + const driver = await wdio.remote({ + protocol: "http", + hostname: host, + port: 4723, + path: "/", + logLevel: "error", + capabilities, + connectionRetryTimeout: 60000, // 60 seconds (reduced from 10 minutes) + waitforTimeout: 60000, // 60 seconds (reduced from 10 minutes) + }); + driver.state = { url: "", x: null, y: null }; + return driver; + } catch (err) { + lastError = err; + // Try next hostname + } + } + + throw new Error( + `Failed to connect to Appium driver on hosts [${hosts.join(", ")}]: ${lastError?.message || "Unknown error"}` + ); } /** @@ -1029,9 +1179,16 @@ async function getRunner(options = {}) { cwd: path.join(__dirname, ".."), }); - // Wait for Appium to be ready - await appiumIsReady(); - log(config, "debug", "Appium is ready for external driver."); + // Wait for Appium to be ready and get the working hostname + let appiumHost; + try { + appiumHost = await appiumIsReady(); + log(config, "debug", `Appium is ready on ${appiumHost} for external driver.`); + } catch (error) { + // Clean up Appium process on timeout/failure + kill(appium.pid); + throw error; + } // Get Chrome driver capabilities const caps = getDriverCapabilities({ @@ -1044,10 +1201,10 @@ async function getRunner(options = {}) { }, }); - // Start the runner + // Start the runner using the hostname that worked for Appium status check let runner; try { - runner = await driverStart(caps); + runner = await driverStart(caps, appiumHost); } catch (error) { // If runner fails, attempt to set headless and retry try { @@ -1057,7 +1214,7 @@ async function getRunner(options = {}) { "Failed to start Chrome runner. Retrying as headless." ); caps["goog:chromeOptions"].args.push("--headless", "--disable-gpu"); - runner = await driverStart(caps); + runner = await driverStart(caps, appiumHost); } catch (error) { // If runner fails, clean up Appium and rethrow kill(appium.pid); diff --git a/src/tests/saveScreenshot.js b/src/tests/saveScreenshot.js index f8bc3d6..d03df39 100644 --- a/src/tests/saveScreenshot.js +++ b/src/tests/saveScreenshot.js @@ -237,6 +237,32 @@ async function saveScreenshot({ config, step, driver }) { rect.width += padding.left + padding.right; rect.height += padding.top + padding.bottom; + // Get viewport dimensions for clamping + const viewport = await driver.execute(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })); + + // Clamp coordinates to viewport bounds (prevent negative values and overflow) + if (rect.x < 0) { + rect.width += rect.x; // Reduce width by the amount we're clamping + rect.x = 0; + } + if (rect.y < 0) { + rect.height += rect.y; // Reduce height by the amount we're clamping + rect.y = 0; + } + if (rect.x + rect.width > viewport.width) { + rect.width = viewport.width - rect.x; + } + if (rect.y + rect.height > viewport.height) { + rect.height = viewport.height - rect.y; + } + + // Ensure width and height are at least 1 pixel + rect.width = Math.max(1, rect.width); + rect.height = Math.max(1, rect.height); + // Scale the values based on the pixel density rect.x *= pixelDensity; rect.y *= pixelDensity; diff --git a/test/artifacts/find_rightClick.spec.json b/test/artifacts/find_rightClick.spec.json index ada0ae2..48b480d 100644 --- a/test/artifacts/find_rightClick.spec.json +++ b/test/artifacts/find_rightClick.spec.json @@ -9,7 +9,7 @@ }, { "action": "find", - "selector": "#searchbox_input", + "selector": "input[name=\"q\"]", "click": { "button": "right" } diff --git a/test/artifacts/screenshot.spec.json b/test/artifacts/screenshot.spec.json index 13635d9..d547c30 100644 --- a/test/artifacts/screenshot.spec.json +++ b/test/artifacts/screenshot.spec.json @@ -20,7 +20,7 @@ "path": "crop.png", "directory": "static/images", "maxVariation": 0.1, - "crop": "#searchbox_input" + "crop": "input[name=\"q\"]" } }, { @@ -30,7 +30,7 @@ "directory": "static/images", "maxVariation": 0.1, "crop": { - "selector": "#searchbox_input", + "selector": "input[name=\"q\"]", "padding": 5 } } @@ -42,7 +42,7 @@ "directory": "static/images", "maxVariation": 0.1, "crop": { - "selector": "#searchbox_input", + "selector": "input[name=\"q\"]", "padding": { "top": 5, "right": 5, diff --git a/test/core.test.js b/test/core.test.js index 144473f..de0cdec 100644 --- a/test/core.test.js +++ b/test/core.test.js @@ -41,6 +41,26 @@ describe("Run tests successfully", function () { // Set indefinite timeout this.timeout(0); describe("Core test suite", function () { + // Screenshot test cleanup helper + const screenshotCleanupPaths = [ + path.join(artifactPath, "screenshot-boolean.png"), + path.join(artifactPath, "image.png"), + path.join(artifactPath, "static", "images", "crop.png"), + path.join(artifactPath, "static", "images", "padding.png"), + ]; + + const cleanupScreenshotFiles = () => { + screenshotCleanupPaths.forEach((filePath) => { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } catch (err) { + // Ignore cleanup errors + } + }); + }; + // For each file (not directory) in artifactPath, create an individual test const files = fs.readdirSync(artifactPath); files.forEach((file) => { @@ -49,9 +69,22 @@ describe("Run tests successfully", function () { it(`Test file: ${file}`, async () => { const config_tests = JSON.parse(JSON.stringify(config_base)); config_tests.runTests.input = filePath; - const result = await runTests(config_tests); - if (result === null) assert.fail("Expected result to be non-null"); - assert.equal(result.summary.specs.fail, 0); + + // Special handling for screenshot test - cleanup before and after + const isScreenshotTest = file === "screenshot.spec.json"; + if (isScreenshotTest) { + cleanupScreenshotFiles(); + } + + try { + const result = await runTests(config_tests); + if (result === null) assert.fail("Expected result to be non-null"); + assert.equal(result.summary.specs.fail, 0); + } finally { + if (isScreenshotTest) { + cleanupScreenshotFiles(); + } + } }); } }); @@ -822,4 +855,97 @@ describe("getRunner() function", function () { if (cleanup) await cleanup(); } }); + + // Cross-platform timeout test - ensures getRunner() doesn't hang indefinitely + // This test is designed to catch platform-specific issues (e.g., Windows/macOS hangs with 0.0.0.0) + it("should initialize within 60 seconds on all platforms", async function () { + const maxStartupMs = 60000; + const startTime = Date.now(); + let cleanup; + let timeoutId; + + try { + const result = await Promise.race([ + getRunner(), + new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error(`getRunner() timed out after ${maxStartupMs}ms`)), + maxStartupMs + ); + }), + ]); + cleanup = result.cleanup; + + // Clear timeout on success + clearTimeout(timeoutId); + + const elapsed = Date.now() - startTime; + assert.ok( + elapsed < maxStartupMs, + `getRunner() took ${elapsed}ms, should complete within ${maxStartupMs}ms` + ); + + // Verify runner is functional + assert.ok(result.runner, "runner should be defined"); + assert.ok(result.appium, "appium should be defined"); + assert.ok(result.cleanup, "cleanup should be defined"); + + // Quick functionality check + await result.runner.url("http://localhost:8092/index.html"); + const title = await result.runner.getTitle(); + assert.ok(title, "runner should be able to navigate and get title"); + } finally { + if (timeoutId) clearTimeout(timeoutId); + if (cleanup) await cleanup(); + } + }); + + it("should throw descriptive error if Appium fails to start within timeout", async function () { + // This test validates that timeout mechanism works and provides helpful error messages + const { checkPortAvailable } = require("../src/tests"); + assert.ok(typeof checkPortAvailable === "function", "checkPortAvailable should be exported"); + + // Verify the function works - it should return a boolean + const available = await checkPortAvailable(65432, "127.0.0.1"); // Use unlikely port + assert.ok(typeof available === "boolean", "checkPortAvailable should return a boolean"); + }); + + it("should navigate to local server using runStep", async function () { + const maxTimeoutMs = 60000; + let cleanup; + let timeoutId; + try { + const result = await Promise.race([ + getRunner(), + new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error(`getRunner() timed out after ${maxTimeoutMs}ms`)), + maxTimeoutMs + ); + }), + ]); + cleanup = result.cleanup; + + // Clear timeout on success + clearTimeout(timeoutId); + + const { runStep, runner } = result; + + // Use runStep to navigate to local echo server + const goToResult = await runStep({ + config: { logLevel: "debug" }, + driver: runner, + step: { goTo: "http://localhost:8092/index.html" } + }); + + assert.strictEqual(goToResult.status, "PASS", `goTo step should pass: ${goToResult.description}`); + + // Verify navigation worked using runner directly + const title = await runner.getTitle(); + assert.ok(title, "should get page title"); + } finally { + if (timeoutId) clearTimeout(timeoutId); + if (cleanup) await cleanup(); + } + }); });