UNPKG

@wdio/appium-service

Version:
1,244 lines (1,217 loc) 119 kB
// src/launcher.ts import os from "node:os"; import fs3 from "node:fs"; import fsp from "node:fs/promises"; import url from "node:url"; import path5 from "node:path"; import { spawn } from "node:child_process"; import { promisify } from "node:util"; import logger9 from "@wdio/logger"; import getPort from "get-port"; import { resolve as resolve2 } from "import-meta-resolve"; import { isCloudCapability } from "@wdio/config"; import { SevereServiceError as SevereServiceError3 } from "webdriverio"; import { isAppiumCapability } from "@wdio/utils"; // src/utils.ts import { basename, join, resolve } from "node:path"; import { kebabCase } from "change-case"; var FILE_EXTENSION_REGEX = /\.[0-9a-z]+$/i; function getFilePath(filePath, defaultFilename) { let absolutePath = resolve(filePath); if (!FILE_EXTENSION_REGEX.test(basename(absolutePath))) { absolutePath = join(absolutePath, defaultFilename); } return absolutePath; } function formatCliArgs(args) { const cliArgs = []; for (const key in args) { const value = args[key]; if (typeof value === "boolean" && !value || value === null) { continue; } if (key === "chromedriver_autodownload") { cliArgs.push(key); continue; } cliArgs.push(`--${kebabCase(key)}`); if (typeof value !== "boolean") { cliArgs.push(sanitizeCliOptionValue(value)); } } return cliArgs; } function sanitizeCliOptionValue(value) { const valueString = typeof value === "object" ? JSON.stringify(value) : String(value); return /\s/.test(valueString) ? `'${valueString}'` : valueString; } // src/launcher.ts import treeKill from "tree-kill"; // src/mobileSelectorPerformanceOptimizer/aggregator.ts import fs2 from "node:fs"; import path4 from "node:path"; import { SevereServiceError as SevereServiceError2 } from "webdriverio"; // src/mobileSelectorPerformanceOptimizer/mspo-store.ts var currentSuiteName; var currentTestFile; var currentTestName; var currentDeviceName; var performanceData = []; function setCurrentSuiteName(suiteName) { currentSuiteName = suiteName; } function getCurrentSuiteName() { return currentSuiteName; } function setCurrentTestFile(testFile) { currentTestFile = testFile; } function getCurrentTestFile() { return currentTestFile; } function setCurrentTestName(testName) { currentTestName = testName; } function getCurrentTestName() { return currentTestName; } function setCurrentDeviceName(deviceName) { currentDeviceName = deviceName; } function getCurrentDeviceName() { return currentDeviceName; } function addPerformanceData(data) { performanceData.push(data); } function getPerformanceData() { return performanceData; } // src/mobileSelectorPerformanceOptimizer/markdown-formatter.ts import path3 from "node:path"; // src/mobileSelectorPerformanceOptimizer/utils/constants.ts var LOG_PREFIX = "Mobile Selector Performance"; var SINGLE_ELEMENT_COMMANDS = ["$", "custom$"]; var MULTIPLE_ELEMENT_COMMANDS = ["$$", "custom$$"]; var USER_COMMANDS = [...SINGLE_ELEMENT_COMMANDS, ...MULTIPLE_ELEMENT_COMMANDS]; var REPORT_INDENT_SUMMARY = " "; var REPORT_INDENT_FILE = " "; var REPORT_INDENT_SUITE = " "; var REPORT_INDENT_SELECTOR = " "; // src/mobileSelectorPerformanceOptimizer/utils/selector-location.ts import path from "node:path"; import fs from "node:fs"; import logger from "@wdio/logger"; // src/mobileSelectorPerformanceOptimizer/utils/selector-utils.ts function extractSelectorFromArgs(args) { if (!args || args.length === 0) { return null; } const firstArg = args[0]; if (typeof firstArg === "string") { return firstArg; } if (typeof firstArg === "object" && firstArg !== null) { try { return JSON.stringify(firstArg); } catch { return String(firstArg); } } return String(firstArg); } function isXPathSelector(selector) { if (typeof selector !== "string") { return false; } if (selector.startsWith("/") || selector.startsWith("../") || selector.startsWith("./") || selector.startsWith("*/")) { return true; } if (selector.startsWith("(")) { if (selector.startsWith("(:")) { return false; } return selector.includes("/") || selector.includes("@"); } return false; } function parseOptimizedSelector(optimizedSelector) { if (optimizedSelector.startsWith("~")) { return { using: "accessibility id", value: optimizedSelector.substring(1) }; } if (optimizedSelector.startsWith("-ios predicate string:")) { return { using: "-ios predicate string", value: optimizedSelector.substring("-ios predicate string:".length) }; } if (optimizedSelector.startsWith("-ios class chain:")) { return { using: "-ios class chain", value: optimizedSelector.substring("-ios class chain:".length) }; } return null; } // src/mobileSelectorPerformanceOptimizer/utils/selector-location.ts var log = logger("@wdio/appium-service:selector-optimizer"); function findSelectorInFile(filePath, selector) { try { if (!fs.existsSync(filePath)) { return void 0; } const content = fs.readFileSync(filePath, "utf-8"); const lines = content.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.includes(selector) || line.includes(`'${selector}'`) || line.includes(`"${selector}"`) || line.includes(`\`${selector}\``)) { return i + 1; } } return void 0; } catch { return void 0; } } function findPotentialPageObjects(testFile) { const testDir = path.dirname(testFile); const testBasename = path.basename(testFile); const ext = path.extname(testFile); const baseName = testBasename.replace(/\.(spec|test|e2e)/, "").replace(ext, ""); const potentialFiles = []; const pageObjectDirs = ["pageobjects", "pageObjects", "page-objects", "pages", "page_objects"]; let currentDir = testDir; for (let i = 0; i < 5; i++) { for (const poDir of pageObjectDirs) { const pageObjectDir = path.join(currentDir, poDir); if (fs.existsSync(pageObjectDir)) { const patterns = [ `${baseName}.page${ext}`, `${baseName}.po${ext}`, `${baseName}Page${ext}`, `${baseName}${ext}` ]; for (const pattern of patterns) { const fullPath = path.join(pageObjectDir, pattern); if (fs.existsSync(fullPath)) { potentialFiles.push(fullPath); } } } } const parentDir = path.dirname(currentDir); if (parentDir === currentDir) { break; } currentDir = parentDir; } return potentialFiles; } function findFilesInDirectory(dirPath, maxDepth = 5, currentDepth = 0) { const files = []; if (currentDepth >= maxDepth) { return files; } try { if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) { return files; } const entries = fs.readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { if (entry.name === "node_modules" || entry.name.startsWith(".")) { continue; } files.push(...findFilesInDirectory(fullPath, maxDepth, currentDepth + 1)); } else if (entry.isFile() && /\.(js|ts|jsx|tsx)$/.test(entry.name)) { files.push(fullPath); } } } catch { } return files; } function findPageObjectFilesFromConfig(pageObjectPaths) { const files = []; for (const configPath of pageObjectPaths) { try { const resolvedPath = path.isAbsolute(configPath) ? configPath : path.resolve(process.cwd(), configPath); const stat = fs.statSync(resolvedPath); if (stat.isDirectory()) { files.push(...findFilesInDirectory(resolvedPath)); } else if (stat.isFile() && /\.(js|ts|jsx|tsx)$/.test(resolvedPath)) { files.push(resolvedPath); } } catch { } } return files; } function findSelectorLocation(testFile, selector, pageObjectPaths) { if (!testFile || !selector) { log.debug("[Selector Location] No test file or selector provided"); return []; } if (!isXPathSelector(selector)) { log.debug(`[Selector Location] Skipping non-XPath selector: ${selector}`); return []; } try { const locations = []; log.debug(`[Selector Location] Searching for XPath selector: ${selector}`); log.debug(`[Selector Location] Starting with test file: ${testFile}`); const testFileLine = findSelectorInFile(testFile, selector); if (testFileLine) { log.debug(`[Selector Location] Found in test file at line ${testFileLine}`); locations.push({ file: testFile, line: testFileLine, isPageObject: false }); } log.debug("[Selector Location] Searching page objects..."); const pageObjectFiles = pageObjectPaths && pageObjectPaths.length > 0 ? findPageObjectFilesFromConfig(pageObjectPaths) : findPotentialPageObjects(testFile); if (pageObjectPaths && pageObjectPaths.length > 0) { log.debug("[Selector Location] Using configured page object paths:"); pageObjectPaths.forEach((p) => { log.debug(`[Selector Location] - ${p}`); }); } if (pageObjectFiles.length > 0) { log.debug(`[Selector Location] Found ${pageObjectFiles.length} page object file(s) to search`); } else { log.debug("[Selector Location] No page object files found"); } for (const pageObjectFile of pageObjectFiles) { const pageObjectLine = findSelectorInFile(pageObjectFile, selector); if (pageObjectLine) { log.debug(`[Selector Location] Found in page object at ${pageObjectFile}:${pageObjectLine}`); locations.push({ file: pageObjectFile, line: pageObjectLine, isPageObject: true }); } } if (locations.length === 0) { log.debug("[Selector Location] Selector not found in test file or page objects"); } else { log.debug(`[Selector Location] Found ${locations.length} location(s)`); } return locations; } catch (error) { log.debug(`[Selector Location] Error: ${error instanceof Error ? error.message : String(error)}`); return []; } } // src/mobileSelectorPerformanceOptimizer/utils/browser-utils.ts import logger2 from "@wdio/logger"; var log2 = logger2("@wdio/appium-service:selector-optimizer"); function isNativeContext(browser) { if (!browser) { return false; } try { const browserWithNativeContext = browser; if ("instances" in browser && Array.isArray(browser.instances)) { log2.warn("Mobile Selector Performance Optimizer does not support MultiRemote sessions yet. Feature disabled for this session."); return false; } return browserWithNativeContext.isNativeContext === true; } catch { return false; } } // src/mobileSelectorPerformanceOptimizer/utils/timing.ts function getHighResTime() { return performance.now(); } // src/mobileSelectorPerformanceOptimizer/utils/selector-testing.ts import logger3 from "@wdio/logger"; var log3 = logger3("@wdio/appium-service:selector-optimizer"); async function extractMatchingElementsFromPageSource(browser, using, value) { try { const browserWithPageSource = browser; const pageSource = await browserWithPageSource.getPageSource(); if (!pageSource || typeof pageSource !== "string") { return []; } const matchingElements = []; if (using === "-ios predicate string") { const typeMatch = value.match(/type\s*==\s*'([^']+)'/); const elementType = typeMatch ? typeMatch[1] : null; const conditions = []; const attrPattern = /(\w+)\s*==\s*'([^']+)'/g; let attrMatch; while ((attrMatch = attrPattern.exec(value)) !== null) { if (attrMatch[1] !== "type") { conditions.push({ attr: attrMatch[1], value: attrMatch[2] }); } } if (!elementType) { return []; } const elementPattern = new RegExp(`<${elementType.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^>]*)>`, "gi"); let match; while ((match = elementPattern.exec(pageSource)) !== null) { const attrs = match[1] || ""; let matches = true; for (const condition of conditions) { const attrPattern2 = new RegExp(`${condition.attr}="([^"]*)"`, "i"); const attrMatch2 = attrs.match(attrPattern2); if (!attrMatch2 || attrMatch2[1] !== condition.value) { matches = false; break; } } if (matches) { matchingElements.push(match[0]); } } } else if (using === "-ios class chain") { const typeMatch = value.match(/^\*\*\/(\w+)/); const elementType = typeMatch ? typeMatch[1] : null; if (elementType) { const predicateMatch = value.match(/\[`([^`]+)`\]/); const conditions = []; if (predicateMatch) { const predicateContent = predicateMatch[1]; const attrPattern = /(\w+)\s*==\s*"([^"]+)"/g; let attrMatch; while ((attrMatch = attrPattern.exec(predicateContent)) !== null) { conditions.push({ attr: attrMatch[1], value: attrMatch[2] }); } } const elementPattern = new RegExp(`<${elementType.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^>]*)>`, "gi"); let match; while ((match = elementPattern.exec(pageSource)) !== null) { const attrs = match[1] || ""; let matches = true; for (const condition of conditions) { const attrPattern = new RegExp(`${condition.attr}="([^"]*)"`, "i"); const attrMatch = attrs.match(attrPattern); if (!attrMatch || attrMatch[1] !== condition.value) { matches = false; break; } } if (matches) { matchingElements.push(match[0]); } } } } return matchingElements; } catch { return []; } } async function testOptimizedSelector(browser, using, value, isMultiple, debug = false) { try { if (debug) { log3.debug(`[${LOG_PREFIX}: Debug] Step 1: Preparing to call findElement${isMultiple ? "s" : ""}`); log3.debug(`[${LOG_PREFIX}: Debug] Step 1.1: Using strategy: "${using}"`); log3.debug(`[${LOG_PREFIX}: Debug] Step 1.2: Selector value: "${value}"`); log3.debug(`[${LOG_PREFIX}: Debug] Step 1.3: Multiple elements: ${isMultiple}`); } const startTime = getHighResTime(); const browserWithProtocol = browser; if (debug) { log3.debug(`[${LOG_PREFIX}: Debug] Step 2: Executing findElement${isMultiple ? "s" : ""}(${JSON.stringify(using)}, ${JSON.stringify(value)})`); } let elementRefs = []; let duration; if (isMultiple) { const result = await browserWithProtocol.findElements(using, value); duration = getHighResTime() - startTime; elementRefs = Array.isArray(result) ? result : []; if (debug) { log3.debug(`[${LOG_PREFIX}: Debug] Step 3: findElements() completed`); log3.debug(`[${LOG_PREFIX}: Debug] Step 3.1: Found ${elementRefs.length} element(s)`); if (elementRefs.length > 0) { log3.debug(`[${LOG_PREFIX}: Debug] Step 3.2: Element reference(s): ${JSON.stringify(elementRefs)}`); } else { log3.debug(`[${LOG_PREFIX}: Debug] Step 3.2: No elements found - selector may not match any elements`); } log3.debug(`[${LOG_PREFIX}: Debug] Step 3.3: Execution time: ${duration.toFixed(2)}ms`); } } else { const result = await browserWithProtocol.findElement(using, value); duration = getHighResTime() - startTime; const isError = result && typeof result === "object" && "error" in result; const isValidElement = result && !isError && ("ELEMENT" in result || "element-6066-11e4-a52e-4f735466cecf" in result); elementRefs = isValidElement ? [result] : []; if (debug) { log3.debug(`[${LOG_PREFIX}: Debug] Step 3: findElement() completed`); if (isError) { log3.debug(`[${LOG_PREFIX}: Debug] Step 3.1: Element NOT found - error returned`); const errorMsg = result.message || result.error || "Unknown error"; log3.debug(`[${LOG_PREFIX}: Debug] Step 3.2: Error details: ${errorMsg}`); } else if (isValidElement) { log3.debug(`[${LOG_PREFIX}: Debug] Step 3.1: Element found successfully`); log3.debug(`[${LOG_PREFIX}: Debug] Step 3.2: Element reference: ${JSON.stringify(result)}`); } else { log3.debug(`[${LOG_PREFIX}: Debug] Step 3.1: No element found - selector may not match any element`); } log3.debug(`[${LOG_PREFIX}: Debug] Step 3.3: Execution time: ${duration.toFixed(2)}ms`); } } if (debug) { if (elementRefs.length > 0) { log3.debug(`[${LOG_PREFIX}: Debug] Step 4: Verification successful - selector is valid and found element(s)`); } if (elementRefs.length === 0) { log3.debug(`[${LOG_PREFIX}: Debug] Step 4: Verification failed - selector did not find any element(s)`); log3.debug(`[${LOG_PREFIX}: Debug] Step 5: Collecting fresh page source to investigate...`); log3.debug(`[${LOG_PREFIX}: Debug] Step 5.0: Searching for elements matching: ${using}="${value}"`); const matchingElements = await extractMatchingElementsFromPageSource(browser, using, value); if (matchingElements.length > 0) { log3.debug(`[${LOG_PREFIX}: Debug] Step 5.1: Found ${matchingElements.length} matching element(s) in fresh page source:`); matchingElements.forEach((element, index) => { const truncated = element.length > 200 ? element.substring(0, 200) + "..." : element; log3.debug(`[${LOG_PREFIX}: Debug] Step 5.1.${index + 1}: ${truncated}`); }); log3.debug(`[${LOG_PREFIX}: Debug] Step 5.2: Retrying selector with fresh page source state...`); const retryStartTime = getHighResTime(); try { if (isMultiple) { const retryResult = await browserWithProtocol.findElements(using, value); const retryDuration = getHighResTime() - retryStartTime; const retryElementRefs = Array.isArray(retryResult) ? retryResult : []; if (retryElementRefs.length > 0) { log3.debug(`[${LOG_PREFIX}: Debug] Step 5.3: Retry successful! Found ${retryElementRefs.length} element(s) in ${retryDuration.toFixed(2)}ms`); return { elementRefs: retryElementRefs, duration: retryDuration }; } log3.debug(`[${LOG_PREFIX}: Debug] Step 5.3: Retry failed - still no elements found (${retryDuration.toFixed(2)}ms)`); } else { const retryResult = await browserWithProtocol.findElement(using, value); const retryDuration = getHighResTime() - retryStartTime; const isError = retryResult && typeof retryResult === "object" && "error" in retryResult; const isValidElement = retryResult && !isError && ("ELEMENT" in retryResult || "element-6066-11e4-a52e-4f735466cecf" in retryResult); const retryElementRefs = isValidElement ? [retryResult] : []; if (retryElementRefs.length > 0) { log3.debug(`[${LOG_PREFIX}: Debug] Step 5.3: Retry successful! Found element in ${retryDuration.toFixed(2)}ms`); return { elementRefs: retryElementRefs, duration: retryDuration }; } const errorMsg = isError ? retryResult.message || retryResult.error || "Unknown error" : "No element found"; log3.debug(`[${LOG_PREFIX}: Debug] Step 5.3: Retry failed - ${errorMsg} (${retryDuration.toFixed(2)}ms)`); } } catch (retryError) { const retryDuration = getHighResTime() - retryStartTime; log3.debug(`[${LOG_PREFIX}: Debug] Step 5.3: Retry threw error: ${retryError instanceof Error ? retryError.message : String(retryError)} (${retryDuration.toFixed(2)}ms)`); } } else { log3.debug(`[${LOG_PREFIX}: Debug] Step 5.1: No matching elements found in fresh page source - element may have disappeared`); } } } return { elementRefs, duration }; } catch (error) { if (debug) { log3.debug(`[${LOG_PREFIX}: Debug] Step 3: findElement${isMultiple ? "s" : ""}() threw an error`); log3.debug(`[${LOG_PREFIX}: Debug] Step 3.1: Error: ${error instanceof Error ? error.message : String(error)}`); log3.debug(`[${LOG_PREFIX}: Debug] Step 4: Verification failed - selector execution error`); } return null; } } // src/mobileSelectorPerformanceOptimizer/utils/optimization.ts import logger7 from "@wdio/logger"; // src/mobileSelectorPerformanceOptimizer/utils/xpath-converter.ts import logger6 from "@wdio/logger"; // src/mobileSelectorPerformanceOptimizer/utils/xpath-constants.ts var UNMAPPABLE_XPATH_AXES = [ { pattern: /ancestor::/i, name: "ancestor axis" }, { pattern: /ancestor-or-self::/i, name: "ancestor-or-self axis" }, { pattern: /following-sibling::/i, name: "following-sibling axis" }, { pattern: /preceding-sibling::/i, name: "preceding-sibling axis" }, { pattern: /following::/i, name: "following axis" }, { pattern: /preceding::/i, name: "preceding axis" }, { pattern: /parent::/i, name: "parent axis" }, { pattern: /\/\.\.(?:\/|$|\[|\))/, name: "parent axis" } ]; var UNMAPPABLE_XPATH_FUNCTIONS = [ { pattern: /normalize-space\(/i, name: "normalize-space() function" }, { pattern: /position\(\)/i, name: "position() function" }, { pattern: /count\(/i, name: "count() function" } ]; var ATTRIBUTE_PRIORITY = ["name", "label", "value", "enabled", "visible", "accessible", "hittable"]; var MEANINGFUL_ATTRIBUTES = ["name", "label", "value"]; var BOOLEAN_ATTRIBUTES = ["enabled", "visible", "accessible", "hittable"]; // src/mobileSelectorPerformanceOptimizer/utils/xpath-detection.ts function detectUnmappableXPathFeatures(xpath2) { const unmappableFeatures = []; for (const axis of UNMAPPABLE_XPATH_AXES) { if (axis.pattern.test(xpath2)) { unmappableFeatures.push(axis.name); } } for (const func of UNMAPPABLE_XPATH_FUNCTIONS) { if (func.pattern.test(xpath2)) { unmappableFeatures.push(func.name); } } if (/substring\([^)]+\)/.test(xpath2)) { const textSubstringMatch = xpath2.match(/substring\(text\(\),\s*1\s*,\s*\d+\)/i); const attrSubstringMatch = xpath2.match(/substring\(@\w+,\s*1\s*,\s*\d+\)/i); if (!textSubstringMatch && !attrSubstringMatch) { const substringMatch = xpath2.match(/substring\([^,]+,\s*(\d+)/i); if (substringMatch && substringMatch[1] !== "1") { unmappableFeatures.push("complex substring() function (not starting at position 1)"); } } } if (containsUnionOperator(xpath2)) { unmappableFeatures.push("union operator (|)"); } return unmappableFeatures; } function containsUnionOperator(xpath2) { let depth = 0; let inSingleQuote = false; let inDoubleQuote = false; for (let i = 0; i < xpath2.length; i++) { const char = xpath2[i]; if (char === "'" && !inDoubleQuote) { inSingleQuote = !inSingleQuote; } else if (char === '"' && !inSingleQuote) { inDoubleQuote = !inDoubleQuote; } else if (!inSingleQuote && !inDoubleQuote) { if (char === "[" || char === "(") { depth++; } else if (char === "]" || char === ")") { depth--; } else if (char === "|" && depth === 0) { return true; } } } return false; } // src/mobileSelectorPerformanceOptimizer/utils/xpath-page-source.ts import logger4 from "@wdio/logger"; var log4 = logger4("@wdio/appium-service:selector-optimizer"); function isSelectorUniqueInPageSource(selector, pageSource) { try { if (selector.startsWith("~")) { return isAccessibilityIdUnique(selector.substring(1), pageSource); } else if (selector.startsWith("-ios predicate string:")) { const predicateString = selector.substring("-ios predicate string:".length); return countMatchingElementsByPredicate(predicateString, pageSource) === 1; } else if (selector.startsWith("-ios class chain:")) { const chainString = selector.substring("-ios class chain:".length); return countMatchingElementsByClassChain(chainString, pageSource) === 1; } return false; } catch (error) { log4.debug(`Selector uniqueness check failed: ${error instanceof Error ? error.message : String(error)}`); return false; } } function isAccessibilityIdUnique(value, pageSource) { const escapedValue = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const namePattern = new RegExp(`<\\w+[^>]*\\s+name="${escapedValue}"[^>]*>`, "gi"); const labelPattern = new RegExp(`<\\w+[^>]*\\s+label="${escapedValue}"[^>]*>`, "gi"); const nameMatches = pageSource.match(namePattern) || []; const labelMatches = pageSource.match(labelPattern) || []; const allMatches = /* @__PURE__ */ new Set([...nameMatches, ...labelMatches]); return allMatches.size === 1; } function countMatchingElementsByPredicate(predicateString, pageSource) { const conditions = parsePredicateConditions(predicateString); const typeMatch = predicateString.match(/type\s*==\s*'([^']+)'/); const elementType = typeMatch ? typeMatch[1] : null; const elementPattern = elementType ? new RegExp(`<${elementType.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^>]*)>`, "gi") : /<(\w+)([^>]*)>/gi; let match; let count = 0; while ((match = elementPattern.exec(pageSource)) !== null) { const attrs = match[1] || match[2] || ""; if (elementType && !match[0].includes(`<${elementType}`)) { continue; } if (matchesPredicateConditions(attrs, conditions)) { count++; } } return count; } function parsePredicateConditions(predicateString) { const conditions = []; const attrPattern = /(\w+)\s*==\s*'([^']+)'/g; let attrMatch; while ((attrMatch = attrPattern.exec(predicateString)) !== null) { if (attrMatch[1] !== "type") { conditions.push({ attr: attrMatch[1], op: "==", value: attrMatch[2] }); } } return conditions; } function matchesPredicateConditions(attrs, conditions) { for (const condition of conditions) { const attrPattern = new RegExp(`${condition.attr}="([^"]*)"`, "i"); const attrMatch = attrs.match(attrPattern); if (!attrMatch || attrMatch[1] !== condition.value) { return false; } } return true; } function countMatchingElementsByClassChain(chainString, pageSource) { const typeMatch = chainString.match(/^\*\*\/(\w+)/); const elementType = typeMatch ? typeMatch[1] : null; if (!elementType) { return 0; } const predicateMatch = chainString.match(/\[`([^`]+)`\]/); const conditions = []; if (predicateMatch) { const predicateContent = predicateMatch[1]; const attrPattern = /(\w+)\s*==\s*"([^"]+)"/g; let attrMatch; while ((attrMatch = attrPattern.exec(predicateContent)) !== null) { conditions.push({ attr: attrMatch[1], op: "==", value: attrMatch[2] }); } } const elementPattern = new RegExp(`<${elementType.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^>]*)>`, "gi"); let match; let count = 0; while ((match = elementPattern.exec(pageSource)) !== null) { const attrs = match[1] || ""; let matches = true; for (const condition of conditions) { const attrPattern = new RegExp(`${condition.attr}="([^"]*)"`, "i"); const attrMatch = attrs.match(attrPattern); if (!attrMatch || attrMatch[1] !== condition.value) { matches = false; break; } } if (matches) { count++; } } return count; } // src/mobileSelectorPerformanceOptimizer/utils/xpath-selector-builder.ts function buildSelectorFromElementData(elementData, pageSource) { const { type, attributes } = elementData; const name = attributes.name || attributes.label; if (name) { const accessibilitySelector = `~${name}`; if (isSelectorUniqueInPageSource(accessibilitySelector, pageSource)) { return { selector: accessibilitySelector }; } } if (type) { const predicateResult = buildUniquePredicateString(type, attributes, pageSource); if (predicateResult) { return predicateResult; } } if (type) { const classChainResult = buildUniqueClassChain(type, attributes, pageSource); if (classChainResult) { return classChainResult; } } return { selector: null, warning: "Could not generate a unique selector from element data. Multiple elements may match the suggested selector." }; } function buildUniquePredicateString(type, attributes, pageSource) { const predicateParts = [`type == '${type}'`]; let selector = `-ios predicate string:${predicateParts.join(" AND ")}`; if (isSelectorUniqueInPageSource(selector, pageSource)) { return { selector }; } const name = attributes.name; const label = attributes.label; const nameEqualsLabel = name && label && name === label; for (const attr of MEANINGFUL_ATTRIBUTES) { if (attr === "label" && nameEqualsLabel) { continue; } if (attributes[attr] !== void 0) { const value = attributes[attr]; if (typeof value === "string" && value.length > 0) { predicateParts.push(`${attr} == '${value}'`); selector = `-ios predicate string:${predicateParts.join(" AND ")}`; if (isSelectorUniqueInPageSource(selector, pageSource)) { return { selector }; } } } } for (const attr of BOOLEAN_ATTRIBUTES) { if (attributes[attr] === "true") { predicateParts.push(`${attr} == 'true'`); selector = `-ios predicate string:${predicateParts.join(" AND ")}`; if (isSelectorUniqueInPageSource(selector, pageSource)) { return { selector }; } } } const meaningfulOnlyParts = predicateParts.filter((part) => { const attr = part.split(" == ")[0]; return !BOOLEAN_ATTRIBUTES.includes(attr); }); if (meaningfulOnlyParts.length > 1) { return { selector: `-ios predicate string:${meaningfulOnlyParts.join(" AND ")}`, warning: "Selector may match multiple elements. Consider adding more specific attributes." }; } if (predicateParts.length > 1) { return { selector: `-ios predicate string:${predicateParts.join(" AND ")}`, warning: "Selector may match multiple elements. Consider adding more specific attributes." }; } return null; } function buildUniqueClassChain(type, attributes, pageSource) { const chain = `**/${type}`; const predicateParts = []; for (const attr of ATTRIBUTE_PRIORITY) { if (attributes[attr] !== void 0) { const value = attributes[attr]; if (typeof value === "string" && value.length > 0) { predicateParts.push(`${attr} == "${value}"`); const selector = `-ios class chain:${chain}[\`${predicateParts.join(" AND ")}\`]`; if (isSelectorUniqueInPageSource(selector, pageSource)) { return { selector }; } } } } if (predicateParts.length > 0) { return { selector: `-ios class chain:${chain}[\`${predicateParts.join(" AND ")}\`]`, warning: "Selector may match multiple elements. Consider adding more specific attributes." }; } const basicSelector = `-ios class chain:${chain}`; if (isSelectorUniqueInPageSource(basicSelector, pageSource)) { return { selector: basicSelector }; } return { selector: basicSelector, warning: "Selector may match multiple elements. Consider adding more specific attributes." }; } // src/mobileSelectorPerformanceOptimizer/utils/xpath-page-source-executor.ts import { DOMParser } from "@xmldom/xmldom"; import xpath from "xpath"; import logger5 from "@wdio/logger"; var log5 = logger5("@wdio/appium-service:selector-optimizer"); function executeXPathOnPageSource(xpathExpr, pageSource) { if (!pageSource || !xpathExpr) { return null; } try { const doc = new DOMParser().parseFromString(pageSource, "text/xml"); const parseErrors = doc.getElementsByTagName("parsererror"); if (parseErrors.length > 0) { log5.debug("XML parsing error in page source"); return null; } const nodes = xpath.select(xpathExpr, doc); if (!nodes || !Array.isArray(nodes) || nodes.length === 0) { return []; } const elements = []; for (const node of nodes) { if (node && typeof node === "object" && "nodeName" in node && "attributes" in node) { const elementNode = node; const attributes = {}; if (elementNode.attributes) { for (let i = 0; i < elementNode.attributes.length; i++) { const attr = elementNode.attributes[i]; if (attr && attr.name && attr.value !== void 0) { attributes[attr.name] = attr.value; } } } elements.push({ type: elementNode.nodeName, attributes }); } } return elements; } catch (error) { log5.debug(`XPath execution failed: ${error instanceof Error ? error.message : String(error)}`); return null; } } function findElementByXPathWithFallback(xpathExpr, pageSource) { const elements = executeXPathOnPageSource(xpathExpr, pageSource); if (!elements || elements.length === 0) { return null; } return { element: elements[0], matchCount: elements.length }; } // src/mobileSelectorPerformanceOptimizer/utils/xpath-converter.ts var log6 = logger6("@wdio/appium-service:selector-optimizer"); async function convertXPathToOptimizedSelector(xpath2, options) { if (!xpath2 || typeof xpath2 !== "string") { return null; } const unmappableFeatures = detectUnmappableXPathFeatures(xpath2); const hasUnmappableFeatures = unmappableFeatures.length > 0; const unmappableWarning = hasUnmappableFeatures ? `XPath contains unmappable features: ${unmappableFeatures.join(", ")}.` : void 0; try { const browserWithPageSource = options.browser; const pageSource = await browserWithPageSource.getPageSource(); if (!pageSource || typeof pageSource !== "string") { return { selector: null, warning: hasUnmappableFeatures ? `${unmappableWarning} Page source unavailable.` : "Page source unavailable." }; } const result = findElementByXPathWithFallback(xpath2, pageSource); if (!result) { return { selector: null, warning: hasUnmappableFeatures ? `${unmappableWarning} Element not found in page source.` : "Element not found in page source." }; } const { element, matchCount } = result; const selectorResult = buildSelectorFromElementData(element, pageSource); if (!selectorResult || !selectorResult.selector) { return { selector: null, warning: hasUnmappableFeatures ? `${unmappableWarning} Could not build selector from element attributes.` : "Could not build selector from element attributes." }; } if (matchCount > 1) { return { selector: null, warning: `XPath matched ${matchCount} elements. The suggested selector may not be unique. You can use this selector but be aware it may match multiple elements.`, suggestion: selectorResult.selector }; } return selectorResult; } catch (error) { log6.debug(`Page source analysis failed: ${error instanceof Error ? error.message : String(error)}`); return { selector: null, warning: hasUnmappableFeatures ? `${unmappableWarning} Page source analysis failed.` : "Page source analysis failed." }; } } // src/mobileSelectorPerformanceOptimizer/utils/optimization.ts var log7 = logger7("@wdio/appium-service:selector-optimizer"); async function findOptimizedSelector(xpath2, options) { log7.info(`[${LOG_PREFIX}: Step 2] Collecting page source for dynamic analysis...`); const pageSourceStartTime = getHighResTime(); const result = await convertXPathToOptimizedSelector(xpath2, { browser: options.browser }); const pageSourceDuration = getHighResTime() - pageSourceStartTime; log7.info(`[${LOG_PREFIX}: Step 2] Page source collected in ${pageSourceDuration.toFixed(2)}ms`); return result; } // src/mobileSelectorPerformanceOptimizer/utils/formatting.ts import logger8 from "@wdio/logger"; var log8 = logger8("@wdio/appium-service:selector-optimizer"); function formatSelectorForDisplay(selector, maxLength = 100) { if (typeof selector === "string") { if (selector.length > maxLength) { return selector.substring(0, maxLength) + "..."; } return selector; } return String(selector); } function formatSelectorLocations(locations) { if (locations.length === 0) { return ""; } if (locations.length === 1) { const loc = locations[0]; const fileDisplay = loc.isPageObject ? `${loc.file} (page object)` : loc.file; return ` at ${fileDisplay}:${loc.line}`; } const locationStrings = locations.map((loc) => { const fileDisplay = loc.isPageObject ? `${loc.file} (page object)` : loc.file; return `${fileDisplay}:${loc.line}`; }); return ` at multiple locations: ${locationStrings.join(", ")}. Note: The selector was found in ${locations.length} files. Please verify which one is correct.`; } function logOptimizationConclusion(timeDifference, improvementPercent, originalSelector, optimizedSelector, locationInfo = "") { const formattedOriginal = formatSelectorForDisplay(originalSelector); const formattedOptimized = formatSelectorForDisplay(optimizedSelector); const quoteStyle = optimizedSelector.startsWith("-ios class chain:") ? "'" : '"'; if (timeDifference > 0) { log8.info(`[${LOG_PREFIX}: Conclusion] Optimized selector is ${timeDifference.toFixed(2)}ms faster than XPath (${improvementPercent.toFixed(1)}% improvement)`); log8.info(`[${LOG_PREFIX}: Advice] Consider using the optimized selector ${quoteStyle}${formattedOptimized}${quoteStyle} for better performance${locationInfo ? locationInfo : ""}.`); } else if (timeDifference < 0) { log8.info(`[${LOG_PREFIX}: Conclusion] Optimized selector is ${Math.abs(timeDifference).toFixed(2)}ms slower than XPath`); log8.info(`[${LOG_PREFIX}: Advice] There is no improvement in performance, consider using the original selector '${formattedOriginal}' if performance is critical. If performance is not critical, you can use the optimized selector ${quoteStyle}${formattedOptimized}${quoteStyle} for better stability${locationInfo ? locationInfo : ""}.`); } else { log8.info(`[${LOG_PREFIX}: Conclusion] Optimized selector has the same performance as XPath`); log8.info(`[${LOG_PREFIX}: Advice] There is no improvement in performance, consider using the original selector '${formattedOriginal}' if performance is critical. If performance is not critical, you can use the optimized selector ${quoteStyle}${formattedOptimized}${quoteStyle} for better stability${locationInfo ? locationInfo : ""}.`); } } // src/mobileSelectorPerformanceOptimizer/utils/performance-data.ts function createOptimizedSelectorData(testContext, originalSelector, originalDuration, optimizedSelector, optimizedDuration) { const timeDifference = originalDuration - optimizedDuration; const improvementPercent = originalDuration > 0 ? timeDifference / originalDuration * 100 : 0; return { testFile: testContext.testFile || "unknown", suiteName: testContext.suiteName, testName: testContext.testName, lineNumber: testContext.lineNumber, selectorFile: testContext.selectorFile, selector: originalSelector, selectorType: "xpath", duration: originalDuration, timestamp: Date.now(), deviceName: getCurrentDeviceName(), optimizedSelector, optimizedDuration, improvementMs: timeDifference, improvementPercent }; } function storePerformanceData(timing, duration, testContext) { const data = { testFile: testContext.testFile || "unknown", suiteName: testContext.suiteName, testName: testContext.testName, lineNumber: testContext.lineNumber, selector: timing.selector, selectorType: timing.selectorType, duration, timestamp: Date.now(), deviceName: getCurrentDeviceName() }; addPerformanceData(data); } // src/mobileSelectorPerformanceOptimizer/utils/command-timing.ts function findMostRecentUnmatchedUserCommand(commandTimings) { return Array.from(commandTimings.entries()).filter(([_id, timing]) => timing.isUserCommand && !timing.selectorType).sort(([_idA, a], [_idB, b]) => b.startTime - a.startTime)[0]; } function findMatchingInternalCommandTiming(commandTimings, formattedSelector, selectorType) { return Array.from(commandTimings.entries()).filter( ([_id, timing]) => !timing.isUserCommand && timing.formattedSelector === formattedSelector && timing.selectorType === selectorType ).sort(([_idA, a], [_idB, b]) => b.startTime - a.startTime)[0]; } // src/mobileSelectorPerformanceOptimizer/utils/reporter.ts import path2 from "node:path"; import { SevereServiceError } from "webdriverio"; function isReporterRegistered(reporters, reporterName) { return reporters.some((reporter) => { if (Array.isArray(reporter)) { const reporterClass = reporter[0]; if (typeof reporterClass === "function") { return reporterClass.name === reporterName; } return false; } if (typeof reporter === "function") { return reporter.name === reporterName; } return false; }); } function determineReportDirectory(reportPath, config, appiumServiceOptions) { let reportDir; if (reportPath) { reportDir = path2.isAbsolute(reportPath) ? reportPath : path2.join(process.cwd(), reportPath); } else if (config?.outputDir) { reportDir = path2.isAbsolute(config.outputDir) ? config.outputDir : path2.join(process.cwd(), config.outputDir); } else if (appiumServiceOptions?.logPath) { reportDir = path2.isAbsolute(appiumServiceOptions.logPath) ? appiumServiceOptions.logPath : path2.join(process.cwd(), appiumServiceOptions.logPath); } else if (appiumServiceOptions?.args?.log) { const logPath = appiumServiceOptions.args.log; reportDir = path2.isAbsolute(logPath) ? path2.dirname(logPath) : path2.join(process.cwd(), path2.dirname(logPath)); } if (!reportDir) { throw new SevereServiceError( "Mobile Selector Performance Optimizer: JSON report cannot be created. Please provide one of the following:\n 1. reportPath in trackSelectorPerformance service options\n 2. outputDir in WebdriverIO config\n 3. logPath in Appium service options\n 4. log in Appium service args" ); } return reportDir; } // src/mobileSelectorPerformanceOptimizer/markdown-formatter.ts function countSelectorUsage(data) { const counts = /* @__PURE__ */ new Map(); for (const entry of data) { const count = counts.get(entry.selector) || 0; counts.set(entry.selector, count + 1); } return counts; } function deduplicateSelectors(data, usageCounts) { const selectorMap = /* @__PURE__ */ new Map(); for (const entry of data) { if (!entry.optimizedSelector || entry.improvementMs === void 0) { continue; } const existing = selectorMap.get(entry.selector); const current = { selector: entry.selector, optimizedSelector: entry.optimizedSelector, improvementMs: entry.improvementMs, improvementPercent: entry.improvementPercent || 0, lineNumber: entry.lineNumber, selectorFile: entry.selectorFile, testFile: entry.testFile, usageCount: usageCounts.get(entry.selector) || 1 }; if (!existing || Math.abs(current.improvementMs) > Math.abs(existing.improvementMs)) { if (existing && !current.selectorFile && existing.selectorFile) { current.selectorFile = existing.selectorFile; current.lineNumber = existing.lineNumber; } selectorMap.set(entry.selector, current); } else if (!existing.selectorFile && current.selectorFile) { existing.selectorFile = current.selectorFile; existing.lineNumber = current.lineNumber; } } return Array.from(selectorMap.values()); } function groupByFile(optimizations) { const fileMap = /* @__PURE__ */ new Map(); const workspaceWide = []; for (const opt of optimizations) { const filePath = opt.selectorFile; if (filePath && opt.lineNumber) { if (!fileMap.has(filePath)) { fileMap.set(filePath, []); } fileMap.get(filePath).push(opt); } else { workspaceWide.push(opt); } } const fileGroups = []; for (const [filePath, opts] of fileMap.entries()) { opts.sort((a, b) => (a.lineNumber || 0) - (b.lineNumber || 0)); const totalSavingsMs = opts.reduce((sum, o) => sum + o.improvementMs, 0); const totalSavingsWithUsage = opts.reduce((sum, o) => sum + o.improvementMs * o.usageCount, 0); fileGroups.push({ filePath, optimizations: opts, totalSavingsMs, totalSavingsWithUsage }); } fileGroups.sort((a, b) => b.totalSavingsWithUsage - a.totalSavingsWithUsage); workspaceWide.sort((a, b) => b.improvementMs * b.usageCount - a.improvementMs * a.usageCount); return { fileGroups, workspaceWide }; } function escapeForTable(str) { return str.replace(/\\/g, "\\\\").replace(/\|/g, "\\|"); } function getQuoteStyle(selector) { if (selector.startsWith("//") || selector.startsWith("/")) { return "'"; } if (selector.startsWith("-ios class chain:")) { return "'"; } return '"'; } function formatSelector(selector) { const truncated = formatSelectorForDisplay(selector, 60); const quote = getQuoteStyle(selector); return `\`$(${quote}${escapeForTable(truncated)}${quote})\``; } function toRelativePath(filePath, projectRoot) { if (!projectRoot) { return filePath; } if (!path3.isAbsolute(filePath)) { return filePath; } return path3.relative(projectRoot, filePath); } function getFileName(filePath) { return path3.basename(filePath); } function formatFileLink(filePath, lineNumber, projectRoot) { const relativePath = toRelativePath(filePath, projectRoot); const fileName = getFileName(filePath); if (lineNumber) { return `[\`${fileName}:${lineNumber}\`](${relativePath}#L${lineNumber})`; } return `[\`${fileName}\`](${relativePath})`; } function formatLineLink(filePath, lineNumber, projectRoot) { if (!lineNumber) { return "L?:"; } const relativePath = toRelativePath(filePath, projectRoot); return `[L${lineNumber}:](${relativePath}#L${lineNumber})`; } function formatTime(timestamp) { const date = new Date(timestamp); return date.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); } function formatDuration(ms) { if (ms < 1e3) { return `${ms.toFixed(0)}ms`; } const seconds = ms / 1e3; if (seconds < 60) { return `${seconds.toFixed(2)}s`; } const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${minutes}m ${remainingSeconds.toFixed(0)}s`; } function generateMarkdownReport(optimizedSelectors, deviceName, timingInfo, projectRoot) { const lines = []; lines.push("# \u{1F4CA} Mobile Selector Performance Optimizer Report"); lines.push(""); if (optimizedSelectors.length === 0) { lines.push(`**Device:** ${deviceName}`); lines.push(`**Generated:** ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}`); lines.push(""); lines.push("## \u2705 Summary"); lines.push(""); lines.push("No optimization opportunities found. All selectors are already optimized!"); lines.push(""); return lines.join("\n"); } const usageCounts = countSelectorUsage(optimizedSelectors); const deduplicated = deduplicateSelectors(optimizedSelectors, usageCounts); const positiveOptimizations = deduplicated.filter((o) => o.improvementMs > 0); const negativeOptimizations = deduplicated.filter((o) => o.improvementMs < 0); const totalSavingsMs = positiveOptimizations.reduce((sum, o) => sum + o.improvementMs * o.usageCount, 0); const avgImprovement = positiveOptimizations.length > 0 ? positiveOptimizations.reduce((sum, o) => sum + o.improvementPercent, 0) / positiveOptimizations.length : 0; const highImpact = positiveOptimizations.filter((o) => o.improvementPercent >= 50); const mediumImpact = positiveOptimizations.filter((o) => o.improvementPercent >= 20 && o.improvementPercent < 50); const lowImpact = positiveOptimizations.filter((o) => o.improvementPercent >= 10 && o.improvementPercent < 20); const minorImpact = positiveOptimizations.filter((o) => o.improvementPercent > 0 && o.improvementPercent < 10); lines.push(`**Device:** ${deviceName}`); if (timingInfo) { lines.push(`**Run Time:** ${formatTime(timingInfo.startTime)} \u2192 ${formatTime(timingInfo.endTime)} (${formatDuration(timingInfo.totalRunDurationMs)})`); } lines.push(`**Analyzed:** ${deduplicated.length} unique selectors (${positiveOptimizations.length} optimizable${negativeOptimizations.length > 0 ? `, ${negativeOptimizations.length} not recommended` : ""})`); lines.push(""); const savingsLine = `**Total Potential Savings:** **${formatDuration(totalSavingsMs)}** per test run`; if (timingInfo && timingInfo.totalRunDurationMs > 0) { const improvementPercent = totalSavingsMs / timingInfo.totalRunDurationMs * 100; lines.push(`${savingsLine} (**${improvementPercent.toFixed(1)}%** of total run time)`); } else { lines.push(savingsLine); } lines.push(`**Average Improvement per Selector:** **${avgImprovement.toFixed(1)}%** faster`); lines.push(""); lines.push("---"); lines.push(""); lines.push("## \u{1F4C8} Summary"); lines.push(""); lines.push("| Impact Level | Count | Action |"); lines.push("|:-------------|------:|:-------|"); if (highImpact.length > 0) { lines.push(`| \u{1F534} **High** (>50% gain) | ${highImpact.length} | Fix immediately |`); }