@wdio/appium-service
Version:
A WebdriverIO service to start & stop Appium Server
1,244 lines (1,217 loc) • 119 kB
JavaScript
// 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 |`);
}