diff --git a/.gitignore b/.gitignore index ab951233f..841c91c50 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ .env node_modules/ browse/dist/ +# browse-mobile dist is a JS bundle + shell script (3.2MB) — small enough to commit +# unlike browse/dist/ which is a 58MB compiled binary design/dist/ bin/gstack-global-discover .gstack/ diff --git a/SKILL.md b/SKILL.md index fa2729051..05bd0356e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -520,6 +520,48 @@ $B attrs "#logo" # returns all attributes as JSON $B css ".button" "background-color" ``` +## Mobile Testing (optional — browse-mobile) + +If `browse-mobile` is built (`browse-mobile/dist/browse-mobile`), you can use `$BM` for mobile app automation on iOS Simulator via Appium. This is detected automatically by `/qa` and `/qa-only` when an `app.json` or `app.config.js` is present. + +### Mobile commands + +```bash +# Launch an app on the simulator +$BM goto app://com.example.myapp + +# Take a snapshot (accessibility tree with @e refs) +$BM snapshot -i + +# Tap an element by ref +$BM click @e3 + +# Tap by accessibility label (for elements without accessibilityRole) +$BM click label:Sign In + +# Fill a text field +$BM fill @e3 "user@acme.co" + +# Screenshot the simulator +$BM screenshot /tmp/screen.png + +# Scroll and navigate +$BM scroll down +$BM back +$BM viewport landscape + +# List tappable elements and input fields +$BM links +$BM forms +``` + +**Key differences from web (`$B`):** +- Elements are `XCUIElementType*` (Button, TextField, etc.) instead of HTML roles +- Many RN components lack `accessibilityRole` — use `~"Label Text"` to click by accessibility label +- Web-only commands (`console`, `cookies`, `js`, `html`, `css`) return "not supported" +- Form fill may use coordinate tap + keyboard when elements lack accessibility metadata +- Requires Appium running (`appium` in a separate terminal) and iOS Simulator booted + ## Snapshot System The snapshot is your primary tool for understanding and interacting with pages. diff --git a/SKILL.md.tmpl b/SKILL.md.tmpl index 39b6873e2..051bea47b 100644 --- a/SKILL.md.tmpl +++ b/SKILL.md.tmpl @@ -256,6 +256,48 @@ $B attrs "#logo" # returns all attributes as JSON $B css ".button" "background-color" ``` +## Mobile Testing (optional — browse-mobile) + +If `browse-mobile` is built (`browse-mobile/dist/browse-mobile`), you can use `$BM` for mobile app automation on iOS Simulator via Appium. This is detected automatically by `/qa` and `/qa-only` when an `app.json` or `app.config.js` is present. + +### Mobile commands + +```bash +# Launch an app on the simulator +$BM goto app://com.example.myapp + +# Take a snapshot (accessibility tree with @e refs) +$BM snapshot -i + +# Tap an element by ref +$BM click @e3 + +# Tap by accessibility label (for elements without accessibilityRole) +$BM click label:Sign In + +# Fill a text field +$BM fill @e3 "user@acme.co" + +# Screenshot the simulator +$BM screenshot /tmp/screen.png + +# Scroll and navigate +$BM scroll down +$BM back +$BM viewport landscape + +# List tappable elements and input fields +$BM links +$BM forms +``` + +**Key differences from web (`$B`):** +- Elements are `XCUIElementType*` (Button, TextField, etc.) instead of HTML roles +- Many RN components lack `accessibilityRole` — use `~"Label Text"` to click by accessibility label +- Web-only commands (`console`, `cookies`, `js`, `html`, `css`) return "not supported" +- Form fill may use coordinate tap + keyboard when elements lack accessibility metadata +- Requires Appium running (`appium` in a separate terminal) and iOS Simulator booted + ## Snapshot System {{SNAPSHOT_FLAGS}} diff --git a/TODOS.md b/TODOS.md index b8314ab2a..79749b01d 100644 --- a/TODOS.md +++ b/TODOS.md @@ -689,3 +689,113 @@ Shipped in v0.6.5. TemplateContext in gen-skill-docs.ts bakes skill name into pr ### Auto-upgrade mode + smart update check - Config CLI (`bin/gstack-config`), auto-upgrade via `~/.gstack/config.yaml`, 12h cache TTL, exponential snooze backoff (24h→48h→1wk), "never ask again" option, vendored copy sync on upgrade **Completed:** v0.3.8 + +## Browse-Mobile (Appium Driver for Mobile QA) + +### Investigate xcrun simctl + XCTest as alternative to Appium + +**What:** During the Phase 1 spike, benchmark Appium vs. native Apple tooling (xcrun simctl for lifecycle, XCTest/xctestctl for element inspection + interaction, xcrun simctl io screenshot for captures) on cold start latency, element tree reliability, and setup friction. + +**Why:** Outside voice in eng review challenged the Appium choice — it's a heavyweight Java-dependent server designed for CI, not interactive dev tools. For an iOS-only Phase 1, native tooling may be faster (no JVM boot), more reliable (direct XCTest element tree vs Appium's XML translation), and zero Java dependency. Cross-platform Appium benefit is irrelevant when Android Phase 2 would use adb + UIAutomator directly anyway. + +**Context:** The design doc chose Appium for cross-platform maturity. But the eng review decided on a separate binary (not shared CLI), meaning each platform already gets its own driver. If native xcrun/XCTest is significantly better for iOS, Appium adds cost without value until a unified cross-platform abstraction is needed. + +**Effort:** S (spike comparison during Phase 1 proof-of-concept) +**Priority:** P1 +**Depends on:** Phase 1 spike start + +### Document/automate app build prerequisite + +**What:** The QA flow assumes the Expo app is already built and running on the simulator. Document this as a prerequisite, or automate: detect if Metro bundler is running (check port 8081), offer to start `expo start`, or require a pre-built .app path. + +**Why:** Outside voice flagged this as a Phase 1 blocker: the plan jumps to Appium automation without specifying who builds and serves the JS bundle. A developer running `/qa` for the first time will hit "app not found" with no guidance. + +**Context:** For Expo: `expo start` must be running, or `npx expo run:ios` must have been run to build a .app. For bare RN: Metro bundler + built .app. Detection: check `lsof -i :8081` for Metro, check `xcrun simctl listapps booted` for installed app. + +**Effort:** S +**Priority:** P1 +**Depends on:** browse-mobile server.ts implementation + +### Screenshot disk-full error handling + +**What:** When screenshot save fails (disk full, permission error), return an actionable error to Claude instead of silently failing. Check `fs.writeFile` result and return `{ error: "disk_full", message: "Screenshot save failed — disk may be full" }`. + +**Why:** Coverage diagram identified this as the only critical gap: a failure mode with no test, no error handling, and silent user impact. + +**Context:** Applies to both browse-mobile and potentially browse/ (same pattern). Simple `try/catch` on the write with an informative error response. + +**Effort:** S +**Priority:** P2 +**Depends on:** None + +### Revyl command table validation test + +**What:** Add a gen-skill-docs test that parses the Revyl command mapping table from generated SKILL.md files and validates command flags against a known-good reference list (e.g., `revyl device swipe` requires `--direction` + `--x/--y` or `--target`; `revyl device type` requires `--target` + `--text`). + +**Why:** The Revyl command table has been wrong 3 times now (--output→--out, swipe missing --x/--y, type missing --target). Each time it was caught by live testing, not automated checks. A validation test in `bun test` would catch drift immediately. + +**Context:** Pattern: existing `gen-skill-docs.test.ts` already validates generated SKILL.md content. This adds a focused check for Revyl CLI command syntax. The reference list is a simple map of command→required flags. Does NOT require `revyl` CLI installed — just validates the template output against known-correct syntax. + +**Effort:** S +**Priority:** P2 +**Depends on:** None + +### Revyl auth in headless/CI environments + +**What:** Handle `revyl auth login` failing in environments without a browser (SSH sessions, CI runners, headless servers). Detect headless environment and offer `revyl auth token ` as alternative. + +**Why:** `revyl auth login` opens a browser for OAuth. If no browser is available, the user gets stuck with no guidance. This blocks mobile QA in CI/CD and remote dev environments. + +**Context:** The QA template now auto-runs `revyl auth login` when auth is needed, with an AskUserQuestion fallback. But the fallback could be smarter: detect `$DISPLAY` (Linux) or `$SSH_CONNECTION` to preemptively offer the token-based auth path instead of trying browser auth first. + +**Effort:** S +**Priority:** P3 +**Depends on:** None + +### Revyl mobile QA E2E test + +**What:** E2E test that runs `/qa` against a test Expo app using Revyl cloud device, verifying the full flow: detect → init → build → upload → test → report. + +**Why:** The Revyl QA path (added in the dual-backend update) has no automated testing. A gen-skill-docs quality check catches template regressions but can't verify the actual QA flow works end-to-end with Revyl's CLI and device provisioning. + +**Context:** All other skill paths have E2E tests via `claude -p`. The Revyl mobile path is the only untested flow. Requires a Revyl account, costs per device session, and an Expo test app fixture. Pattern: existing E2E tests in `test/skill-e2e-*.test.ts`. + +**Effort:** M +**Priority:** P2 +**Depends on:** Revyl test account setup, CI Revyl auth + +### /browse skill Revyl integration (dev loop mode) + +**What:** Add Revyl dev loop mode to `/browse` skill for interactive mobile development with hot reload on cloud devices. + +**Why:** The `/qa` skill now supports Revyl in static mode (Release build). `/browse` needs the complementary dev loop mode for hot-reload interactive development. The initial mobile QA report proved that dev loop works once tunnel DNS stabilizes — it just needs proper tunnel verification and retry logic. + +**Context:** The /qa skill's Revyl detection infrastructure (auth check, app-id resolution, YAML validation) can be reused. Dev loop adds: Metro + Cloudflare tunnel startup, tunnel health polling, deep link handling, and the "Open in X?" iOS dialog workaround. Tunnel race condition (30s DNS propagation) is the main reliability risk. + +**Effort:** M +**Priority:** P2 +**Depends on:** This branch landing (Revyl detection infrastructure in gen-skill-docs.ts) + +### Revyl dev loop: reuse existing Metro instead of starting a new one — PARTIALLY FIXED + +**What:** ~~When the QA skill tries the dev loop path and Metro is already running on port 8081, detect and reuse it instead of starting a second Metro process.~~ + +**Shipped (partial):** The QA template now detects existing Metro on :8081 via `lsof` and kills it before `revyl dev start`, avoiding the 65s timeout. This is the "kill and restart" approach. + +**Remaining:** A smarter approach would reuse the existing Metro instead of killing it — tell Revyl which port to connect to. This avoids the Metro restart overhead but requires understanding Revyl's port configuration options. + +**Effort:** S +**Priority:** P3 +**Depends on:** Understanding Revyl's `--port` or tunnel config options + +### Android Revyl support + +**What:** Extend Revyl mobile QA path to support Android devices (`--platform android`), including APK build pipeline, ADB-based app installation, and Android-specific system dialog handling. + +**Why:** The current Revyl mobile QA only supports iOS. Revyl itself supports Android. Many mobile apps target both platforms and need cross-platform QA. + +**Context:** Android differences: APK build instead of .app (`npx expo run:android` or EAS), different system dialogs (no "Open in X?" confirmation), different accessibility tree format. The Revyl command API is the same (`revyl device start --platform android`). browse-mobile (Appium) is also iOS-only — this TODO covers both backends. + +**Effort:** M +**Priority:** P3 +**Depends on:** This branch landing (Revyl iOS support) diff --git a/browse-mobile/dist/browse-mobile b/browse-mobile/dist/browse-mobile new file mode 100755 index 000000000..762617baf --- /dev/null +++ b/browse-mobile/dist/browse-mobile @@ -0,0 +1,4 @@ +#!/bin/bash +# browse-mobile launcher — runs the bundled JS with bun +DIR="$(cd "$(dirname "$0")" && pwd)" +exec bun run "$DIR/cli.js" "$@" diff --git a/browse-mobile/dist/cli.js b/browse-mobile/dist/cli.js new file mode 100644 index 000000000..9111b26c8 --- /dev/null +++ b/browse-mobile/dist/cli.js @@ -0,0 +1,3103 @@ +// @bun +var __create = Object.create; +var __getProtoOf = Object.getPrototypeOf; +var __defProp = Object.defineProperty; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +function __accessProp(key) { + return this[key]; +} +var __toESMCache_node; +var __toESMCache_esm; +var __toESM = (mod, isNodeMode, target) => { + var canCache = mod != null && typeof mod === "object"; + if (canCache) { + var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap; + var cached = cache.get(mod); + if (cached) + return cached; + } + target = mod != null ? __create(__getProtoOf(mod)) : {}; + const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target; + for (let key of __getOwnPropNames(mod)) + if (!__hasOwnProp.call(to, key)) + __defProp(to, key, { + get: __accessProp.bind(mod, key), + enumerable: true + }); + if (canCache) + cache.set(mod, to); + return to; +}; +var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); + +// node_modules/fast-xml-parser/src/util.js +var require_util = __commonJS((exports) => { + var nameStartChar = ":A-Za-z_\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD"; + var nameChar = nameStartChar + "\\-.\\d\\u00B7\\u0300-\\u036F\\u203F-\\u2040"; + var nameRegexp = "[" + nameStartChar + "][" + nameChar + "]*"; + var regexName = new RegExp("^" + nameRegexp + "$"); + var getAllMatches = function(string, regex) { + const matches = []; + let match = regex.exec(string); + while (match) { + const allmatches = []; + allmatches.startIndex = regex.lastIndex - match[0].length; + const len = match.length; + for (let index = 0;index < len; index++) { + allmatches.push(match[index]); + } + matches.push(allmatches); + match = regex.exec(string); + } + return matches; + }; + var isName = function(string) { + const match = regexName.exec(string); + return !(match === null || typeof match === "undefined"); + }; + exports.isExist = function(v) { + return typeof v !== "undefined"; + }; + exports.isEmptyObject = function(obj) { + return Object.keys(obj).length === 0; + }; + exports.merge = function(target, a, arrayMode) { + if (a) { + const keys = Object.keys(a); + const len = keys.length; + for (let i = 0;i < len; i++) { + if (arrayMode === "strict") { + target[keys[i]] = [a[keys[i]]]; + } else { + target[keys[i]] = a[keys[i]]; + } + } + } + }; + exports.getValue = function(v) { + if (exports.isExist(v)) { + return v; + } else { + return ""; + } + }; + var DANGEROUS_PROPERTY_NAMES = [ + "hasOwnProperty", + "toString", + "valueOf", + "__defineGetter__", + "__defineSetter__", + "__lookupGetter__", + "__lookupSetter__" + ]; + var criticalProperties = ["__proto__", "constructor", "prototype"]; + exports.isName = isName; + exports.getAllMatches = getAllMatches; + exports.nameRegexp = nameRegexp; + exports.DANGEROUS_PROPERTY_NAMES = DANGEROUS_PROPERTY_NAMES; + exports.criticalProperties = criticalProperties; +}); + +// node_modules/fast-xml-parser/src/validator.js +var require_validator = __commonJS((exports) => { + var util = require_util(); + var defaultOptions = { + allowBooleanAttributes: false, + unpairedTags: [] + }; + exports.validate = function(xmlData, options) { + options = Object.assign({}, defaultOptions, options); + const tags = []; + let tagFound = false; + let reachedRoot = false; + if (xmlData[0] === "\uFEFF") { + xmlData = xmlData.substr(1); + } + for (let i = 0;i < xmlData.length; i++) { + if (xmlData[i] === "<" && xmlData[i + 1] === "?") { + i += 2; + i = readPI(xmlData, i); + if (i.err) + return i; + } else if (xmlData[i] === "<") { + let tagStartPos = i; + i++; + if (xmlData[i] === "!") { + i = readCommentAndCDATA(xmlData, i); + continue; + } else { + let closingTag = false; + if (xmlData[i] === "/") { + closingTag = true; + i++; + } + let tagName = ""; + for (;i < xmlData.length && xmlData[i] !== ">" && xmlData[i] !== " " && xmlData[i] !== "\t" && xmlData[i] !== ` +` && xmlData[i] !== "\r"; i++) { + tagName += xmlData[i]; + } + tagName = tagName.trim(); + if (tagName[tagName.length - 1] === "/") { + tagName = tagName.substring(0, tagName.length - 1); + i--; + } + if (!validateTagName(tagName)) { + let msg; + if (tagName.trim().length === 0) { + msg = "Invalid space after '<'."; + } else { + msg = "Tag '" + tagName + "' is an invalid name."; + } + return getErrorObject("InvalidTag", msg, getLineNumberForPosition(xmlData, i)); + } + const result = readAttributeStr(xmlData, i); + if (result === false) { + return getErrorObject("InvalidAttr", "Attributes for '" + tagName + "' have open quote.", getLineNumberForPosition(xmlData, i)); + } + let attrStr = result.value; + i = result.index; + if (attrStr[attrStr.length - 1] === "/") { + const attrStrStart = i - attrStr.length; + attrStr = attrStr.substring(0, attrStr.length - 1); + const isValid = validateAttributeString(attrStr, options); + if (isValid === true) { + tagFound = true; + } else { + return getErrorObject(isValid.err.code, isValid.err.msg, getLineNumberForPosition(xmlData, attrStrStart + isValid.err.line)); + } + } else if (closingTag) { + if (!result.tagClosed) { + return getErrorObject("InvalidTag", "Closing tag '" + tagName + "' doesn't have proper closing.", getLineNumberForPosition(xmlData, i)); + } else if (attrStr.trim().length > 0) { + return getErrorObject("InvalidTag", "Closing tag '" + tagName + "' can't have attributes or invalid starting.", getLineNumberForPosition(xmlData, tagStartPos)); + } else if (tags.length === 0) { + return getErrorObject("InvalidTag", "Closing tag '" + tagName + "' has not been opened.", getLineNumberForPosition(xmlData, tagStartPos)); + } else { + const otg = tags.pop(); + if (tagName !== otg.tagName) { + let openPos = getLineNumberForPosition(xmlData, otg.tagStartPos); + return getErrorObject("InvalidTag", "Expected closing tag '" + otg.tagName + "' (opened in line " + openPos.line + ", col " + openPos.col + ") instead of closing tag '" + tagName + "'.", getLineNumberForPosition(xmlData, tagStartPos)); + } + if (tags.length == 0) { + reachedRoot = true; + } + } + } else { + const isValid = validateAttributeString(attrStr, options); + if (isValid !== true) { + return getErrorObject(isValid.err.code, isValid.err.msg, getLineNumberForPosition(xmlData, i - attrStr.length + isValid.err.line)); + } + if (reachedRoot === true) { + return getErrorObject("InvalidXml", "Multiple possible root nodes found.", getLineNumberForPosition(xmlData, i)); + } else if (options.unpairedTags.indexOf(tagName) !== -1) {} else { + tags.push({ tagName, tagStartPos }); + } + tagFound = true; + } + for (i++;i < xmlData.length; i++) { + if (xmlData[i] === "<") { + if (xmlData[i + 1] === "!") { + i++; + i = readCommentAndCDATA(xmlData, i); + continue; + } else if (xmlData[i + 1] === "?") { + i = readPI(xmlData, ++i); + if (i.err) + return i; + } else { + break; + } + } else if (xmlData[i] === "&") { + const afterAmp = validateAmpersand(xmlData, i); + if (afterAmp == -1) + return getErrorObject("InvalidChar", "char '&' is not expected.", getLineNumberForPosition(xmlData, i)); + i = afterAmp; + } else { + if (reachedRoot === true && !isWhiteSpace(xmlData[i])) { + return getErrorObject("InvalidXml", "Extra text at the end", getLineNumberForPosition(xmlData, i)); + } + } + } + if (xmlData[i] === "<") { + i--; + } + } + } else { + if (isWhiteSpace(xmlData[i])) { + continue; + } + return getErrorObject("InvalidChar", "char '" + xmlData[i] + "' is not expected.", getLineNumberForPosition(xmlData, i)); + } + } + if (!tagFound) { + return getErrorObject("InvalidXml", "Start tag expected.", 1); + } else if (tags.length == 1) { + return getErrorObject("InvalidTag", "Unclosed tag '" + tags[0].tagName + "'.", getLineNumberForPosition(xmlData, tags[0].tagStartPos)); + } else if (tags.length > 0) { + return getErrorObject("InvalidXml", "Invalid '" + JSON.stringify(tags.map((t) => t.tagName), null, 4).replace(/\r?\n/g, "") + "' found.", { line: 1, col: 1 }); + } + return true; + }; + function isWhiteSpace(char) { + return char === " " || char === "\t" || char === ` +` || char === "\r"; + } + function readPI(xmlData, i) { + const start = i; + for (;i < xmlData.length; i++) { + if (xmlData[i] == "?" || xmlData[i] == " ") { + const tagname = xmlData.substr(start, i - start); + if (i > 5 && tagname === "xml") { + return getErrorObject("InvalidXml", "XML declaration allowed only at the start of the document.", getLineNumberForPosition(xmlData, i)); + } else if (xmlData[i] == "?" && xmlData[i + 1] == ">") { + i++; + break; + } else { + continue; + } + } + } + return i; + } + function readCommentAndCDATA(xmlData, i) { + if (xmlData.length > i + 5 && xmlData[i + 1] === "-" && xmlData[i + 2] === "-") { + for (i += 3;i < xmlData.length; i++) { + if (xmlData[i] === "-" && xmlData[i + 1] === "-" && xmlData[i + 2] === ">") { + i += 2; + break; + } + } + } else if (xmlData.length > i + 8 && xmlData[i + 1] === "D" && xmlData[i + 2] === "O" && xmlData[i + 3] === "C" && xmlData[i + 4] === "T" && xmlData[i + 5] === "Y" && xmlData[i + 6] === "P" && xmlData[i + 7] === "E") { + let angleBracketsCount = 1; + for (i += 8;i < xmlData.length; i++) { + if (xmlData[i] === "<") { + angleBracketsCount++; + } else if (xmlData[i] === ">") { + angleBracketsCount--; + if (angleBracketsCount === 0) { + break; + } + } + } + } else if (xmlData.length > i + 9 && xmlData[i + 1] === "[" && xmlData[i + 2] === "C" && xmlData[i + 3] === "D" && xmlData[i + 4] === "A" && xmlData[i + 5] === "T" && xmlData[i + 6] === "A" && xmlData[i + 7] === "[") { + for (i += 8;i < xmlData.length; i++) { + if (xmlData[i] === "]" && xmlData[i + 1] === "]" && xmlData[i + 2] === ">") { + i += 2; + break; + } + } + } + return i; + } + var doubleQuote = '"'; + var singleQuote = "'"; + function readAttributeStr(xmlData, i) { + let attrStr = ""; + let startChar = ""; + let tagClosed = false; + for (;i < xmlData.length; i++) { + if (xmlData[i] === doubleQuote || xmlData[i] === singleQuote) { + if (startChar === "") { + startChar = xmlData[i]; + } else if (startChar !== xmlData[i]) {} else { + startChar = ""; + } + } else if (xmlData[i] === ">") { + if (startChar === "") { + tagClosed = true; + break; + } + } + attrStr += xmlData[i]; + } + if (startChar !== "") { + return false; + } + return { + value: attrStr, + index: i, + tagClosed + }; + } + var validAttrStrRegxp = new RegExp(`(\\s*)([^\\s=]+)(\\s*=)?(\\s*(['"])(([\\s\\S])*?)\\5)?`, "g"); + function validateAttributeString(attrStr, options) { + const matches = util.getAllMatches(attrStr, validAttrStrRegxp); + const attrNames = {}; + for (let i = 0;i < matches.length; i++) { + if (matches[i][1].length === 0) { + return getErrorObject("InvalidAttr", "Attribute '" + matches[i][2] + "' has no space in starting.", getPositionFromMatch(matches[i])); + } else if (matches[i][3] !== undefined && matches[i][4] === undefined) { + return getErrorObject("InvalidAttr", "Attribute '" + matches[i][2] + "' is without value.", getPositionFromMatch(matches[i])); + } else if (matches[i][3] === undefined && !options.allowBooleanAttributes) { + return getErrorObject("InvalidAttr", "boolean attribute '" + matches[i][2] + "' is not allowed.", getPositionFromMatch(matches[i])); + } + const attrName = matches[i][2]; + if (!validateAttrName(attrName)) { + return getErrorObject("InvalidAttr", "Attribute '" + attrName + "' is an invalid name.", getPositionFromMatch(matches[i])); + } + if (!attrNames.hasOwnProperty(attrName)) { + attrNames[attrName] = 1; + } else { + return getErrorObject("InvalidAttr", "Attribute '" + attrName + "' is repeated.", getPositionFromMatch(matches[i])); + } + } + return true; + } + function validateNumberAmpersand(xmlData, i) { + let re = /\d/; + if (xmlData[i] === "x") { + i++; + re = /[\da-fA-F]/; + } + for (;i < xmlData.length; i++) { + if (xmlData[i] === ";") + return i; + if (!xmlData[i].match(re)) + break; + } + return -1; + } + function validateAmpersand(xmlData, i) { + i++; + if (xmlData[i] === ";") + return -1; + if (xmlData[i] === "#") { + i++; + return validateNumberAmpersand(xmlData, i); + } + let count = 0; + for (;i < xmlData.length; i++, count++) { + if (xmlData[i].match(/\w/) && count < 20) + continue; + if (xmlData[i] === ";") + break; + return -1; + } + return i; + } + function getErrorObject(code, message, lineNumber) { + return { + err: { + code, + msg: message, + line: lineNumber.line || lineNumber, + col: lineNumber.col + } + }; + } + function validateAttrName(attrName) { + return util.isName(attrName); + } + function validateTagName(tagname) { + return util.isName(tagname); + } + function getLineNumberForPosition(xmlData, index) { + const lines = xmlData.substring(0, index).split(/\r?\n/); + return { + line: lines.length, + col: lines[lines.length - 1].length + 1 + }; + } + function getPositionFromMatch(match) { + return match.startIndex + match[1].length; + } +}); + +// node_modules/fast-xml-parser/src/xmlparser/OptionsBuilder.js +var require_OptionsBuilder = __commonJS((exports) => { + var { DANGEROUS_PROPERTY_NAMES, criticalProperties } = require_util(); + var defaultOnDangerousProperty = (name) => { + if (DANGEROUS_PROPERTY_NAMES.includes(name)) { + return "__" + name; + } + return name; + }; + var defaultOptions = { + preserveOrder: false, + attributeNamePrefix: "@_", + attributesGroupName: false, + textNodeName: "#text", + ignoreAttributes: true, + removeNSPrefix: false, + allowBooleanAttributes: false, + parseTagValue: true, + parseAttributeValue: false, + trimValues: true, + cdataPropName: false, + numberParseOptions: { + hex: true, + leadingZeros: true, + eNotation: true + }, + tagValueProcessor: function(tagName, val) { + return val; + }, + attributeValueProcessor: function(attrName, val) { + return val; + }, + stopNodes: [], + alwaysCreateTextNode: false, + isArray: () => false, + commentPropName: false, + unpairedTags: [], + processEntities: true, + htmlEntities: false, + ignoreDeclaration: false, + ignorePiTags: false, + transformTagName: false, + transformAttributeName: false, + updateTag: function(tagName, jPath, attrs) { + return tagName; + }, + captureMetaData: false, + maxNestedTags: 100, + strictReservedNames: true, + onDangerousProperty: defaultOnDangerousProperty + }; + function validatePropertyName(propertyName, optionName) { + if (typeof propertyName !== "string") { + return; + } + const normalized = propertyName.toLowerCase(); + if (DANGEROUS_PROPERTY_NAMES.some((dangerous) => normalized === dangerous.toLowerCase())) { + throw new Error(`[SECURITY] Invalid ${optionName}: "${propertyName}" is a reserved JavaScript keyword that could cause prototype pollution`); + } + if (criticalProperties.some((dangerous) => normalized === dangerous.toLowerCase())) { + throw new Error(`[SECURITY] Invalid ${optionName}: "${propertyName}" is a reserved JavaScript keyword that could cause prototype pollution`); + } + } + function normalizeProcessEntities(value) { + if (typeof value === "boolean") { + return { + enabled: value, + maxEntitySize: 1e4, + maxExpansionDepth: 10, + maxTotalExpansions: 1000, + maxExpandedLength: 1e5, + allowedTags: null, + tagFilter: null + }; + } + if (typeof value === "object" && value !== null) { + return { + enabled: value.enabled !== false, + maxEntitySize: Math.max(1, value.maxEntitySize ?? 1e4), + maxExpansionDepth: Math.max(1, value.maxExpansionDepth ?? 10), + maxTotalExpansions: Math.max(1, value.maxTotalExpansions ?? 1000), + maxExpandedLength: Math.max(1, value.maxExpandedLength ?? 1e5), + maxEntityCount: Math.max(1, value.maxEntityCount ?? 100), + allowedTags: value.allowedTags ?? null, + tagFilter: value.tagFilter ?? null + }; + } + return normalizeProcessEntities(true); + } + var buildOptions = function(options) { + const built = Object.assign({}, defaultOptions, options); + const propertyNameOptions = [ + { value: built.attributeNamePrefix, name: "attributeNamePrefix" }, + { value: built.attributesGroupName, name: "attributesGroupName" }, + { value: built.textNodeName, name: "textNodeName" }, + { value: built.cdataPropName, name: "cdataPropName" }, + { value: built.commentPropName, name: "commentPropName" } + ]; + for (const { value, name } of propertyNameOptions) { + if (value) { + validatePropertyName(value, name); + } + } + if (built.onDangerousProperty === null) { + built.onDangerousProperty = defaultOnDangerousProperty; + } + built.processEntities = normalizeProcessEntities(built.processEntities); + return built; + }; + exports.buildOptions = buildOptions; + exports.defaultOptions = defaultOptions; +}); + +// node_modules/fast-xml-parser/src/xmlparser/xmlNode.js +var require_xmlNode = __commonJS((exports, module) => { + class XmlNode { + constructor(tagname) { + this.tagname = tagname; + this.child = []; + this[":@"] = {}; + } + add(key, val) { + if (key === "__proto__") + key = "#__proto__"; + this.child.push({ [key]: val }); + } + addChild(node) { + if (node.tagname === "__proto__") + node.tagname = "#__proto__"; + if (node[":@"] && Object.keys(node[":@"]).length > 0) { + this.child.push({ [node.tagname]: node.child, [":@"]: node[":@"] }); + } else { + this.child.push({ [node.tagname]: node.child }); + } + } + } + module.exports = XmlNode; +}); + +// node_modules/fast-xml-parser/src/xmlparser/DocTypeReader.js +var require_DocTypeReader = __commonJS((exports, module) => { + var util = require_util(); + + class DocTypeReader { + constructor(options) { + this.suppressValidationErr = !options; + this.options = options || {}; + } + readDocType(xmlData, i) { + const entities = Object.create(null); + let entityCount = 0; + if (xmlData[i + 3] === "O" && xmlData[i + 4] === "C" && xmlData[i + 5] === "T" && xmlData[i + 6] === "Y" && xmlData[i + 7] === "P" && xmlData[i + 8] === "E") { + i = i + 9; + let angleBracketsCount = 1; + let hasBody = false, comment = false; + let exp = ""; + for (;i < xmlData.length; i++) { + if (xmlData[i] === "<" && !comment) { + if (hasBody && hasSeq(xmlData, "!ENTITY", i)) { + i += 7; + let entityName, val; + [entityName, val, i] = this.readEntityExp(xmlData, i + 1, this.suppressValidationErr); + if (val.indexOf("&") === -1) { + if (this.options.enabled !== false && this.options.maxEntityCount != null && entityCount >= this.options.maxEntityCount) { + throw new Error(`Entity count (${entityCount + 1}) exceeds maximum allowed (${this.options.maxEntityCount})`); + } + const escaped = entityName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + entities[entityName] = { + regx: RegExp(`&${escaped};`, "g"), + val + }; + entityCount++; + } + } else if (hasBody && hasSeq(xmlData, "!ELEMENT", i)) { + i += 8; + const { index } = this.readElementExp(xmlData, i + 1); + i = index; + } else if (hasBody && hasSeq(xmlData, "!ATTLIST", i)) { + i += 8; + } else if (hasBody && hasSeq(xmlData, "!NOTATION", i)) { + i += 9; + const { index } = this.readNotationExp(xmlData, i + 1, this.suppressValidationErr); + i = index; + } else if (hasSeq(xmlData, "!--", i)) { + comment = true; + } else { + throw new Error(`Invalid DOCTYPE`); + } + angleBracketsCount++; + exp = ""; + } else if (xmlData[i] === ">") { + if (comment) { + if (xmlData[i - 1] === "-" && xmlData[i - 2] === "-") { + comment = false; + angleBracketsCount--; + } + } else { + angleBracketsCount--; + } + if (angleBracketsCount === 0) { + break; + } + } else if (xmlData[i] === "[") { + hasBody = true; + } else { + exp += xmlData[i]; + } + } + if (angleBracketsCount !== 0) { + throw new Error(`Unclosed DOCTYPE`); + } + } else { + throw new Error(`Invalid Tag instead of DOCTYPE`); + } + return { entities, i }; + } + readEntityExp(xmlData, i) { + i = skipWhitespace(xmlData, i); + let entityName = ""; + while (i < xmlData.length && !/\s/.test(xmlData[i]) && xmlData[i] !== '"' && xmlData[i] !== "'") { + entityName += xmlData[i]; + i++; + } + validateEntityName(entityName); + i = skipWhitespace(xmlData, i); + if (!this.suppressValidationErr) { + if (xmlData.substring(i, i + 6).toUpperCase() === "SYSTEM") { + throw new Error("External entities are not supported"); + } else if (xmlData[i] === "%") { + throw new Error("Parameter entities are not supported"); + } + } + let entityValue = ""; + [i, entityValue] = this.readIdentifierVal(xmlData, i, "entity"); + if (this.options.enabled !== false && this.options.maxEntitySize != null && entityValue.length > this.options.maxEntitySize) { + throw new Error(`Entity "${entityName}" size (${entityValue.length}) exceeds maximum allowed size (${this.options.maxEntitySize})`); + } + i--; + return [entityName, entityValue, i]; + } + readNotationExp(xmlData, i) { + i = skipWhitespace(xmlData, i); + let notationName = ""; + while (i < xmlData.length && !/\s/.test(xmlData[i])) { + notationName += xmlData[i]; + i++; + } + !this.suppressValidationErr && validateEntityName(notationName); + i = skipWhitespace(xmlData, i); + const identifierType = xmlData.substring(i, i + 6).toUpperCase(); + if (!this.suppressValidationErr && identifierType !== "SYSTEM" && identifierType !== "PUBLIC") { + throw new Error(`Expected SYSTEM or PUBLIC, found "${identifierType}"`); + } + i += identifierType.length; + i = skipWhitespace(xmlData, i); + let publicIdentifier = null; + let systemIdentifier = null; + if (identifierType === "PUBLIC") { + [i, publicIdentifier] = this.readIdentifierVal(xmlData, i, "publicIdentifier"); + i = skipWhitespace(xmlData, i); + if (xmlData[i] === '"' || xmlData[i] === "'") { + [i, systemIdentifier] = this.readIdentifierVal(xmlData, i, "systemIdentifier"); + } + } else if (identifierType === "SYSTEM") { + [i, systemIdentifier] = this.readIdentifierVal(xmlData, i, "systemIdentifier"); + if (!this.suppressValidationErr && !systemIdentifier) { + throw new Error("Missing mandatory system identifier for SYSTEM notation"); + } + } + return { notationName, publicIdentifier, systemIdentifier, index: --i }; + } + readIdentifierVal(xmlData, i, type) { + let identifierVal = ""; + const startChar = xmlData[i]; + if (startChar !== '"' && startChar !== "'") { + throw new Error(`Expected quoted string, found "${startChar}"`); + } + i++; + while (i < xmlData.length && xmlData[i] !== startChar) { + identifierVal += xmlData[i]; + i++; + } + if (xmlData[i] !== startChar) { + throw new Error(`Unterminated ${type} value`); + } + i++; + return [i, identifierVal]; + } + readElementExp(xmlData, i) { + i = skipWhitespace(xmlData, i); + let elementName = ""; + while (i < xmlData.length && !/\s/.test(xmlData[i])) { + elementName += xmlData[i]; + i++; + } + if (!this.suppressValidationErr && !util.isName(elementName)) { + throw new Error(`Invalid element name: "${elementName}"`); + } + i = skipWhitespace(xmlData, i); + let contentModel = ""; + if (xmlData[i] === "E" && hasSeq(xmlData, "MPTY", i)) { + i += 4; + } else if (xmlData[i] === "A" && hasSeq(xmlData, "NY", i)) { + i += 2; + } else if (xmlData[i] === "(") { + i++; + while (i < xmlData.length && xmlData[i] !== ")") { + contentModel += xmlData[i]; + i++; + } + if (xmlData[i] !== ")") { + throw new Error("Unterminated content model"); + } + } else if (!this.suppressValidationErr) { + throw new Error(`Invalid Element Expression, found "${xmlData[i]}"`); + } + return { + elementName, + contentModel: contentModel.trim(), + index: i + }; + } + readAttlistExp(xmlData, i) { + i = skipWhitespace(xmlData, i); + let elementName = ""; + while (i < xmlData.length && !/\s/.test(xmlData[i])) { + elementName += xmlData[i]; + i++; + } + validateEntityName(elementName); + i = skipWhitespace(xmlData, i); + let attributeName = ""; + while (i < xmlData.length && !/\s/.test(xmlData[i])) { + attributeName += xmlData[i]; + i++; + } + if (!validateEntityName(attributeName)) { + throw new Error(`Invalid attribute name: "${attributeName}"`); + } + i = skipWhitespace(xmlData, i); + let attributeType = ""; + if (xmlData.substring(i, i + 8).toUpperCase() === "NOTATION") { + attributeType = "NOTATION"; + i += 8; + i = skipWhitespace(xmlData, i); + if (xmlData[i] !== "(") { + throw new Error(`Expected '(', found "${xmlData[i]}"`); + } + i++; + let allowedNotations = []; + while (i < xmlData.length && xmlData[i] !== ")") { + let notation = ""; + while (i < xmlData.length && xmlData[i] !== "|" && xmlData[i] !== ")") { + notation += xmlData[i]; + i++; + } + notation = notation.trim(); + if (!validateEntityName(notation)) { + throw new Error(`Invalid notation name: "${notation}"`); + } + allowedNotations.push(notation); + if (xmlData[i] === "|") { + i++; + i = skipWhitespace(xmlData, i); + } + } + if (xmlData[i] !== ")") { + throw new Error("Unterminated list of notations"); + } + i++; + attributeType += " (" + allowedNotations.join("|") + ")"; + } else { + while (i < xmlData.length && !/\s/.test(xmlData[i])) { + attributeType += xmlData[i]; + i++; + } + const validTypes = ["CDATA", "ID", "IDREF", "IDREFS", "ENTITY", "ENTITIES", "NMTOKEN", "NMTOKENS"]; + if (!this.suppressValidationErr && !validTypes.includes(attributeType.toUpperCase())) { + throw new Error(`Invalid attribute type: "${attributeType}"`); + } + } + i = skipWhitespace(xmlData, i); + let defaultValue = ""; + if (xmlData.substring(i, i + 8).toUpperCase() === "#REQUIRED") { + defaultValue = "#REQUIRED"; + i += 8; + } else if (xmlData.substring(i, i + 7).toUpperCase() === "#IMPLIED") { + defaultValue = "#IMPLIED"; + i += 7; + } else { + [i, defaultValue] = this.readIdentifierVal(xmlData, i, "ATTLIST"); + } + return { + elementName, + attributeName, + attributeType, + defaultValue, + index: i + }; + } + } + var skipWhitespace = (data, index) => { + while (index < data.length && /\s/.test(data[index])) { + index++; + } + return index; + }; + function hasSeq(data, seq, i) { + for (let j = 0;j < seq.length; j++) { + if (seq[j] !== data[i + j + 1]) + return false; + } + return true; + } + function validateEntityName(name) { + if (util.isName(name)) + return name; + else + throw new Error(`Invalid entity name ${name}`); + } + module.exports = DocTypeReader; +}); + +// node_modules/strnum/strnum.js +var require_strnum = __commonJS((exports, module) => { + var hexRegex = /^[-+]?0x[a-fA-F0-9]+$/; + var numRegex = /^([\-\+])?(0*)([0-9]*(\.[0-9]*)?)$/; + var consider = { + hex: true, + leadingZeros: true, + decimalPoint: ".", + eNotation: true + }; + function toNumber(str, options = {}) { + options = Object.assign({}, consider, options); + if (!str || typeof str !== "string") + return str; + let trimmedStr = str.trim(); + if (options.skipLike !== undefined && options.skipLike.test(trimmedStr)) + return str; + else if (str === "0") + return 0; + else if (options.hex && hexRegex.test(trimmedStr)) { + return parse_int(trimmedStr, 16); + } else if (trimmedStr.search(/[eE]/) !== -1) { + const notation = trimmedStr.match(/^([-\+])?(0*)([0-9]*(\.[0-9]*)?[eE][-\+]?[0-9]+)$/); + if (notation) { + if (options.leadingZeros) { + trimmedStr = (notation[1] || "") + notation[3]; + } else { + if (notation[2] === "0" && notation[3][0] === ".") {} else { + return str; + } + } + return options.eNotation ? Number(trimmedStr) : str; + } else { + return str; + } + } else { + const match = numRegex.exec(trimmedStr); + if (match) { + const sign = match[1]; + const leadingZeros = match[2]; + let numTrimmedByZeros = trimZeros(match[3]); + if (!options.leadingZeros && leadingZeros.length > 0 && sign && trimmedStr[2] !== ".") + return str; + else if (!options.leadingZeros && leadingZeros.length > 0 && !sign && trimmedStr[1] !== ".") + return str; + else if (options.leadingZeros && leadingZeros === str) + return 0; + else { + const num = Number(trimmedStr); + const numStr = "" + num; + if (numStr.search(/[eE]/) !== -1) { + if (options.eNotation) + return num; + else + return str; + } else if (trimmedStr.indexOf(".") !== -1) { + if (numStr === "0" && numTrimmedByZeros === "") + return num; + else if (numStr === numTrimmedByZeros) + return num; + else if (sign && numStr === "-" + numTrimmedByZeros) + return num; + else + return str; + } + if (leadingZeros) { + return numTrimmedByZeros === numStr || sign + numTrimmedByZeros === numStr ? num : str; + } else { + return trimmedStr === numStr || trimmedStr === sign + numStr ? num : str; + } + } + } else { + return str; + } + } + } + function trimZeros(numStr) { + if (numStr && numStr.indexOf(".") !== -1) { + numStr = numStr.replace(/0+$/, ""); + if (numStr === ".") + numStr = "0"; + else if (numStr[0] === ".") + numStr = "0" + numStr; + else if (numStr[numStr.length - 1] === ".") + numStr = numStr.substr(0, numStr.length - 1); + return numStr; + } + return numStr; + } + function parse_int(numStr, base) { + if (parseInt) + return parseInt(numStr, base); + else if (Number.parseInt) + return Number.parseInt(numStr, base); + else if (window && window.parseInt) + return window.parseInt(numStr, base); + else + throw new Error("parseInt, Number.parseInt, window.parseInt are not supported"); + } + module.exports = toNumber; +}); + +// node_modules/fast-xml-parser/src/ignoreAttributes.js +var require_ignoreAttributes = __commonJS((exports, module) => { + function getIgnoreAttributesFn(ignoreAttributes) { + if (typeof ignoreAttributes === "function") { + return ignoreAttributes; + } + if (Array.isArray(ignoreAttributes)) { + return (attrName) => { + for (const pattern of ignoreAttributes) { + if (typeof pattern === "string" && attrName === pattern) { + return true; + } + if (pattern instanceof RegExp && pattern.test(attrName)) { + return true; + } + } + }; + } + return () => false; + } + module.exports = getIgnoreAttributesFn; +}); + +// node_modules/fast-xml-parser/src/xmlparser/OrderedObjParser.js +var require_OrderedObjParser = __commonJS((exports, module) => { + var util = require_util(); + var xmlNode = require_xmlNode(); + var DocTypeReader = require_DocTypeReader(); + var toNumber = require_strnum(); + var getIgnoreAttributesFn = require_ignoreAttributes(); + + class OrderedObjParser { + constructor(options) { + this.options = options; + this.currentNode = null; + this.tagsNodeStack = []; + this.docTypeEntities = {}; + this.lastEntities = { + apos: { regex: /&(apos|#39|#x27);/g, val: "'" }, + gt: { regex: /&(gt|#62|#x3E);/g, val: ">" }, + lt: { regex: /&(lt|#60|#x3C);/g, val: "<" }, + quot: { regex: /&(quot|#34|#x22);/g, val: '"' } + }; + this.ampEntity = { regex: /&(amp|#38|#x26);/g, val: "&" }; + this.htmlEntities = { + space: { regex: /&(nbsp|#160);/g, val: " " }, + cent: { regex: /&(cent|#162);/g, val: "\xA2" }, + pound: { regex: /&(pound|#163);/g, val: "\xA3" }, + yen: { regex: /&(yen|#165);/g, val: "\xA5" }, + euro: { regex: /&(euro|#8364);/g, val: "\u20AC" }, + copyright: { regex: /&(copy|#169);/g, val: "\xA9" }, + reg: { regex: /&(reg|#174);/g, val: "\xAE" }, + inr: { regex: /&(inr|#8377);/g, val: "\u20B9" }, + num_dec: { regex: /&#([0-9]{1,7});/g, val: (_, str) => fromCodePoint(str, 10, "&#") }, + num_hex: { regex: /&#x([0-9a-fA-F]{1,6});/g, val: (_, str) => fromCodePoint(str, 16, "&#x") } + }; + this.addExternalEntities = addExternalEntities; + this.parseXml = parseXml; + this.parseTextData = parseTextData; + this.resolveNameSpace = resolveNameSpace; + this.buildAttributesMap = buildAttributesMap; + this.isItStopNode = isItStopNode; + this.replaceEntitiesValue = replaceEntitiesValue; + this.readStopNodeData = readStopNodeData; + this.saveTextToParentTag = saveTextToParentTag; + this.addChild = addChild; + this.ignoreAttributesFn = getIgnoreAttributesFn(this.options.ignoreAttributes); + this.entityExpansionCount = 0; + this.currentExpandedLength = 0; + if (this.options.stopNodes && this.options.stopNodes.length > 0) { + this.stopNodesExact = new Set; + this.stopNodesWildcard = new Set; + for (let i = 0;i < this.options.stopNodes.length; i++) { + const stopNodeExp = this.options.stopNodes[i]; + if (typeof stopNodeExp !== "string") + continue; + if (stopNodeExp.startsWith("*.")) { + this.stopNodesWildcard.add(stopNodeExp.substring(2)); + } else { + this.stopNodesExact.add(stopNodeExp); + } + } + } + } + } + function addExternalEntities(externalEntities) { + const entKeys = Object.keys(externalEntities); + for (let i = 0;i < entKeys.length; i++) { + const ent = entKeys[i]; + const escaped = ent.replace(/[.\-+*:]/g, "\\."); + this.lastEntities[ent] = { + regex: new RegExp("&" + escaped + ";", "g"), + val: externalEntities[ent] + }; + } + } + function parseTextData(val, tagName, jPath, dontTrim, hasAttributes, isLeafNode, escapeEntities) { + if (val !== undefined) { + if (this.options.trimValues && !dontTrim) { + val = val.trim(); + } + if (val.length > 0) { + if (!escapeEntities) + val = this.replaceEntitiesValue(val, tagName, jPath); + const newval = this.options.tagValueProcessor(tagName, val, jPath, hasAttributes, isLeafNode); + if (newval === null || newval === undefined) { + return val; + } else if (typeof newval !== typeof val || newval !== val) { + return newval; + } else if (this.options.trimValues) { + return parseValue(val, this.options.parseTagValue, this.options.numberParseOptions); + } else { + const trimmedVal = val.trim(); + if (trimmedVal === val) { + return parseValue(val, this.options.parseTagValue, this.options.numberParseOptions); + } else { + return val; + } + } + } + } + } + function resolveNameSpace(tagname) { + if (this.options.removeNSPrefix) { + const tags = tagname.split(":"); + const prefix = tagname.charAt(0) === "/" ? "/" : ""; + if (tags[0] === "xmlns") { + return ""; + } + if (tags.length === 2) { + tagname = prefix + tags[1]; + } + } + return tagname; + } + var attrsRegx = new RegExp(`([^\\s=]+)\\s*(=\\s*(['"])([\\s\\S]*?)\\3)?`, "gm"); + function buildAttributesMap(attrStr, jPath, tagName) { + if (this.options.ignoreAttributes !== true && typeof attrStr === "string") { + const matches = util.getAllMatches(attrStr, attrsRegx); + const len = matches.length; + const attrs = {}; + for (let i = 0;i < len; i++) { + const attrName = this.resolveNameSpace(matches[i][1]); + if (this.ignoreAttributesFn(attrName, jPath)) { + continue; + } + let oldVal = matches[i][4]; + let aName = this.options.attributeNamePrefix + attrName; + if (attrName.length) { + if (this.options.transformAttributeName) { + aName = this.options.transformAttributeName(aName); + } + aName = sanitizeName(aName, this.options); + if (oldVal !== undefined) { + if (this.options.trimValues) { + oldVal = oldVal.trim(); + } + oldVal = this.replaceEntitiesValue(oldVal, tagName, jPath); + const newVal = this.options.attributeValueProcessor(attrName, oldVal, jPath); + if (newVal === null || newVal === undefined) { + attrs[aName] = oldVal; + } else if (typeof newVal !== typeof oldVal || newVal !== oldVal) { + attrs[aName] = newVal; + } else { + attrs[aName] = parseValue(oldVal, this.options.parseAttributeValue, this.options.numberParseOptions); + } + } else if (this.options.allowBooleanAttributes) { + attrs[aName] = true; + } + } + } + if (!Object.keys(attrs).length) { + return; + } + if (this.options.attributesGroupName) { + const attrCollection = {}; + attrCollection[this.options.attributesGroupName] = attrs; + return attrCollection; + } + return attrs; + } + } + var parseXml = function(xmlData) { + xmlData = xmlData.replace(/\r\n?/g, ` +`); + const xmlObj = new xmlNode("!xml"); + let currentNode = xmlObj; + let textData = ""; + let jPath = ""; + this.entityExpansionCount = 0; + this.currentExpandedLength = 0; + const docTypeReader = new DocTypeReader(this.options.processEntities); + for (let i = 0;i < xmlData.length; i++) { + const ch = xmlData[i]; + if (ch === "<") { + if (xmlData[i + 1] === "/") { + const closeIndex = findClosingIndex(xmlData, ">", i, "Closing Tag is not closed."); + let tagName = xmlData.substring(i + 2, closeIndex).trim(); + if (this.options.removeNSPrefix) { + const colonIndex = tagName.indexOf(":"); + if (colonIndex !== -1) { + tagName = tagName.substr(colonIndex + 1); + } + } + if (this.options.transformTagName) { + tagName = this.options.transformTagName(tagName); + } + if (currentNode) { + textData = this.saveTextToParentTag(textData, currentNode, jPath); + } + const lastTagName = jPath.substring(jPath.lastIndexOf(".") + 1); + if (tagName && this.options.unpairedTags.indexOf(tagName) !== -1) { + throw new Error(`Unpaired tag can not be used as closing tag: `); + } + let propIndex = 0; + if (lastTagName && this.options.unpairedTags.indexOf(lastTagName) !== -1) { + propIndex = jPath.lastIndexOf(".", jPath.lastIndexOf(".") - 1); + this.tagsNodeStack.pop(); + } else { + propIndex = jPath.lastIndexOf("."); + } + jPath = jPath.substring(0, propIndex); + currentNode = this.tagsNodeStack.pop(); + textData = ""; + i = closeIndex; + } else if (xmlData[i + 1] === "?") { + let tagData = readTagExp(xmlData, i, false, "?>"); + if (!tagData) + throw new Error("Pi Tag is not closed."); + textData = this.saveTextToParentTag(textData, currentNode, jPath); + if (this.options.ignoreDeclaration && tagData.tagName === "?xml" || this.options.ignorePiTags) {} else { + const childNode = new xmlNode(tagData.tagName); + childNode.add(this.options.textNodeName, ""); + if (tagData.tagName !== tagData.tagExp && tagData.attrExpPresent) { + childNode[":@"] = this.buildAttributesMap(tagData.tagExp, jPath, tagData.tagName); + } + this.addChild(currentNode, childNode, jPath, i); + } + i = tagData.closeIndex + 1; + } else if (xmlData.substr(i + 1, 3) === "!--") { + const endIndex = findClosingIndex(xmlData, "-->", i + 4, "Comment is not closed."); + if (this.options.commentPropName) { + const comment = xmlData.substring(i + 4, endIndex - 2); + textData = this.saveTextToParentTag(textData, currentNode, jPath); + currentNode.add(this.options.commentPropName, [{ [this.options.textNodeName]: comment }]); + } + i = endIndex; + } else if (xmlData.substr(i + 1, 2) === "!D") { + const result = docTypeReader.readDocType(xmlData, i); + this.docTypeEntities = result.entities; + i = result.i; + } else if (xmlData.substr(i + 1, 2) === "![") { + const closeIndex = findClosingIndex(xmlData, "]]>", i, "CDATA is not closed.") - 2; + const tagExp = xmlData.substring(i + 9, closeIndex); + textData = this.saveTextToParentTag(textData, currentNode, jPath); + let val = this.parseTextData(tagExp, currentNode.tagname, jPath, true, false, true, true); + if (val == undefined) + val = ""; + if (this.options.cdataPropName) { + currentNode.add(this.options.cdataPropName, [{ [this.options.textNodeName]: tagExp }]); + } else { + currentNode.add(this.options.textNodeName, val); + } + i = closeIndex + 2; + } else { + let result = readTagExp(xmlData, i, this.options.removeNSPrefix); + let tagName = result.tagName; + const rawTagName = result.rawTagName; + let tagExp = result.tagExp; + let attrExpPresent = result.attrExpPresent; + let closeIndex = result.closeIndex; + if (this.options.transformTagName) { + const newTagName = this.options.transformTagName(tagName); + if (tagExp === tagName) { + tagExp = newTagName; + } + tagName = newTagName; + } + if (this.options.strictReservedNames && (tagName === this.options.commentPropName || tagName === this.options.cdataPropName || tagName === this.options.textNodeName || tagName === this.options.attributesGroupName)) { + throw new Error(`Invalid tag name: ${tagName}`); + } + if (currentNode && textData) { + if (currentNode.tagname !== "!xml") { + textData = this.saveTextToParentTag(textData, currentNode, jPath, false); + } + } + const lastTag = currentNode; + if (lastTag && this.options.unpairedTags.indexOf(lastTag.tagname) !== -1) { + currentNode = this.tagsNodeStack.pop(); + jPath = jPath.substring(0, jPath.lastIndexOf(".")); + } + if (tagName !== xmlObj.tagname) { + jPath += jPath ? "." + tagName : tagName; + } + const startIndex = i; + if (this.isItStopNode(this.stopNodesExact, this.stopNodesWildcard, jPath, tagName)) { + let tagContent = ""; + if (tagExp.length > 0 && tagExp.lastIndexOf("/") === tagExp.length - 1) { + if (tagName[tagName.length - 1] === "/") { + tagName = tagName.substr(0, tagName.length - 1); + jPath = jPath.substr(0, jPath.length - 1); + tagExp = tagName; + } else { + tagExp = tagExp.substr(0, tagExp.length - 1); + } + i = result.closeIndex; + } else if (this.options.unpairedTags.indexOf(tagName) !== -1) { + i = result.closeIndex; + } else { + const result2 = this.readStopNodeData(xmlData, rawTagName, closeIndex + 1); + if (!result2) + throw new Error(`Unexpected end of ${rawTagName}`); + i = result2.i; + tagContent = result2.tagContent; + } + const childNode = new xmlNode(tagName); + if (tagName !== tagExp && attrExpPresent) { + childNode[":@"] = this.buildAttributesMap(tagExp, jPath, tagName); + } + if (tagContent) { + tagContent = this.parseTextData(tagContent, tagName, jPath, true, attrExpPresent, true, true); + } + jPath = jPath.substr(0, jPath.lastIndexOf(".")); + childNode.add(this.options.textNodeName, tagContent); + this.addChild(currentNode, childNode, jPath, startIndex); + } else { + if (tagExp.length > 0 && tagExp.lastIndexOf("/") === tagExp.length - 1) { + if (tagName[tagName.length - 1] === "/") { + tagName = tagName.substr(0, tagName.length - 1); + jPath = jPath.substr(0, jPath.length - 1); + tagExp = tagName; + } else { + tagExp = tagExp.substr(0, tagExp.length - 1); + } + if (this.options.transformTagName) { + const newTagName = this.options.transformTagName(tagName); + if (tagExp === tagName) { + tagExp = newTagName; + } + tagName = newTagName; + } + const childNode = new xmlNode(tagName); + if (tagName !== tagExp && attrExpPresent) { + childNode[":@"] = this.buildAttributesMap(tagExp, jPath, tagName); + } + this.addChild(currentNode, childNode, jPath, startIndex); + jPath = jPath.substr(0, jPath.lastIndexOf(".")); + } else if (this.options.unpairedTags.indexOf(tagName) !== -1) { + const childNode = new xmlNode(tagName); + if (tagName !== tagExp && attrExpPresent) { + childNode[":@"] = this.buildAttributesMap(tagExp, jPath); + } + this.addChild(currentNode, childNode, jPath, startIndex); + jPath = jPath.substr(0, jPath.lastIndexOf(".")); + i = result.closeIndex; + continue; + } else { + const childNode = new xmlNode(tagName); + if (this.tagsNodeStack.length > this.options.maxNestedTags) { + throw new Error("Maximum nested tags exceeded"); + } + this.tagsNodeStack.push(currentNode); + if (tagName !== tagExp && attrExpPresent) { + childNode[":@"] = this.buildAttributesMap(tagExp, jPath, tagName); + } + this.addChild(currentNode, childNode, jPath); + currentNode = childNode; + } + textData = ""; + i = closeIndex; + } + } + } else { + textData += xmlData[i]; + } + } + return xmlObj.child; + }; + function addChild(currentNode, childNode, jPath, startIndex) { + if (!this.options.captureMetaData) + startIndex = undefined; + const result = this.options.updateTag(childNode.tagname, jPath, childNode[":@"]); + if (result === false) {} else if (typeof result === "string") { + childNode.tagname = result; + currentNode.addChild(childNode, startIndex); + } else { + currentNode.addChild(childNode, startIndex); + } + } + var replaceEntitiesValue = function(val, tagName, jPath) { + if (val.indexOf("&") === -1) { + return val; + } + const entityConfig = this.options.processEntities; + if (!entityConfig.enabled) { + return val; + } + if (entityConfig.allowedTags) { + if (!entityConfig.allowedTags.includes(tagName)) { + return val; + } + } + if (entityConfig.tagFilter) { + if (!entityConfig.tagFilter(tagName, jPath)) { + return val; + } + } + for (let entityName in this.docTypeEntities) { + const entity = this.docTypeEntities[entityName]; + const matches = val.match(entity.regx); + if (matches) { + this.entityExpansionCount += matches.length; + if (entityConfig.maxTotalExpansions && this.entityExpansionCount > entityConfig.maxTotalExpansions) { + throw new Error(`Entity expansion limit exceeded: ${this.entityExpansionCount} > ${entityConfig.maxTotalExpansions}`); + } + const lengthBefore = val.length; + val = val.replace(entity.regx, entity.val); + if (entityConfig.maxExpandedLength) { + this.currentExpandedLength += val.length - lengthBefore; + if (this.currentExpandedLength > entityConfig.maxExpandedLength) { + throw new Error(`Total expanded content size exceeded: ${this.currentExpandedLength} > ${entityConfig.maxExpandedLength}`); + } + } + } + } + if (val.indexOf("&") === -1) + return val; + for (const entityName of Object.keys(this.lastEntities)) { + const entity = this.lastEntities[entityName]; + const matches = val.match(entity.regex); + if (matches) { + this.entityExpansionCount += matches.length; + if (entityConfig.maxTotalExpansions && this.entityExpansionCount > entityConfig.maxTotalExpansions) { + throw new Error(`Entity expansion limit exceeded: ${this.entityExpansionCount} > ${entityConfig.maxTotalExpansions}`); + } + } + val = val.replace(entity.regex, entity.val); + } + if (val.indexOf("&") === -1) + return val; + if (this.options.htmlEntities) { + for (const entityName of Object.keys(this.htmlEntities)) { + const entity = this.htmlEntities[entityName]; + const matches = val.match(entity.regex); + if (matches) { + this.entityExpansionCount += matches.length; + if (entityConfig.maxTotalExpansions && this.entityExpansionCount > entityConfig.maxTotalExpansions) { + throw new Error(`Entity expansion limit exceeded: ${this.entityExpansionCount} > ${entityConfig.maxTotalExpansions}`); + } + } + val = val.replace(entity.regex, entity.val); + } + } + val = val.replace(this.ampEntity.regex, this.ampEntity.val); + return val; + }; + function saveTextToParentTag(textData, parentNode, jPath, isLeafNode) { + if (textData) { + if (isLeafNode === undefined) + isLeafNode = parentNode.child.length === 0; + textData = this.parseTextData(textData, parentNode.tagname, jPath, false, parentNode[":@"] ? Object.keys(parentNode[":@"]).length !== 0 : false, isLeafNode); + if (textData !== undefined && textData !== "") + parentNode.add(this.options.textNodeName, textData); + textData = ""; + } + return textData; + } + function isItStopNode(stopNodesExact, stopNodesWildcard, jPath, currentTagName) { + if (stopNodesWildcard && stopNodesWildcard.has(currentTagName)) + return true; + if (stopNodesExact && stopNodesExact.has(jPath)) + return true; + return false; + } + function tagExpWithClosingIndex(xmlData, i, closingChar = ">") { + let attrBoundary; + let tagExp = ""; + for (let index = i;index < xmlData.length; index++) { + let ch = xmlData[index]; + if (attrBoundary) { + if (ch === attrBoundary) + attrBoundary = ""; + } else if (ch === '"' || ch === "'") { + attrBoundary = ch; + } else if (ch === closingChar[0]) { + if (closingChar[1]) { + if (xmlData[index + 1] === closingChar[1]) { + return { + data: tagExp, + index + }; + } + } else { + return { + data: tagExp, + index + }; + } + } else if (ch === "\t") { + ch = " "; + } + tagExp += ch; + } + } + function findClosingIndex(xmlData, str, i, errMsg) { + const closingIndex = xmlData.indexOf(str, i); + if (closingIndex === -1) { + throw new Error(errMsg); + } else { + return closingIndex + str.length - 1; + } + } + function readTagExp(xmlData, i, removeNSPrefix, closingChar = ">") { + const result = tagExpWithClosingIndex(xmlData, i + 1, closingChar); + if (!result) + return; + let tagExp = result.data; + const closeIndex = result.index; + const separatorIndex = tagExp.search(/\s/); + let tagName = tagExp; + let attrExpPresent = true; + if (separatorIndex !== -1) { + tagName = tagExp.substring(0, separatorIndex); + tagExp = tagExp.substring(separatorIndex + 1).trimStart(); + } + const rawTagName = tagName; + if (removeNSPrefix) { + const colonIndex = tagName.indexOf(":"); + if (colonIndex !== -1) { + tagName = tagName.substr(colonIndex + 1); + attrExpPresent = tagName !== result.data.substr(colonIndex + 1); + } + } + return { + tagName, + tagExp, + closeIndex, + attrExpPresent, + rawTagName + }; + } + function readStopNodeData(xmlData, tagName, i) { + const startIndex = i; + let openTagCount = 1; + for (;i < xmlData.length; i++) { + if (xmlData[i] === "<") { + if (xmlData[i + 1] === "/") { + const closeIndex = findClosingIndex(xmlData, ">", i, `${tagName} is not closed`); + let closeTagName = xmlData.substring(i + 2, closeIndex).trim(); + if (closeTagName === tagName) { + openTagCount--; + if (openTagCount === 0) { + return { + tagContent: xmlData.substring(startIndex, i), + i: closeIndex + }; + } + } + i = closeIndex; + } else if (xmlData[i + 1] === "?") { + const closeIndex = findClosingIndex(xmlData, "?>", i + 1, "StopNode is not closed."); + i = closeIndex; + } else if (xmlData.substr(i + 1, 3) === "!--") { + const closeIndex = findClosingIndex(xmlData, "-->", i + 3, "StopNode is not closed."); + i = closeIndex; + } else if (xmlData.substr(i + 1, 2) === "![") { + const closeIndex = findClosingIndex(xmlData, "]]>", i, "StopNode is not closed.") - 2; + i = closeIndex; + } else { + const tagData = readTagExp(xmlData, i, ">"); + if (tagData) { + const openTagName = tagData && tagData.tagName; + if (openTagName === tagName && tagData.tagExp[tagData.tagExp.length - 1] !== "/") { + openTagCount++; + } + i = tagData.closeIndex; + } + } + } + } + } + function parseValue(val, shouldParse, options) { + if (shouldParse && typeof val === "string") { + const newval = val.trim(); + if (newval === "true") + return true; + else if (newval === "false") + return false; + else + return toNumber(val, options); + } else { + if (util.isExist(val)) { + return val; + } else { + return ""; + } + } + } + function fromCodePoint(str, base, prefix) { + const codePoint = Number.parseInt(str, base); + if (codePoint >= 0 && codePoint <= 1114111) { + return String.fromCodePoint(codePoint); + } else { + return prefix + str + ";"; + } + } + function sanitizeName(name, options) { + if (util.criticalProperties.includes(name)) { + throw new Error(`[SECURITY] Invalid name: "${name}" is a reserved JavaScript keyword that could cause prototype pollution`); + } else if (util.DANGEROUS_PROPERTY_NAMES.includes(name)) { + return options.onDangerousProperty(name); + } + return name; + } + module.exports = OrderedObjParser; +}); + +// node_modules/fast-xml-parser/src/xmlparser/node2json.js +var require_node2json = __commonJS((exports) => { + function prettify(node, options) { + return compress(node, options); + } + function compress(arr, options, jPath) { + let text; + const compressedObj = {}; + for (let i = 0;i < arr.length; i++) { + const tagObj = arr[i]; + const property = propName(tagObj); + let newJpath = ""; + if (jPath === undefined) + newJpath = property; + else + newJpath = jPath + "." + property; + if (property === options.textNodeName) { + if (text === undefined) + text = tagObj[property]; + else + text += "" + tagObj[property]; + } else if (property === undefined) { + continue; + } else if (tagObj[property]) { + let val = compress(tagObj[property], options, newJpath); + const isLeaf = isLeafTag(val, options); + if (tagObj[":@"]) { + assignAttributes(val, tagObj[":@"], newJpath, options); + } else if (Object.keys(val).length === 1 && val[options.textNodeName] !== undefined && !options.alwaysCreateTextNode) { + val = val[options.textNodeName]; + } else if (Object.keys(val).length === 0) { + if (options.alwaysCreateTextNode) + val[options.textNodeName] = ""; + else + val = ""; + } + if (compressedObj[property] !== undefined && compressedObj.hasOwnProperty(property)) { + if (!Array.isArray(compressedObj[property])) { + compressedObj[property] = [compressedObj[property]]; + } + compressedObj[property].push(val); + } else { + if (options.isArray(property, newJpath, isLeaf)) { + compressedObj[property] = [val]; + } else { + compressedObj[property] = val; + } + } + } + } + if (typeof text === "string") { + if (text.length > 0) + compressedObj[options.textNodeName] = text; + } else if (text !== undefined) + compressedObj[options.textNodeName] = text; + return compressedObj; + } + function propName(obj) { + const keys = Object.keys(obj); + for (let i = 0;i < keys.length; i++) { + const key = keys[i]; + if (key !== ":@") + return key; + } + } + function assignAttributes(obj, attrMap, jpath, options) { + if (attrMap) { + const keys = Object.keys(attrMap); + const len = keys.length; + for (let i = 0;i < len; i++) { + const atrrName = keys[i]; + if (options.isArray(atrrName, jpath + "." + atrrName, true, true)) { + obj[atrrName] = [attrMap[atrrName]]; + } else { + obj[atrrName] = attrMap[atrrName]; + } + } + } + } + function isLeafTag(obj, options) { + const { textNodeName } = options; + const propCount = Object.keys(obj).length; + if (propCount === 0) { + return true; + } + if (propCount === 1 && (obj[textNodeName] || typeof obj[textNodeName] === "boolean" || obj[textNodeName] === 0)) { + return true; + } + return false; + } + exports.prettify = prettify; +}); + +// node_modules/fast-xml-parser/src/xmlparser/XMLParser.js +var require_XMLParser = __commonJS((exports, module) => { + var { buildOptions } = require_OptionsBuilder(); + var OrderedObjParser = require_OrderedObjParser(); + var { prettify } = require_node2json(); + var validator = require_validator(); + + class XMLParser { + constructor(options) { + this.externalEntities = {}; + this.options = buildOptions(options); + } + parse(xmlData, validationOption) { + if (typeof xmlData === "string") {} else if (xmlData.toString) { + xmlData = xmlData.toString(); + } else { + throw new Error("XML data is accepted in String or Bytes[] form."); + } + if (validationOption) { + if (validationOption === true) + validationOption = {}; + const result = validator.validate(xmlData, validationOption); + if (result !== true) { + throw Error(`${result.err.msg}:${result.err.line}:${result.err.col}`); + } + } + const orderedObjParser = new OrderedObjParser(this.options); + orderedObjParser.addExternalEntities(this.externalEntities); + const orderedResult = orderedObjParser.parseXml(xmlData); + if (this.options.preserveOrder || orderedResult === undefined) + return orderedResult; + else + return prettify(orderedResult, this.options); + } + addEntity(key, value) { + if (value.indexOf("&") !== -1) { + throw new Error("Entity value can't have '&'"); + } else if (key.indexOf("&") !== -1 || key.indexOf(";") !== -1) { + throw new Error("An entity must be set without '&' and ';'. Eg. use '#xD' for ' '"); + } else if (value === "&") { + throw new Error("An entity with value '&' is not permitted"); + } else { + this.externalEntities[key] = value; + } + } + } + module.exports = XMLParser; +}); + +// node_modules/fast-xml-parser/src/xmlbuilder/orderedJs2Xml.js +var require_orderedJs2Xml = __commonJS((exports, module) => { + var EOL = ` +`; + function toXml(jArray, options) { + let indentation = ""; + if (options.format && options.indentBy.length > 0) { + indentation = EOL; + } + return arrToStr(jArray, options, "", indentation); + } + function arrToStr(arr, options, jPath, indentation) { + let xmlStr = ""; + let isPreviousElementTag = false; + if (!Array.isArray(arr)) { + if (arr !== undefined && arr !== null) { + let text = arr.toString(); + text = replaceEntitiesValue(text, options); + return text; + } + return ""; + } + for (let i = 0;i < arr.length; i++) { + const tagObj = arr[i]; + const tagName = propName(tagObj); + if (tagName === undefined) + continue; + let newJPath = ""; + if (jPath.length === 0) + newJPath = tagName; + else + newJPath = `${jPath}.${tagName}`; + if (tagName === options.textNodeName) { + let tagText = tagObj[tagName]; + if (!isStopNode(newJPath, options)) { + tagText = options.tagValueProcessor(tagName, tagText); + tagText = replaceEntitiesValue(tagText, options); + } + if (isPreviousElementTag) { + xmlStr += indentation; + } + xmlStr += tagText; + isPreviousElementTag = false; + continue; + } else if (tagName === options.cdataPropName) { + if (isPreviousElementTag) { + xmlStr += indentation; + } + xmlStr += ``; + isPreviousElementTag = false; + continue; + } else if (tagName === options.commentPropName) { + xmlStr += indentation + ``; + isPreviousElementTag = true; + continue; + } else if (tagName[0] === "?") { + const attStr2 = attr_to_str(tagObj[":@"], options); + const tempInd = tagName === "?xml" ? "" : indentation; + let piTextNodeName = tagObj[tagName][0][options.textNodeName]; + piTextNodeName = piTextNodeName.length !== 0 ? " " + piTextNodeName : ""; + xmlStr += tempInd + `<${tagName}${piTextNodeName}${attStr2}?>`; + isPreviousElementTag = true; + continue; + } + let newIdentation = indentation; + if (newIdentation !== "") { + newIdentation += options.indentBy; + } + const attStr = attr_to_str(tagObj[":@"], options); + const tagStart = indentation + `<${tagName}${attStr}`; + const tagValue = arrToStr(tagObj[tagName], options, newJPath, newIdentation); + if (options.unpairedTags.indexOf(tagName) !== -1) { + if (options.suppressUnpairedNode) + xmlStr += tagStart + ">"; + else + xmlStr += tagStart + "/>"; + } else if ((!tagValue || tagValue.length === 0) && options.suppressEmptyNode) { + xmlStr += tagStart + "/>"; + } else if (tagValue && tagValue.endsWith(">")) { + xmlStr += tagStart + `>${tagValue}${indentation}`; + } else { + xmlStr += tagStart + ">"; + if (tagValue && indentation !== "" && (tagValue.includes("/>") || tagValue.includes("`; + } + isPreviousElementTag = true; + } + return xmlStr; + } + function propName(obj) { + const keys = Object.keys(obj); + for (let i = 0;i < keys.length; i++) { + const key = keys[i]; + if (!Object.prototype.hasOwnProperty.call(obj, key)) + continue; + if (key !== ":@") + return key; + } + } + function attr_to_str(attrMap, options) { + let attrStr = ""; + if (attrMap && !options.ignoreAttributes) { + for (let attr in attrMap) { + if (!Object.prototype.hasOwnProperty.call(attrMap, attr)) + continue; + let attrVal = options.attributeValueProcessor(attr, attrMap[attr]); + attrVal = replaceEntitiesValue(attrVal, options); + if (attrVal === true && options.suppressBooleanAttributes) { + attrStr += ` ${attr.substr(options.attributeNamePrefix.length)}`; + } else { + attrStr += ` ${attr.substr(options.attributeNamePrefix.length)}="${attrVal}"`; + } + } + } + return attrStr; + } + function isStopNode(jPath, options) { + jPath = jPath.substr(0, jPath.length - options.textNodeName.length - 1); + let tagName = jPath.substr(jPath.lastIndexOf(".") + 1); + for (let index in options.stopNodes) { + if (options.stopNodes[index] === jPath || options.stopNodes[index] === "*." + tagName) + return true; + } + return false; + } + function replaceEntitiesValue(textValue, options) { + if (textValue && textValue.length > 0 && options.processEntities) { + for (let i = 0;i < options.entities.length; i++) { + const entity = options.entities[i]; + textValue = textValue.replace(entity.regex, entity.val); + } + } + return textValue; + } + module.exports = toXml; +}); + +// node_modules/fast-xml-parser/src/xmlbuilder/json2xml.js +var require_json2xml = __commonJS((exports, module) => { + var buildFromOrderedJs = require_orderedJs2Xml(); + var getIgnoreAttributesFn = require_ignoreAttributes(); + var defaultOptions = { + attributeNamePrefix: "@_", + attributesGroupName: false, + textNodeName: "#text", + ignoreAttributes: true, + cdataPropName: false, + format: false, + indentBy: " ", + suppressEmptyNode: false, + suppressUnpairedNode: true, + suppressBooleanAttributes: true, + tagValueProcessor: function(key, a) { + return a; + }, + attributeValueProcessor: function(attrName, a) { + return a; + }, + preserveOrder: false, + commentPropName: false, + unpairedTags: [], + entities: [ + { regex: new RegExp("&", "g"), val: "&" }, + { regex: new RegExp(">", "g"), val: ">" }, + { regex: new RegExp("<", "g"), val: "<" }, + { regex: new RegExp("'", "g"), val: "'" }, + { regex: new RegExp('"', "g"), val: """ } + ], + processEntities: true, + stopNodes: [], + oneListGroup: false + }; + function Builder(options) { + this.options = Object.assign({}, defaultOptions, options); + if (this.options.ignoreAttributes === true || this.options.attributesGroupName) { + this.isAttribute = function() { + return false; + }; + } else { + this.ignoreAttributesFn = getIgnoreAttributesFn(this.options.ignoreAttributes); + this.attrPrefixLen = this.options.attributeNamePrefix.length; + this.isAttribute = isAttribute; + } + this.processTextOrObjNode = processTextOrObjNode; + if (this.options.format) { + this.indentate = indentate; + this.tagEndChar = `> +`; + this.newLine = ` +`; + } else { + this.indentate = function() { + return ""; + }; + this.tagEndChar = ">"; + this.newLine = ""; + } + } + Builder.prototype.build = function(jObj) { + if (this.options.preserveOrder) { + return buildFromOrderedJs(jObj, this.options); + } else { + if (Array.isArray(jObj) && this.options.arrayNodeName && this.options.arrayNodeName.length > 1) { + jObj = { + [this.options.arrayNodeName]: jObj + }; + } + return this.j2x(jObj, 0, []).val; + } + }; + Builder.prototype.j2x = function(jObj, level, ajPath) { + let attrStr = ""; + let val = ""; + const jPath = ajPath.join("."); + for (let key in jObj) { + if (!Object.prototype.hasOwnProperty.call(jObj, key)) + continue; + if (typeof jObj[key] === "undefined") { + if (this.isAttribute(key)) { + val += ""; + } + } else if (jObj[key] === null) { + if (this.isAttribute(key)) { + val += ""; + } else if (key === this.options.cdataPropName) { + val += ""; + } else if (key[0] === "?") { + val += this.indentate(level) + "<" + key + "?" + this.tagEndChar; + } else { + val += this.indentate(level) + "<" + key + "/" + this.tagEndChar; + } + } else if (jObj[key] instanceof Date) { + val += this.buildTextValNode(jObj[key], key, "", level); + } else if (typeof jObj[key] !== "object") { + const attr = this.isAttribute(key); + if (attr && !this.ignoreAttributesFn(attr, jPath)) { + attrStr += this.buildAttrPairStr(attr, "" + jObj[key]); + } else if (!attr) { + if (key === this.options.textNodeName) { + let newval = this.options.tagValueProcessor(key, "" + jObj[key]); + val += this.replaceEntitiesValue(newval); + } else { + val += this.buildTextValNode(jObj[key], key, "", level); + } + } + } else if (Array.isArray(jObj[key])) { + const arrLen = jObj[key].length; + let listTagVal = ""; + let listTagAttr = ""; + for (let j = 0;j < arrLen; j++) { + const item = jObj[key][j]; + if (typeof item === "undefined") {} else if (item === null) { + if (key[0] === "?") + val += this.indentate(level) + "<" + key + "?" + this.tagEndChar; + else + val += this.indentate(level) + "<" + key + "/" + this.tagEndChar; + } else if (typeof item === "object") { + if (this.options.oneListGroup) { + const result = this.j2x(item, level + 1, ajPath.concat(key)); + listTagVal += result.val; + if (this.options.attributesGroupName && item.hasOwnProperty(this.options.attributesGroupName)) { + listTagAttr += result.attrStr; + } + } else { + listTagVal += this.processTextOrObjNode(item, key, level, ajPath); + } + } else { + if (this.options.oneListGroup) { + let textValue = this.options.tagValueProcessor(key, item); + textValue = this.replaceEntitiesValue(textValue); + listTagVal += textValue; + } else { + listTagVal += this.buildTextValNode(item, key, "", level); + } + } + } + if (this.options.oneListGroup) { + listTagVal = this.buildObjectNode(listTagVal, key, listTagAttr, level); + } + val += listTagVal; + } else { + if (this.options.attributesGroupName && key === this.options.attributesGroupName) { + const Ks = Object.keys(jObj[key]); + const L = Ks.length; + for (let j = 0;j < L; j++) { + attrStr += this.buildAttrPairStr(Ks[j], "" + jObj[key][Ks[j]]); + } + } else { + val += this.processTextOrObjNode(jObj[key], key, level, ajPath); + } + } + } + return { attrStr, val }; + }; + Builder.prototype.buildAttrPairStr = function(attrName, val) { + val = this.options.attributeValueProcessor(attrName, "" + val); + val = this.replaceEntitiesValue(val); + if (this.options.suppressBooleanAttributes && val === "true") { + return " " + attrName; + } else + return " " + attrName + '="' + val + '"'; + }; + function processTextOrObjNode(object, key, level, ajPath) { + const result = this.j2x(object, level + 1, ajPath.concat(key)); + if (object[this.options.textNodeName] !== undefined && Object.keys(object).length === 1) { + return this.buildTextValNode(object[this.options.textNodeName], key, result.attrStr, level); + } else { + return this.buildObjectNode(result.val, key, result.attrStr, level); + } + } + Builder.prototype.buildObjectNode = function(val, key, attrStr, level) { + if (val === "") { + if (key[0] === "?") + return this.indentate(level) + "<" + key + attrStr + "?" + this.tagEndChar; + else { + return this.indentate(level) + "<" + key + attrStr + this.closeTag(key) + this.tagEndChar; + } + } else { + let tagEndExp = "" + val + tagEndExp; + } else if (this.options.commentPropName !== false && key === this.options.commentPropName && piClosingChar.length === 0) { + return this.indentate(level) + `` + this.newLine; + } else { + return this.indentate(level) + "<" + key + attrStr + piClosingChar + this.tagEndChar + val + this.indentate(level) + tagEndExp; + } + } + }; + Builder.prototype.closeTag = function(key) { + let closeTag = ""; + if (this.options.unpairedTags.indexOf(key) !== -1) { + if (!this.options.suppressUnpairedNode) + closeTag = "/"; + } else if (this.options.suppressEmptyNode) { + closeTag = "/"; + } else { + closeTag = `>` + this.newLine; + } else if (this.options.commentPropName !== false && key === this.options.commentPropName) { + return this.indentate(level) + `` + this.newLine; + } else if (key[0] === "?") { + return this.indentate(level) + "<" + key + attrStr + "?" + this.tagEndChar; + } else { + let textValue = this.options.tagValueProcessor(key, val); + textValue = this.replaceEntitiesValue(textValue); + if (textValue === "") { + return this.indentate(level) + "<" + key + attrStr + this.closeTag(key) + this.tagEndChar; + } else { + return this.indentate(level) + "<" + key + attrStr + ">" + textValue + " 0 && this.options.processEntities) { + for (let i = 0;i < this.options.entities.length; i++) { + const entity = this.options.entities[i]; + textValue = textValue.replace(entity.regex, entity.val); + } + } + return textValue; + }; + function indentate(level) { + return this.options.indentBy.repeat(level); + } + function isAttribute(name) { + if (name.startsWith(this.options.attributeNamePrefix) && name !== this.options.textNodeName) { + return name.substr(this.attrPrefixLen); + } else { + return false; + } + } + module.exports = Builder; +}); + +// node_modules/fast-xml-parser/src/fxp.js +var require_fxp = __commonJS((exports, module) => { + var validator = require_validator(); + var XMLParser = require_XMLParser(); + var XMLBuilder = require_json2xml(); + module.exports = { + XMLParser, + XMLValidator: validator, + XMLBuilder + }; +}); + +// browse-mobile/src/ref-system.ts +var import_fast_xml_parser = __toESM(require_fxp(), 1); +var IOS_INTERACTIVE_TYPES = new Set([ + "XCUIElementTypeButton", + "XCUIElementTypeTextField", + "XCUIElementTypeSecureTextField", + "XCUIElementTypeSwitch", + "XCUIElementTypeSlider", + "XCUIElementTypeLink", + "XCUIElementTypeSearchField", + "XCUIElementTypeTextView", + "XCUIElementTypeCell", + "XCUIElementTypeImage", + "XCUIElementTypeSegmentedControl", + "XCUIElementTypePicker", + "XCUIElementTypePickerWheel", + "XCUIElementTypeStepper", + "XCUIElementTypePageIndicator", + "XCUIElementTypeTab", + "XCUIElementTypeTabBar" +]); +var IOS_WRAPPER_TYPES = new Set([ + "XCUIElementTypeApplication", + "XCUIElementTypeWindow", + "XCUIElementTypeOther", + "XCUIElementTypeGroup", + "XCUIElementTypeScrollView", + "XCUIElementTypeTable", + "XCUIElementTypeCollectionView", + "XCUIElementTypeNavigationBar", + "XCUIElementTypeToolbar", + "XCUIElementTypeStatusBar", + "XCUIElementTypeKeyboard" +]); +function parseBounds(attrs) { + const x = parseInt(attrs.x, 10); + const y = parseInt(attrs.y, 10); + const width = parseInt(attrs.width, 10); + const height = parseInt(attrs.height, 10); + if ([x, y, width, height].some((v) => isNaN(v))) + return null; + return { x, y, width, height }; +} +function isInteractive(type, attrs) { + if (IOS_INTERACTIVE_TYPES.has(type)) + return true; + if (type === "XCUIElementTypeStaticText") { + const accessible = attrs.accessible; + if (accessible === "true" && attrs.label) + return true; + } + return false; +} +function getTagAndChildren(node) { + const attrs = node[":@"] || {}; + for (const key of Object.keys(node)) { + if (key === ":@" || key === "#text" || key === "?xml") + continue; + const children = node[key]; + return { + tag: key, + children: Array.isArray(children) ? children : [], + attrs + }; + } + return null; +} +function walkNode(node, ctx) { + if (ctx.depth > ctx.maxDepth) + return; + const parsed = getTagAndChildren(node); + if (!parsed) + return; + const { tag: type, children, attrs } = parsed; + if (type === "AppiumAUT") { + for (const child of children) { + if (typeof child === "object" && child !== null) { + walkNode(child, ctx); + } + } + return; + } + if (!type.startsWith("XCUIElementType")) + return; + const label = attrs.label || null; + const visible = attrs.visible !== "false"; + if (!visible) + return; + ctx.xpathParts.push(`${type}[${attrs.index || "0"}]`); + const xpath = "//" + ctx.xpathParts.join("/"); + const indent = " ".repeat(ctx.depth); + if (isInteractive(type, attrs)) { + ctx.counter++; + const refKey = `e${ctx.counter}`; + const friendlyType = type.replace("XCUIElementType", ""); + let resolveStrategy = "xpath"; + if (attrs.testID) { + resolveStrategy = "testID"; + } else if (label) { + resolveStrategy = "accessibilityLabel"; + } + ctx.refs.set(refKey, { + xpath, + bounds: parseBounds(attrs), + label, + testID: attrs.testID || null, + elementType: type, + resolveStrategy + }); + const displayLabel = label ? ` "${label}"` : ""; + ctx.lines.push(`${indent}@${refKey} ${friendlyType}${displayLabel}`); + } else if (!IOS_WRAPPER_TYPES.has(type)) { + if (label) { + const friendlyType = type.replace("XCUIElementType", ""); + ctx.lines.push(`${indent}${friendlyType}: "${label}"`); + } + } + ctx.depth++; + for (const child of children) { + if (typeof child === "object" && child !== null) { + walkNode(child, ctx); + } + } + ctx.depth--; + ctx.xpathParts.pop(); +} +function parseXmlToRefs(xml) { + if (!xml || xml.trim().length === 0) { + return { refs: new Map, text: "(empty screen)" }; + } + const parser = new import_fast_xml_parser.XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "", + preserveOrder: true, + allowBooleanAttributes: true + }); + let parsed; + try { + parsed = parser.parse(xml); + } catch (err) { + return { + refs: new Map, + text: `(error parsing accessibility tree: ${err instanceof Error ? err.message : String(err)})` + }; + } + const ctx = { + refs: new Map, + lines: [], + counter: 0, + xpathParts: [], + depth: 0, + maxDepth: 150 + }; + if (Array.isArray(parsed)) { + for (const node of parsed) { + if (typeof node === "object" && node !== null) { + walkNode(node, ctx); + } + } + } + const summary = `${ctx.refs.size} interactive element${ctx.refs.size !== 1 ? "s" : ""} found`; + const text = ctx.lines.length > 0 ? `${summary} + +${ctx.lines.join(` +`)}` : `${summary} + +(no interactive elements on this screen)`; + return { refs: ctx.refs, text }; +} +async function resolveRef(ref, refs, findElement) { + const key = ref.startsWith("@") ? ref.slice(1) : ref; + const entry = refs.get(key); + if (!entry) { + return null; + } + let element = null; + if (entry.resolveStrategy === "testID" && entry.testID) { + element = await findElement("accessibility id", entry.testID); + } else if (entry.resolveStrategy === "accessibilityLabel" && entry.label) { + element = await findElement("accessibility id", entry.label); + } + if (!element) { + element = await findElement("xpath", entry.xpath); + } + if (element) { + return { element, usedCoordinates: false }; + } + if (entry.label && entry.resolveStrategy !== "accessibilityLabel") { + element = await findElement("accessibility id", entry.label); + if (element) { + return { element, usedCoordinates: false }; + } + } + if (entry.bounds) { + return { + element: { + _coordinateTap: true, + x: entry.bounds.x + entry.bounds.width / 2, + y: entry.bounds.y + entry.bounds.height / 2 + }, + usedCoordinates: true + }; + } + return null; +} +function snapshotDiff(previous, current) { + if (!previous) { + return current + ` + +(no previous snapshot to diff against)`; + } + const prevLines = previous.split(` +`); + const currLines = current.split(` +`); + const result = []; + const maxLen = Math.max(prevLines.length, currLines.length); + let hasChanges = false; + for (let i = 0;i < maxLen; i++) { + const prev = prevLines[i] || ""; + const curr = currLines[i] || ""; + if (prev === curr) { + result.push(` ${curr}`); + } else { + hasChanges = true; + if (prev) + result.push(`- ${prev}`); + if (curr) + result.push(`+ ${curr}`); + } + } + if (!hasChanges) { + return "(no changes since last snapshot)"; + } + return result.join(` +`); +} + +// browse-mobile/src/platform/ios.ts +import { execSync } from "child_process"; +function assertSafeShellArg(value, name) { + if (/[;&|`$"'\\<>(){}\n\r]/.test(value)) { + throw new Error(`Unsafe ${name}: contains shell metacharacters`); + } +} +function listDevices() { + try { + const output = execSync("xcrun simctl list devices available -j", { + encoding: "utf-8", + timeout: 1e4 + }); + const data = JSON.parse(output); + const devices = []; + for (const [runtime, devs] of Object.entries(data.devices || {})) { + if (!Array.isArray(devs)) + continue; + for (const dev of devs) { + devices.push({ + udid: dev.udid, + name: dev.name, + state: dev.state, + runtime: runtime.replace(/^com\.apple\.CoreSimulator\.SimRuntime\./, "") + }); + } + } + return devices; + } catch { + return []; + } +} +function ensureBootedSimulator() { + const devices = listDevices(); + const booted = devices.find((d) => d.state === "Booted"); + if (booted) + return booted; + const iphones = devices.filter((d) => d.name.includes("iPhone") && d.state === "Shutdown").sort((a, b) => { + return b.name.localeCompare(a.name); + }); + const target = iphones[0] || devices[0]; + if (!target) + return null; + try { + assertSafeShellArg(target.udid, "simulator UDID"); + execSync(`xcrun simctl boot "${target.udid}"`, { + timeout: 30000, + stdio: "pipe" + }); + return { ...target, state: "Booted" }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("current state: Booted")) { + return { ...target, state: "Booted" }; + } + return null; + } +} + +// browse-mobile/src/mobile-driver.ts +import * as fs from "fs"; +import * as path from "path"; +var APPIUM_BASE = "http://127.0.0.1:4723"; +var REQUEST_TIMEOUT = 30000; +var SESSION_TIMEOUT = 180000; +async function appiumPost(sessionId, endpoint, body, timeout = REQUEST_TIMEOUT) { + const url = `${APPIUM_BASE}/session/${sessionId}${endpoint}`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : "{}", + signal: AbortSignal.timeout(timeout) + }); + const data = await res.json(); + if (!res.ok) { + const err = data.value; + const msg = typeof err === "string" ? err : err?.message || JSON.stringify(err); + throw new Error(`Appium error: ${msg}`); + } + return data.value; +} +async function appiumGet(sessionId, endpoint, timeout = REQUEST_TIMEOUT) { + const url = `${APPIUM_BASE}/session/${sessionId}${endpoint}`; + const res = await fetch(url, { + signal: AbortSignal.timeout(timeout) + }); + const data = await res.json(); + if (!res.ok) { + const err = data.value; + const msg = typeof err === "string" ? err : err?.message || JSON.stringify(err); + throw new Error(`Appium error: ${msg}`); + } + return data.value; +} +async function appiumDelete(sessionId, timeout = REQUEST_TIMEOUT) { + const url = `${APPIUM_BASE}/session/${sessionId}`; + await fetch(url, { + method: "DELETE", + signal: AbortSignal.timeout(timeout) + }); +} +async function findElement(sessionId, using, value) { + try { + const result = await appiumPost(sessionId, "/element", { using, value }); + return result["element-6066-11e4-a52e-4f735466cecf"] || result["ELEMENT"] || Object.values(result)[0] || null; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("no such element") || msg.includes("NoSuchElement") || msg.includes("unable to find")) { + return null; + } + throw err; + } +} +function tapAction(x, y) { + return { + actions: [{ + type: "pointer", + id: "finger1", + parameters: { pointerType: "touch" }, + actions: [ + { type: "pointerMove", duration: 0, x: Math.round(x), y: Math.round(y) }, + { type: "pointerDown", button: 0 }, + { type: "pointerUp", button: 0 } + ] + }] + }; +} +function swipeAction(startX, startY, endX, endY, durationMs = 300) { + return { + actions: [{ + type: "pointer", + id: "finger1", + parameters: { pointerType: "touch" }, + actions: [ + { type: "pointerMove", duration: 0, x: startX, y: startY }, + { type: "pointerDown", button: 0 }, + { type: "pointerMove", duration: durationMs, x: endX, y: endY }, + { type: "pointerUp", button: 0 } + ] + }] + }; +} + +class MobileDriver { + sessionId = null; + refs = new Map; + lastSnapshot = null; + options; + _isConnected = false; + constructor(options) { + this.options = options; + } + async connect() { + const sim = ensureBootedSimulator(); + if (!sim) { + throw new Error("No iOS Simulator available. Run: xcrun simctl list devices available"); + } + const capabilities = { + platformName: "iOS", + "appium:automationName": this.options.automationName || "XCUITest", + "appium:deviceName": this.options.deviceName || sim.name, + "appium:udid": sim.udid, + "appium:bundleId": this.options.bundleId, + "appium:autoAcceptAlerts": true, + "appium:noReset": true, + "appium:newCommandTimeout": 1800, + "appium:wdaLaunchTimeout": 120000, + "appium:wdaConnectionTimeout": 120000 + }; + if (this.options.appPath) { + capabilities["appium:app"] = this.options.appPath; + } + if (this.options.platformVersion) { + capabilities["appium:platformVersion"] = this.options.platformVersion; + } + const res = await fetch(`${APPIUM_BASE}/session`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + capabilities: { alwaysMatch: capabilities, firstMatch: [{}] } + }), + signal: AbortSignal.timeout(SESSION_TIMEOUT) + }); + if (!res.ok) { + const errText = await res.text(); + throw new Error(`Appium session creation failed (${res.status}): ${errText}`); + } + const data = await res.json(); + this.sessionId = data.value.sessionId; + this._isConnected = true; + } + async disconnect() { + if (this.sessionId) { + try { + await appiumDelete(this.sessionId); + } catch {} + this.sessionId = null; + } + this._isConnected = false; + this.refs.clear(); + this.lastSnapshot = null; + } + get isConnected() { + return this._isConnected && this.sessionId !== null; + } + async isHealthy() { + if (!this.sessionId) + return false; + try { + await appiumGet(this.sessionId, "/source", 5000); + return true; + } catch { + return false; + } + } + ensureSession() { + if (!this.sessionId) { + throw new Error("Not connected to Appium. Call connect() first."); + } + return this.sessionId; + } + setRefMap(refs) { + this.refs = refs; + } + getRefCount() { + return this.refs.size; + } + clearRefs() { + this.refs.clear(); + } + setLastSnapshot(text) { + this.lastSnapshot = text; + } + getLastSnapshot() { + return this.lastSnapshot; + } + async goto(target) { + const sid = this.ensureSession(); + if (target.startsWith("app://")) { + const bundleId = target.replace("app://", ""); + try { + await appiumPost(sid, "/execute/sync", { + script: "mobile: terminateApp", + args: [{ bundleId }] + }); + } catch {} + await appiumPost(sid, "/execute/sync", { + script: "mobile: launchApp", + args: [{ bundleId }] + }); + return `Launched ${bundleId}`; + } + try { + await appiumPost(sid, "/url", { url: target }); + return `Navigated to ${target}`; + } catch (err) { + return `Deep link failed: ${err instanceof Error ? err.message : String(err)}. Navigate manually via click commands.`; + } + } + async click(refOrSelector) { + const sid = this.ensureSession(); + if (refOrSelector.startsWith("@")) { + const finder = async (strategy, selector) => { + const using = strategy === "accessibility id" ? "accessibility id" : "xpath"; + return findElement(sid, using, selector); + }; + let result = await resolveRef(refOrSelector, this.refs, finder); + if (!result) { + await this.snapshot([]); + result = await resolveRef(refOrSelector, this.refs, finder); + if (!result) { + throw new Error(`Element ${refOrSelector} no longer exists \u2014 screen may have navigated`); + } + } + return this.performClick(sid, result); + } + const labelMatch = refOrSelector.match(/^(?:~|label:)(.+)$/); + if (labelMatch) { + const label = labelMatch[1].replace(/^["']|["']$/g, ""); + const elementId2 = await findElement(sid, "accessibility id", label); + if (elementId2) { + await appiumPost(sid, `/element/${elementId2}/click`); + return `Clicked label:${label}`; + } + throw new Error(`Element with accessibility label "${label}" not found`); + } + const elementId = await findElement(sid, "xpath", refOrSelector); + if (elementId) { + await appiumPost(sid, `/element/${elementId}/click`); + return `Clicked ${refOrSelector}`; + } + throw new Error(`Element not found: ${refOrSelector}`); + } + async performClick(sid, result) { + if (result.usedCoordinates) { + const coords = result.element; + await appiumPost(sid, "/actions", tapAction(coords.x, coords.y)); + return `Tapped at (${Math.round(coords.x)}, ${Math.round(coords.y)}) \u2014 coordinate fallback. Consider adding accessibilityLabel.`; + } + const elementId = result.element; + await appiumPost(sid, `/element/${elementId}/click`); + const refKey = [...this.refs.entries()].find(([, e]) => e.label); + const label = refKey ? ` (${refKey[1].elementType.replace("XCUIElementType", "")}: "${refKey[1].label}")` : ""; + return `Clicked${label}`; + } + async tapCoordinates(x, y) { + const sid = this.ensureSession(); + await appiumPost(sid, "/actions", tapAction(x, y)); + return `Tapped at (${x}, ${y})`; + } + async fill(refOrSelector, text) { + const sid = this.ensureSession(); + if (refOrSelector.startsWith("@")) { + const finder = async (strategy, selector) => { + const using = strategy === "accessibility id" ? "accessibility id" : "xpath"; + return findElement(sid, using, selector); + }; + const result = await resolveRef(refOrSelector, this.refs, finder); + if (!result) { + throw new Error(`Cannot fill ${refOrSelector} \u2014 element not found`); + } + if (result.usedCoordinates) { + const coords = result.element; + await appiumPost(sid, "/actions", tapAction(coords.x, coords.y)); + await new Promise((r) => setTimeout(r, 500)); + const keyActions = []; + for (const char of text) { + keyActions.push({ type: "keyDown", value: char }); + keyActions.push({ type: "keyUp", value: char }); + } + await appiumPost(sid, "/actions", { + actions: [{ type: "key", id: "keyboard", actions: keyActions }] + }); + return `Filled ${refOrSelector} with "${text}" (via coordinate tap + keyboard)`; + } + const elementId2 = result.element; + await appiumPost(sid, `/element/${elementId2}/clear`); + await appiumPost(sid, `/element/${elementId2}/value`, { text }); + return `Filled ${refOrSelector} with "${text}"`; + } + const elementId = await findElement(sid, "accessibility id", refOrSelector.replace(/^~/, "")); + if (!elementId) + throw new Error(`Element not found: ${refOrSelector}`); + await appiumPost(sid, `/element/${elementId}/clear`); + await appiumPost(sid, `/element/${elementId}/value`, { text }); + return `Filled ${refOrSelector} with "${text}"`; + } + async screenshot(outputPath) { + const sid = this.ensureSession(); + const base64 = await appiumGet(sid, "/screenshot"); + const buffer = Buffer.from(base64, "base64"); + try { + const dir = path.dirname(outputPath); + if (!fs.existsSync(dir)) + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(outputPath, buffer); + } catch (err) { + throw new Error(`Screenshot save failed: ${err instanceof Error ? err.message : String(err)}. Disk may be full.`); + } + return `Screenshot saved to ${outputPath} (${buffer.length} bytes)`; + } + async snapshot(flags) { + const sid = this.ensureSession(); + const xml = await appiumGet(sid, "/source"); + const result = parseXmlToRefs(xml); + this.refs = result.refs; + const isDiff = flags.includes("-D") || flags.includes("--diff"); + const isAnnotate = flags.includes("-a") || flags.includes("--annotate"); + let output = result.text; + if (isDiff) + output = snapshotDiff(this.lastSnapshot, result.text); + this.lastSnapshot = result.text; + if (isAnnotate) { + const outputIdx = flags.indexOf("-o"); + const longOutputIdx = flags.indexOf("--output"); + const pathIdx = outputIdx >= 0 ? outputIdx + 1 : longOutputIdx >= 0 ? longOutputIdx + 1 : -1; + if (pathIdx >= 0 && pathIdx < flags.length) { + await this.screenshot(flags[pathIdx]); + output += ` + +Annotated screenshot saved (note: mobile screenshots do not have overlay boxes)`; + } + } + return output; + } + async text() { + const sid = this.ensureSession(); + const xml = await appiumGet(sid, "/source"); + const labels = []; + const labelRegex = /\blabel="([^"]*)"/g; + const valueRegex = /\bvalue="([^"]*)"/g; + let match; + while ((match = labelRegex.exec(xml)) !== null) { + if (match[1].trim()) + labels.push(match[1].trim()); + } + while ((match = valueRegex.exec(xml)) !== null) { + if (match[1].trim()) + labels.push(match[1].trim()); + } + const seen = new Set; + const unique = labels.filter((l) => { + if (seen.has(l)) + return false; + seen.add(l); + return true; + }); + return unique.join(` +`) || "(no visible text)"; + } + async scroll(direction) { + const sid = this.ensureSession(); + let startX = 200, startY = 400, endX = 200, endY = 400; + switch (direction.toLowerCase()) { + case "down": + startY = 500; + endY = 200; + break; + case "up": + startY = 200; + endY = 500; + break; + case "left": + startX = 300; + endX = 50; + break; + case "right": + startX = 50; + endX = 300; + break; + default: + startY = 500; + endY = 200; + } + await appiumPost(sid, "/actions", swipeAction(startX, startY, endX, endY)); + return `Scrolled ${direction || "down"}`; + } + async back() { + const sid = this.ensureSession(); + await appiumPost(sid, "/back"); + return "Navigated back"; + } + async viewport(size) { + const sid = this.ensureSession(); + if (size.toLowerCase() === "landscape" || size.toLowerCase() === "portrait") { + const orientation = size.toLowerCase() === "landscape" ? "LANDSCAPE" : "PORTRAIT"; + await appiumPost(sid, "/orientation", { orientation }); + return `Set orientation to ${orientation}`; + } + return `Viewport size change not supported mid-session. Use: "landscape" or "portrait"`; + } + async links() { + if (this.refs.size === 0) + return "(no tappable elements \u2014 run snapshot first)"; + const lines = []; + for (const [key, entry] of this.refs) { + const type = entry.elementType.replace("XCUIElementType", ""); + const label = entry.label ? ` "${entry.label}"` : ""; + lines.push(`@${key} ${type}${label}`); + } + return lines.join(` +`) || "(no tappable elements)"; + } + async forms() { + const inputTypes = new Set(["XCUIElementTypeTextField", "XCUIElementTypeSecureTextField", "XCUIElementTypeSearchField", "XCUIElementTypeTextView"]); + const lines = []; + for (const [key, entry] of this.refs) { + if (inputTypes.has(entry.elementType)) { + const type = entry.elementType.replace("XCUIElementType", ""); + const label = entry.label ? ` "${entry.label}"` : ""; + lines.push(`@${key} ${type}${label}`); + } + } + return lines.join(` +`) || "(no input fields found)"; + } + async dialogAccept() { + const sid = this.ensureSession(); + try { + await appiumPost(sid, "/alert/accept"); + return "Alert accepted"; + } catch { + return "No alert to accept"; + } + } + async dialogDismiss() { + const sid = this.ensureSession(); + try { + await appiumPost(sid, "/alert/dismiss"); + return "Alert dismissed"; + } catch { + return "No alert to dismiss"; + } + } +} + +// browse-mobile/src/server.ts +import * as fs2 from "fs"; +import * as path2 from "path"; +import * as crypto from "crypto"; +var TOKEN = crypto.randomUUID(); +var STATE_FILE = process.env.BROWSE_MOBILE_STATE_FILE || ".gstack/browse-mobile.json"; +var IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_MOBILE_IDLE_TIMEOUT || "1800000", 10); +var mobileDriver = null; +var lastActivity = Date.now(); +var idleTimer = null; +var commandQueue = Promise.resolve(); +var READ_COMMANDS = new Set([ + "text", + "links", + "forms", + "snapshot" +]); +var WRITE_COMMANDS = new Set([ + "goto", + "click", + "tap", + "fill", + "scroll", + "back", + "viewport", + "dialog-accept", + "dialog-dismiss" +]); +var META_COMMANDS = new Set([ + "screenshot", + "status", + "stop" +]); +var UNSUPPORTED_COMMANDS = new Set([ + "html", + "css", + "attrs", + "js", + "eval", + "accessibility", + "console", + "network", + "cookies", + "storage", + "perf", + "dialog", + "is", + "forward", + "reload", + "select", + "hover", + "type", + "press", + "wait", + "cookie", + "cookie-import", + "cookie-import-browser", + "header", + "useragent", + "upload", + "tabs", + "tab", + "newtab", + "closetab", + "pdf", + "responsive", + "chain", + "diff", + "url", + "handoff", + "resume" +]); +async function handleCommand(command, args) { + if (!mobileDriver) { + throw new Error("MobileDriver not initialized"); + } + if (!mobileDriver.isConnected) { + console.error("[browse-mobile] Not connected \u2014 attempting to connect to Appium..."); + await mobileDriver.connect(); + console.error("[browse-mobile] Connected to Appium (reconnect)"); + } + if (UNSUPPORTED_COMMANDS.has(command)) { + return JSON.stringify({ + error: "not_supported", + message: `Command '${command}' is not supported in mobile mode.`, + supported: false + }); + } + switch (command) { + case "text": + return mobileDriver.text(); + case "links": + return mobileDriver.links(); + case "forms": + return mobileDriver.forms(); + case "snapshot": + return mobileDriver.snapshot(args); + case "goto": + if (args.length === 0) + throw new Error("goto requires a target (e.g., app://com.example.app)"); + return mobileDriver.goto(args[0]); + case "click": + if (args.length === 0) + throw new Error("click requires a ref (e.g., @e1) or label:Text"); + return mobileDriver.click(args[0]); + case "tap": { + if (args.length < 2) + throw new Error("tap requires x y coordinates (e.g., tap 195 750)"); + const tapX = parseInt(args[0], 10); + const tapY = parseInt(args[1], 10); + if (isNaN(tapX) || isNaN(tapY)) + throw new Error(`Invalid coordinates: "${args[0]}" "${args[1]}" \u2014 must be numbers`); + return mobileDriver.tapCoordinates(tapX, tapY); + } + case "fill": + if (args.length < 2) + throw new Error('fill requires a ref and text (e.g., @e1 "hello")'); + return mobileDriver.fill(args[0], args.slice(1).join(" ")); + case "scroll": + return mobileDriver.scroll(args[0] || "down"); + case "back": + return mobileDriver.back(); + case "viewport": + if (args.length === 0) + throw new Error("viewport requires a size (e.g., landscape, portrait)"); + return mobileDriver.viewport(args[0]); + case "dialog-accept": + return mobileDriver.dialogAccept(); + case "dialog-dismiss": + return mobileDriver.dialogDismiss(); + case "screenshot": { + const outputPath = args[0] || "/tmp/browse-mobile-screenshot.png"; + return mobileDriver.screenshot(outputPath); + } + case "status": + return JSON.stringify({ + connected: mobileDriver.isConnected, + refs: mobileDriver.getRefCount(), + uptime: Math.floor((Date.now() - startTime) / 1000) + }); + case "stop": + await shutdown(); + return "Server stopped"; + default: + throw new Error(`Unknown command: ${command}`); + } +} +var startTime = Date.now(); +async function findAvailablePort() { + const explicit = process.env.BROWSE_MOBILE_PORT; + if (explicit) + return parseInt(explicit, 10); + for (let attempt = 0;attempt < 5; attempt++) { + const port = 1e4 + Math.floor(Math.random() * 50000); + try { + const test = Bun.serve({ port, fetch: () => new Response("ok") }); + test.stop(true); + return port; + } catch { + continue; + } + } + throw new Error("Could not find available port after 5 attempts"); +} +async function shutdown() { + if (idleTimer) { + clearInterval(idleTimer); + idleTimer = null; + } + if (mobileDriver) { + try { + await mobileDriver.disconnect(); + } catch {} + mobileDriver = null; + } + try { + fs2.unlinkSync(STATE_FILE); + } catch {} + process.exit(0); +} +async function startServer() { + const port = await findAvailablePort(); + const server = Bun.serve({ + port, + async fetch(req) { + const url = new URL(req.url); + if (url.pathname === "/health") { + const healthy = mobileDriver ? await mobileDriver.isHealthy() : false; + return Response.json({ + status: healthy ? "healthy" : "unhealthy", + uptime: Math.floor((Date.now() - startTime) / 1000), + refs: mobileDriver?.getRefCount() || 0 + }); + } + const auth = req.headers.get("Authorization"); + if (auth !== `Bearer ${TOKEN}`) { + return new Response("Unauthorized", { status: 401 }); + } + if (url.pathname === "/command" && req.method === "POST") { + lastActivity = Date.now(); + try { + const body = await req.json(); + const { command, args = [] } = body; + const result = await new Promise((resolve, reject) => { + commandQueue = commandQueue.then(() => handleCommand(command, args)).then(resolve).catch(reject); + }); + return new Response(result, { + headers: { "Content-Type": "text/plain" } + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return Response.json({ error: message, hint: getErrorHint(message) }, { status: 500 }); + } + } + return new Response("Not found", { status: 404 }); + } + }); + const stateDir = path2.dirname(STATE_FILE); + if (!fs2.existsSync(stateDir)) { + fs2.mkdirSync(stateDir, { recursive: true }); + } + const state = { + pid: process.pid, + port, + token: TOKEN, + startedAt: new Date().toISOString(), + serverPath: import.meta.path + }; + const tmpFile = STATE_FILE + ".tmp"; + fs2.writeFileSync(tmpFile, JSON.stringify(state), { mode: 384 }); + fs2.renameSync(tmpFile, STATE_FILE); + idleTimer = setInterval(() => { + if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) { + console.error("[browse-mobile] Idle timeout \u2014 shutting down"); + shutdown(); + } + }, 60000); + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); + console.error(`[browse-mobile] Server running on port ${port} (pid ${process.pid})`); +} +function getErrorHint(message) { + if (message.includes("not connected") || message.includes("session")) { + return "Appium session may have died. Try: $BM goto app://your.bundle.id"; + } + if (message.includes("no longer exists")) { + return "Element was on a previous screen. Run: $BM snapshot -i to see current elements."; + } + if (message.includes("Disk may be full")) { + return "Check available disk space with: df -h"; + } + return; +} +async function init() { + const cliArgs = process.argv.slice(2).filter((a) => a !== "--server"); + const bundleId = process.env.BROWSE_MOBILE_BUNDLE_ID || cliArgs[0] || ""; + if (!bundleId) { + console.error("[browse-mobile] Warning: No bundle ID provided. Set BROWSE_MOBILE_BUNDLE_ID or pass as argument."); + } + const options = { + bundleId, + appPath: process.env.BROWSE_MOBILE_APP_PATH, + deviceName: process.env.BROWSE_MOBILE_DEVICE_NAME, + platformVersion: process.env.BROWSE_MOBILE_PLATFORM_VERSION + }; + mobileDriver = new MobileDriver(options); + await startServer(); + try { + await mobileDriver.connect(); + console.error("[browse-mobile] Connected to Appium"); + } catch (err) { + console.error(`[browse-mobile] Failed to connect to Appium: ${err instanceof Error ? err.message : String(err)}`); + console.error("[browse-mobile] Server is running \u2014 Appium connection will be retried on first command"); + } +} +init().catch((err) => { + console.error(`[browse-mobile] Fatal error: ${err}`); + process.exit(1); +}); diff --git a/browse-mobile/src/cli.ts b/browse-mobile/src/cli.ts new file mode 100644 index 000000000..6a47005d0 --- /dev/null +++ b/browse-mobile/src/cli.ts @@ -0,0 +1,468 @@ +/** + * browse-mobile CLI + * + * Same lifecycle pattern as browse/src/cli.ts: + * - Read state file → check server health → send command + * - If no server → start one → wait for state file → send command + */ + +import * as fs from "fs"; +import * as path from "path"; +import { execSync, spawn } from "child_process"; + +// ─── Configuration ─── + +interface Config { + stateFile: string; + lockFile: string; + maxStartWait: number; // ms +} + +function getConfig(): Config { + const projectRoot = findProjectRoot(); + const gstackDir = path.join(projectRoot, ".gstack"); + + return { + stateFile: path.join(gstackDir, "browse-mobile.json"), + lockFile: path.join(gstackDir, "browse-mobile.json.lock"), + maxStartWait: 30000, // 30s — Appium is slow to start + }; +} + +function findProjectRoot(): string { + let dir = process.cwd(); + while (dir !== path.dirname(dir)) { + if (fs.existsSync(path.join(dir, ".git"))) return dir; + if (fs.existsSync(path.join(dir, "package.json"))) return dir; + dir = path.dirname(dir); + } + return process.cwd(); +} + +// ─── State File ─── + +interface ServerState { + pid: number; + port: number; + token: string; + startedAt: string; + serverPath: string; +} + +function readState(config: Config): ServerState | null { + try { + const raw = fs.readFileSync(config.stateFile, "utf-8"); + return JSON.parse(raw) as ServerState; + } catch { + return null; + } +} + +function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + // EPERM means the process exists but we don't have permission — still alive + if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EPERM') { + return true; + } + return false; + } +} + +// ─── Lockfile ─── + +function acquireLock(config: Config): (() => void) | null { + try { + const fd = fs.openSync( + config.lockFile, + fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY + ); + fs.writeSync(fd, `${process.pid}\n`); + fs.closeSync(fd); + return () => { + try { + fs.unlinkSync(config.lockFile); + } catch { + /* ignore */ + } + }; + } catch { + return null; + } +} + +// ─── Setup Check ─── + +interface CheckResult { + name: string; + ok: boolean; + version?: string; + error?: string; + fix?: string; +} + +function runSetupCheck(): CheckResult[] { + const results: CheckResult[] = []; + + // Java + try { + const output = execSync("java -version 2>&1", { + encoding: "utf-8", + timeout: 5000, + }); + const match = output.match(/version "(\d+)/); + const version = match ? parseInt(match[1], 10) : 0; + if (version >= 17) { + results.push({ + name: "Java 17+", + ok: true, + version: match ? match[0] : "found", + }); + } else { + results.push({ + name: "Java 17+", + ok: false, + version: `${version}`, + error: `Java ${version} found, need 17+`, + fix: "brew install openjdk@17", + }); + } + } catch { + results.push({ + name: "Java 17+", + ok: false, + error: "Java not found", + fix: "brew install openjdk@17", + }); + } + + // JAVA_HOME + if (process.env.JAVA_HOME) { + results.push({ + name: "JAVA_HOME", + ok: true, + version: process.env.JAVA_HOME, + }); + } else { + results.push({ + name: "JAVA_HOME", + ok: false, + error: "JAVA_HOME not set", + fix: 'export JAVA_HOME=$(/usr/libexec/java_home) # add to ~/.zshrc', + }); + } + + // Appium + try { + const output = execSync("appium --version", { + encoding: "utf-8", + timeout: 5000, + }); + results.push({ + name: "Appium", + ok: true, + version: output.trim(), + }); + } catch { + results.push({ + name: "Appium", + ok: false, + error: "Appium not found", + fix: "npm install -g appium", + }); + } + + // xcuitest driver + try { + const output = execSync("appium driver list --installed 2>&1", { + encoding: "utf-8", + timeout: 10000, + }); + if (output.includes("xcuitest")) { + results.push({ name: "xcuitest driver", ok: true, version: "installed" }); + } else { + results.push({ + name: "xcuitest driver", + ok: false, + error: "xcuitest driver not installed", + fix: "appium driver install xcuitest", + }); + } + } catch { + results.push({ + name: "xcuitest driver", + ok: false, + error: "Could not check Appium drivers (is Appium installed?)", + fix: "npm install -g appium && appium driver install xcuitest", + }); + } + + // Xcode CLI tools + try { + execSync("xcode-select -p", { stdio: "pipe", timeout: 5000 }); + results.push({ name: "Xcode CLI Tools", ok: true }); + } catch { + results.push({ + name: "Xcode CLI Tools", + ok: false, + error: "Xcode CLI tools not found", + fix: "xcode-select --install", + }); + } + + return results; +} + +function printSetupCheck(): void { + const results = runSetupCheck(); + const allOk = results.every((r) => r.ok); + + console.log("browse-mobile setup check\n"); + + for (const r of results) { + const status = r.ok ? "OK" : "MISSING"; + const icon = r.ok ? "+" : "x"; + let line = ` [${icon}] ${r.name}: ${status}`; + if (r.version) line += ` (${r.version})`; + if (r.error) line += ` — ${r.error}`; + console.log(line); + if (r.fix && !r.ok) { + console.log(` Fix: ${r.fix}`); + } + } + + console.log(""); + if (allOk) { + console.log("All dependencies satisfied. Ready to use browse-mobile."); + } else { + console.log( + "Some dependencies are missing. Install them and run setup-check again." + ); + process.exit(1); + } +} + +// ─── Server Lifecycle ─── + +async function startServer(config: Config, bundleId?: string): Promise { + const releaseLock = acquireLock(config); + if (!releaseLock) { + // Another process is starting the server — wait for it + const start = Date.now(); + while (Date.now() - start < config.maxStartWait) { + await new Promise((r) => setTimeout(r, 200)); + const state = readState(config); + if (state && isPidAlive(state.pid)) { + return state; + } + } + throw new Error("Timed out waiting for another process to start the server"); + } + + try { + // Ensure .gstack directory exists + const dir = path.dirname(config.stateFile); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + // Spawn ourselves in server mode (--server flag runs the server in-process) + // Use the bundled JS file (browse-mobile/dist/cli.js) with bun, or source .ts in dev + const bundlePath = path.join(path.dirname(process.argv[1] || __filename), "../dist/cli.js"); + const sourcePath = path.join(path.dirname(process.argv[1] || __filename), "cli.ts"); + + let serverCmd: string; + let serverArgs: string[]; + + if (fs.existsSync(bundlePath)) { + // Production: run the bundled JS with bun + serverCmd = "bun"; + serverArgs = ["run", bundlePath, "--server"]; + } else if (fs.existsSync(sourcePath)) { + // Dev: run source directly + serverCmd = "bun"; + serverArgs = ["run", sourcePath, "--server"]; + } else { + // Fallback: try running ourselves (compiled binary case) + serverCmd = process.argv[0]; + serverArgs = process.argv[1]?.endsWith(".ts") + ? [process.argv[1], "--server"] + : ["--server"]; + } + + const child = spawn(serverCmd, serverArgs, { + detached: true, + stdio: "ignore", + env: { + ...process.env, + BROWSE_MOBILE_STATE_FILE: config.stateFile, + ...(bundleId ? { BROWSE_MOBILE_BUNDLE_ID: bundleId } : {}), + }, + }); + child.unref(); + + // Wait for state file to appear + const start = Date.now(); + while (Date.now() - start < config.maxStartWait) { + await new Promise((r) => setTimeout(r, 100)); + const state = readState(config); + if (state && isPidAlive(state.pid)) { + return state; + } + } + + throw new Error( + `Server failed to start within ${config.maxStartWait / 1000}s. Check Appium installation: browse-mobile setup-check` + ); + } finally { + releaseLock(); + } +} + + +async function ensureServer(config: Config, bundleId?: string): Promise { + // Check existing state + const state = readState(config); + if (state && isPidAlive(state.pid)) { + // Health check + try { + const res = await fetch(`http://127.0.0.1:${state.port}/health`, { + signal: AbortSignal.timeout(2000), + }); + const data = (await res.json()) as { status: string }; + if (data.status === "healthy" || data.status === "unhealthy") { + return state; // Server is responding (may be waiting for Appium) + } + } catch { + // Server not responding — kill and restart + try { + process.kill(state.pid, "SIGTERM"); + } catch { + /* already dead */ + } + } + } + + return startServer(config, bundleId); +} + +async function sendCommand( + state: ServerState, + command: string, + args: string[] +): Promise { + try { + const res = await fetch(`http://127.0.0.1:${state.port}/command`, { + method: "POST", + headers: { + Authorization: `Bearer ${state.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ command, args }), + // First command (goto) may trigger Appium connection + WDA build (~60-120s) + signal: AbortSignal.timeout(command === "goto" ? 180000 : 60000), + }); + + if (res.status === 401) { + console.error("Auth failed — server may have restarted. Try again."); + process.exit(1); + } + + const text = await res.text(); + + if (res.ok) { + console.log(text); + } else { + try { + const err = JSON.parse(text) as { error: string; hint?: string }; + console.error(`Error: ${err.error}`); + if (err.hint) console.error(`Hint: ${err.hint}`); + } catch { + console.error(text); + } + process.exit(1); + } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + console.error("Command timed out after 30s"); + } else { + console.error( + `Connection failed: ${err instanceof Error ? err.message : String(err)}` + ); + } + process.exit(1); + } +} + +// ─── Main ─── + +async function main(): Promise { + const args = process.argv.slice(2); + + // Handle --server mode: run the server in-process (used when binary spawns itself) + if (args[0] === "--server") { + await import("./server"); + return; + } + + // Handle setup-check subcommand + if (args[0] === "setup-check") { + printSetupCheck(); + return; + } + + // Handle help + if (args.length === 0 || args[0] === "help" || args[0] === "--help") { + console.log(`browse-mobile — Appium-backed mobile automation for gstack + +Usage: + browse-mobile [args...] + browse-mobile setup-check Check dependencies + +Commands: + goto Launch app or deep link + click <@e1> Tap element by ref + click label:Sign In Tap by accessibility label + tap Tap at coordinates + fill <@e1> Type into input field + snapshot [-i] [-D] [-a] Get accessibility tree with refs + screenshot Save screenshot + text Extract visible text + scroll [up|down|left|right] + back Device back button + viewport + links List tappable elements + forms List input fields + status Server status + stop Stop server + +Examples: + browse-mobile goto app://com.example.myapp + browse-mobile snapshot -i + browse-mobile click @e3 + browse-mobile click label:Sign In + browse-mobile tap 195 750 + browse-mobile screenshot /tmp/screen.png`); + return; + } + + const config = getConfig(); + const command = args[0]; + const commandArgs = args.slice(1); + + // Extract bundle ID from goto app:// commands to pass to server + let bundleId = process.env.BROWSE_MOBILE_BUNDLE_ID || ""; + if (command === "goto" && commandArgs[0]?.startsWith("app://")) { + bundleId = commandArgs[0].replace("app://", ""); + } + + const state = await ensureServer(config, bundleId); + await sendCommand(state, command, commandArgs); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/browse-mobile/src/mobile-driver.ts b/browse-mobile/src/mobile-driver.ts new file mode 100644 index 000000000..557772727 --- /dev/null +++ b/browse-mobile/src/mobile-driver.ts @@ -0,0 +1,504 @@ +/** + * MobileDriver — Pure HTTP Appium client (zero npm dependencies) + * + * Uses the W3C WebDriver protocol directly via fetch() instead of webdriverio. + * This avoids bundling issues with webdriverio's transitive dependencies. + */ + +import { parseXmlToRefs, resolveRef, snapshotDiff, type MobileRefEntry } from "./ref-system"; +import { ensureBootedSimulator } from "./platform/ios"; +import * as fs from "fs"; +import * as path from "path"; + +const APPIUM_BASE = "http://127.0.0.1:4723"; +const REQUEST_TIMEOUT = 30000; // 30s per command +const SESSION_TIMEOUT = 180000; // 3 min for session creation (WDA build) + +export interface MobileDriverOptions { + bundleId: string; + appPath?: string; + automationName?: string; + platformVersion?: string; + deviceName?: string; +} + +// ─── Raw Appium HTTP Client ─── + +async function appiumPost( + sessionId: string, + endpoint: string, + body?: Record, + timeout = REQUEST_TIMEOUT, +): Promise { + const url = `${APPIUM_BASE}/session/${sessionId}${endpoint}`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : "{}", + signal: AbortSignal.timeout(timeout), + }); + const data = (await res.json()) as { value: unknown }; + if (!res.ok) { + const err = data.value as { message?: string } | string; + const msg = typeof err === "string" ? err : (err as { message?: string })?.message || JSON.stringify(err); + throw new Error(`Appium error: ${msg}`); + } + return data.value; +} + +async function appiumGet( + sessionId: string, + endpoint: string, + timeout = REQUEST_TIMEOUT, +): Promise { + const url = `${APPIUM_BASE}/session/${sessionId}${endpoint}`; + const res = await fetch(url, { + signal: AbortSignal.timeout(timeout), + }); + const data = (await res.json()) as { value: unknown }; + if (!res.ok) { + const err = data.value as { message?: string } | string; + const msg = typeof err === "string" ? err : (err as { message?: string })?.message || JSON.stringify(err); + throw new Error(`Appium error: ${msg}`); + } + return data.value; +} + +async function appiumDelete( + sessionId: string, + timeout = REQUEST_TIMEOUT, +): Promise { + const url = `${APPIUM_BASE}/session/${sessionId}`; + await fetch(url, { + method: "DELETE", + signal: AbortSignal.timeout(timeout), + }); +} + +// Find element helper — returns element ID or null +// Returns null only for "element not found" (W3C NoSuchElement); rethrows other errors +async function findElement( + sessionId: string, + using: string, + value: string, +): Promise { + try { + const result = (await appiumPost(sessionId, "/element", { using, value })) as Record; + // W3C returns { "element-xxx": "id" } or { ELEMENT: "id" } + return result["element-6066-11e4-a52e-4f735466cecf"] || result["ELEMENT"] || Object.values(result)[0] || null; + } catch (err) { + // W3C "no such element" is expected — return null + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("no such element") || msg.includes("NoSuchElement") || msg.includes("unable to find")) { + return null; + } + // Rethrow unexpected errors (network, timeout, invalid session) + throw err; + } +} + +// ─── Pointer Action Helpers ─── + +function tapAction(x: number, y: number) { + return { + actions: [{ + type: "pointer", id: "finger1", + parameters: { pointerType: "touch" }, + actions: [ + { type: "pointerMove", duration: 0, x: Math.round(x), y: Math.round(y) }, + { type: "pointerDown", button: 0 }, + { type: "pointerUp", button: 0 }, + ], + }], + }; +} + +function swipeAction(startX: number, startY: number, endX: number, endY: number, durationMs = 300) { + return { + actions: [{ + type: "pointer", id: "finger1", + parameters: { pointerType: "touch" }, + actions: [ + { type: "pointerMove", duration: 0, x: startX, y: startY }, + { type: "pointerDown", button: 0 }, + { type: "pointerMove", duration: durationMs, x: endX, y: endY }, + { type: "pointerUp", button: 0 }, + ], + }], + }; +} + +// ─── MobileDriver ─── + +export class MobileDriver { + private sessionId: string | null = null; + private refs: Map = new Map(); + private lastSnapshot: string | null = null; + private options: MobileDriverOptions; + private _isConnected = false; + + constructor(options: MobileDriverOptions) { + this.options = options; + } + + async connect(): Promise { + const sim = ensureBootedSimulator(); + if (!sim) { + throw new Error("No iOS Simulator available. Run: xcrun simctl list devices available"); + } + + const capabilities: Record = { + platformName: "iOS", + "appium:automationName": this.options.automationName || "XCUITest", + "appium:deviceName": this.options.deviceName || sim.name, + "appium:udid": sim.udid, + "appium:bundleId": this.options.bundleId, + "appium:autoAcceptAlerts": true, + "appium:noReset": true, + "appium:newCommandTimeout": 1800, + "appium:wdaLaunchTimeout": 120000, + "appium:wdaConnectionTimeout": 120000, + }; + + if (this.options.appPath) { + capabilities["appium:app"] = this.options.appPath; + } + if (this.options.platformVersion) { + capabilities["appium:platformVersion"] = this.options.platformVersion; + } + + // Create session via raw HTTP (long timeout for WDA compilation) + const res = await fetch(`${APPIUM_BASE}/session`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + capabilities: { alwaysMatch: capabilities, firstMatch: [{}] }, + }), + signal: AbortSignal.timeout(SESSION_TIMEOUT), + }); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(`Appium session creation failed (${res.status}): ${errText}`); + } + + const data = (await res.json()) as { + value: { sessionId: string; capabilities: Record }; + }; + this.sessionId = data.value.sessionId; + this._isConnected = true; + } + + async disconnect(): Promise { + if (this.sessionId) { + try { + await appiumDelete(this.sessionId); + } catch { /* session may already be dead */ } + this.sessionId = null; + } + this._isConnected = false; + this.refs.clear(); + this.lastSnapshot = null; + } + + get isConnected(): boolean { + return this._isConnected && this.sessionId !== null; + } + + async isHealthy(): Promise { + if (!this.sessionId) return false; + try { + await appiumGet(this.sessionId, "/source", 5000); + return true; + } catch { + return false; + } + } + + private ensureSession(): string { + if (!this.sessionId) { + throw new Error("Not connected to Appium. Call connect() first."); + } + return this.sessionId; + } + + // ─── Ref Map ─── + + setRefMap(refs: Map): void { this.refs = refs; } + getRefCount(): number { return this.refs.size; } + clearRefs(): void { this.refs.clear(); } + setLastSnapshot(text: string | null): void { this.lastSnapshot = text; } + getLastSnapshot(): string | null { return this.lastSnapshot; } + + // ─── Commands ─── + + async goto(target: string): Promise { + const sid = this.ensureSession(); + + if (target.startsWith("app://")) { + const bundleId = target.replace("app://", ""); + try { + await appiumPost(sid, "/execute/sync", { + script: "mobile: terminateApp", + args: [{ bundleId }], + }); + } catch { /* app may not be running */ } + await appiumPost(sid, "/execute/sync", { + script: "mobile: launchApp", + args: [{ bundleId }], + }); + return `Launched ${bundleId}`; + } + + // Deep link + try { + await appiumPost(sid, "/url", { url: target }); + return `Navigated to ${target}`; + } catch (err) { + return `Deep link failed: ${err instanceof Error ? err.message : String(err)}. Navigate manually via click commands.`; + } + } + + async click(refOrSelector: string): Promise { + const sid = this.ensureSession(); + + if (refOrSelector.startsWith("@")) { + const finder = async (strategy: string, selector: string) => { + const using = strategy === "accessibility id" ? "accessibility id" : "xpath"; + return findElement(sid, using, selector); + }; + + let result = await resolveRef(refOrSelector, this.refs, finder); + + if (!result) { + // Auto-refresh snapshot and retry + await this.snapshot([]); + result = await resolveRef(refOrSelector, this.refs, finder); + if (!result) { + throw new Error(`Element ${refOrSelector} no longer exists — screen may have navigated`); + } + } + + return this.performClick(sid, result); + } + + // Direct selector: try as accessibility label + // Supports both ~Label and label:Label syntax (label: preferred to avoid shell ~ expansion) + const labelMatch = refOrSelector.match(/^(?:~|label:)(.+)$/); + if (labelMatch) { + const label = labelMatch[1].replace(/^["']|["']$/g, ""); // strip quotes + const elementId = await findElement(sid, "accessibility id", label); + if (elementId) { + await appiumPost(sid, `/element/${elementId}/click`); + return `Clicked label:${label}`; + } + throw new Error(`Element with accessibility label "${label}" not found`); + } + + const elementId = await findElement(sid, "xpath", refOrSelector); + if (elementId) { + await appiumPost(sid, `/element/${elementId}/click`); + return `Clicked ${refOrSelector}`; + } + throw new Error(`Element not found: ${refOrSelector}`); + } + + private async performClick( + sid: string, + result: { element: unknown; usedCoordinates: boolean }, + ): Promise { + if (result.usedCoordinates) { + const coords = result.element as { x: number; y: number }; + await appiumPost(sid, "/actions", tapAction(coords.x, coords.y)); + return `Tapped at (${Math.round(coords.x)}, ${Math.round(coords.y)}) — coordinate fallback. Consider adding accessibilityLabel.`; + } + + const elementId = result.element as string; + await appiumPost(sid, `/element/${elementId}/click`); + + const refKey = [...this.refs.entries()].find(([, e]) => e.label); + const label = refKey ? ` (${refKey[1].elementType.replace("XCUIElementType", "")}: "${refKey[1].label}")` : ""; + return `Clicked${label}`; + } + + async tapCoordinates(x: number, y: number): Promise { + const sid = this.ensureSession(); + await appiumPost(sid, "/actions", tapAction(x, y)); + return `Tapped at (${x}, ${y})`; + } + + async fill(refOrSelector: string, text: string): Promise { + const sid = this.ensureSession(); + + if (refOrSelector.startsWith("@")) { + const finder = async (strategy: string, selector: string) => { + const using = strategy === "accessibility id" ? "accessibility id" : "xpath"; + return findElement(sid, using, selector); + }; + + const result = await resolveRef(refOrSelector, this.refs, finder); + if (!result) { + throw new Error(`Cannot fill ${refOrSelector} — element not found`); + } + + if (result.usedCoordinates) { + // Tap to focus, then type via keyboard actions + const coords = result.element as { x: number; y: number }; + await appiumPost(sid, "/actions", tapAction(coords.x, coords.y)); + await new Promise((r) => setTimeout(r, 500)); + // Type via key actions + const keyActions: Array<{ type: string; value?: string }> = []; + for (const char of text) { + keyActions.push({ type: "keyDown", value: char }); + keyActions.push({ type: "keyUp", value: char }); + } + await appiumPost(sid, "/actions", { + actions: [{ type: "key", id: "keyboard", actions: keyActions }], + }); + return `Filled ${refOrSelector} with "${text}" (via coordinate tap + keyboard)`; + } + + const elementId = result.element as string; + await appiumPost(sid, `/element/${elementId}/clear`); + await appiumPost(sid, `/element/${elementId}/value`, { text }); + return `Filled ${refOrSelector} with "${text}"`; + } + + // Direct selector + const elementId = await findElement(sid, "accessibility id", refOrSelector.replace(/^~/, "")); + if (!elementId) throw new Error(`Element not found: ${refOrSelector}`); + await appiumPost(sid, `/element/${elementId}/clear`); + await appiumPost(sid, `/element/${elementId}/value`, { text }); + return `Filled ${refOrSelector} with "${text}"`; + } + + async screenshot(outputPath: string): Promise { + const sid = this.ensureSession(); + const base64 = (await appiumGet(sid, "/screenshot")) as string; + const buffer = Buffer.from(base64, "base64"); + + try { + const dir = path.dirname(outputPath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(outputPath, buffer); + } catch (err) { + throw new Error(`Screenshot save failed: ${err instanceof Error ? err.message : String(err)}. Disk may be full.`); + } + + return `Screenshot saved to ${outputPath} (${buffer.length} bytes)`; + } + + async snapshot(flags: string[]): Promise { + const sid = this.ensureSession(); + const xml = (await appiumGet(sid, "/source")) as string; + const result = parseXmlToRefs(xml); + + this.refs = result.refs; + const isDiff = flags.includes("-D") || flags.includes("--diff"); + const isAnnotate = flags.includes("-a") || flags.includes("--annotate"); + + let output = result.text; + if (isDiff) output = snapshotDiff(this.lastSnapshot, result.text); + this.lastSnapshot = result.text; + + if (isAnnotate) { + const outputIdx = flags.indexOf("-o"); + const longOutputIdx = flags.indexOf("--output"); + const pathIdx = outputIdx >= 0 ? outputIdx + 1 : longOutputIdx >= 0 ? longOutputIdx + 1 : -1; + if (pathIdx >= 0 && pathIdx < flags.length) { + await this.screenshot(flags[pathIdx]); + output += `\n\nAnnotated screenshot saved (note: mobile screenshots do not have overlay boxes)`; + } + } + + return output; + } + + async text(): Promise { + const sid = this.ensureSession(); + const xml = (await appiumGet(sid, "/source")) as string; + const labels: string[] = []; + const labelRegex = /\blabel="([^"]*)"/g; + const valueRegex = /\bvalue="([^"]*)"/g; + + let match: RegExpExecArray | null; + while ((match = labelRegex.exec(xml)) !== null) { + if (match[1].trim()) labels.push(match[1].trim()); + } + while ((match = valueRegex.exec(xml)) !== null) { + if (match[1].trim()) labels.push(match[1].trim()); + } + + const seen = new Set(); + const unique = labels.filter((l) => { if (seen.has(l)) return false; seen.add(l); return true; }); + return unique.join("\n") || "(no visible text)"; + } + + async scroll(direction: string): Promise { + const sid = this.ensureSession(); + let startX = 200, startY = 400, endX = 200, endY = 400; + + switch (direction.toLowerCase()) { + case "down": startY = 500; endY = 200; break; + case "up": startY = 200; endY = 500; break; + case "left": startX = 300; endX = 50; break; + case "right": startX = 50; endX = 300; break; + default: startY = 500; endY = 200; + } + + await appiumPost(sid, "/actions", swipeAction(startX, startY, endX, endY)); + return `Scrolled ${direction || "down"}`; + } + + async back(): Promise { + const sid = this.ensureSession(); + await appiumPost(sid, "/back"); + return "Navigated back"; + } + + async viewport(size: string): Promise { + const sid = this.ensureSession(); + if (size.toLowerCase() === "landscape" || size.toLowerCase() === "portrait") { + const orientation = size.toLowerCase() === "landscape" ? "LANDSCAPE" : "PORTRAIT"; + await appiumPost(sid, "/orientation", { orientation }); + return `Set orientation to ${orientation}`; + } + return `Viewport size change not supported mid-session. Use: "landscape" or "portrait"`; + } + + async links(): Promise { + if (this.refs.size === 0) return "(no tappable elements — run snapshot first)"; + const lines: string[] = []; + for (const [key, entry] of this.refs) { + const type = entry.elementType.replace("XCUIElementType", ""); + const label = entry.label ? ` "${entry.label}"` : ""; + lines.push(`@${key} ${type}${label}`); + } + return lines.join("\n") || "(no tappable elements)"; + } + + async forms(): Promise { + const inputTypes = new Set(["XCUIElementTypeTextField", "XCUIElementTypeSecureTextField", "XCUIElementTypeSearchField", "XCUIElementTypeTextView"]); + const lines: string[] = []; + for (const [key, entry] of this.refs) { + if (inputTypes.has(entry.elementType)) { + const type = entry.elementType.replace("XCUIElementType", ""); + const label = entry.label ? ` "${entry.label}"` : ""; + lines.push(`@${key} ${type}${label}`); + } + } + return lines.join("\n") || "(no input fields found)"; + } + + async dialogAccept(): Promise { + const sid = this.ensureSession(); + try { await appiumPost(sid, "/alert/accept"); return "Alert accepted"; } + catch { return "No alert to accept"; } + } + + async dialogDismiss(): Promise { + const sid = this.ensureSession(); + try { await appiumPost(sid, "/alert/dismiss"); return "Alert dismissed"; } + catch { return "No alert to dismiss"; } + } +} diff --git a/browse-mobile/src/platform/ios.ts b/browse-mobile/src/platform/ios.ts new file mode 100644 index 000000000..5ad48dae1 --- /dev/null +++ b/browse-mobile/src/platform/ios.ts @@ -0,0 +1,163 @@ +/** + * iOS Simulator platform utilities + */ + +import { execSync } from "child_process"; + +/** Validate a string is a safe shell argument (no injection) */ +function assertSafeShellArg(value: string, name: string): void { + if (/[;&|`$"'\\<>(){}\n\r]/.test(value)) { + throw new Error(`Unsafe ${name}: contains shell metacharacters`); + } +} + +export interface SimulatorDevice { + udid: string; + name: string; + state: "Booted" | "Shutdown"; + runtime: string; +} + +/** + * List available iOS Simulator devices + */ +export function listDevices(): SimulatorDevice[] { + try { + const output = execSync("xcrun simctl list devices available -j", { + encoding: "utf-8", + timeout: 10000, + }); + const data = JSON.parse(output); + const devices: SimulatorDevice[] = []; + + for (const [runtime, devs] of Object.entries(data.devices || {})) { + if (!Array.isArray(devs)) continue; + for (const dev of devs as Array<{ + udid: string; + name: string; + state: string; + }>) { + devices.push({ + udid: dev.udid, + name: dev.name, + state: dev.state as SimulatorDevice["state"], + runtime: runtime.replace( + /^com\.apple\.CoreSimulator\.SimRuntime\./, + "" + ), + }); + } + } + + return devices; + } catch { + return []; + } +} + +/** + * Get the first booted simulator, or boot one if none are running + */ +export function ensureBootedSimulator(): SimulatorDevice | null { + const devices = listDevices(); + + // Prefer already booted + const booted = devices.find((d) => d.state === "Booted"); + if (booted) return booted; + + // Find an iPhone to boot (prefer recent models) + const iphones = devices + .filter((d) => d.name.includes("iPhone") && d.state === "Shutdown") + .sort((a, b) => { + // Sort by name descending to get newest models first + return b.name.localeCompare(a.name); + }); + + const target = iphones[0] || devices[0]; + if (!target) return null; + + try { + assertSafeShellArg(target.udid, "simulator UDID"); + execSync(`xcrun simctl boot "${target.udid}"`, { + timeout: 30000, + stdio: "pipe", + }); + return { ...target, state: "Booted" }; + } catch (err) { + // May already be booted + const msg = + err instanceof Error ? err.message : String(err); + if (msg.includes("current state: Booted")) { + return { ...target, state: "Booted" }; + } + return null; + } +} + +/** + * Get the bundle ID from an Expo app.json or app.config.js + */ +export function detectBundleId(projectDir: string): string | null { + try { + // Try app.json first + const { readFileSync } = require("fs"); + const { join } = require("path"); + + for (const configFile of ["app.json", "app.config.json"]) { + try { + const raw = readFileSync(join(projectDir, configFile), "utf-8"); + const config = JSON.parse(raw); + const expo = config.expo || config; + return expo.ios?.bundleIdentifier || expo.slug || null; + } catch { + continue; + } + } + } catch { + // Ignore + } + return null; +} + +/** + * Check if Xcode command line tools are available + */ +export function hasXcodeTools(): boolean { + try { + execSync("xcode-select -p", { stdio: "pipe", timeout: 5000 }); + return true; + } catch { + return false; + } +} + +/** + * Terminate an app on the simulator + */ +export function terminateApp(bundleId: string): void { + try { + assertSafeShellArg(bundleId, "bundle ID"); + execSync(`xcrun simctl terminate booted "${bundleId}"`, { + stdio: "pipe", + timeout: 10000, + }); + } catch { + // App may not be running + } +} + +/** + * Shutdown the simulator + */ +export function shutdownSimulator(udid?: string): void { + try { + const target = udid || "booted"; + if (udid) assertSafeShellArg(udid, "simulator UDID"); + execSync(`xcrun simctl shutdown "${target}"`, { + stdio: "pipe", + timeout: 15000, + }); + } catch { + // May already be shutdown + } +} diff --git a/browse-mobile/src/ref-system.ts b/browse-mobile/src/ref-system.ts new file mode 100644 index 000000000..904670217 --- /dev/null +++ b/browse-mobile/src/ref-system.ts @@ -0,0 +1,332 @@ +/** + * Mobile Ref System — Parse Appium getPageSource() XML into @e1, @e2 refs + * + * Resolution priority: testID > accessibilityLabel > XPath > coordinate tap + */ + +import { XMLParser } from "fast-xml-parser"; + +export interface MobileRefEntry { + xpath: string; + bounds: { x: number; y: number; width: number; height: number } | null; + label: string | null; + testID: string | null; + elementType: string; + // For resolveRef: which strategy to use + resolveStrategy: "testID" | "accessibilityLabel" | "xpath"; +} + +export interface ParseResult { + refs: Map; + text: string; +} + +// Interactive element types for iOS (XCUITest) +const IOS_INTERACTIVE_TYPES = new Set([ + "XCUIElementTypeButton", + "XCUIElementTypeTextField", + "XCUIElementTypeSecureTextField", + "XCUIElementTypeSwitch", + "XCUIElementTypeSlider", + "XCUIElementTypeLink", + "XCUIElementTypeSearchField", + "XCUIElementTypeTextView", + "XCUIElementTypeCell", + "XCUIElementTypeImage", // Often tappable in RN + "XCUIElementTypeSegmentedControl", + "XCUIElementTypePicker", + "XCUIElementTypePickerWheel", + "XCUIElementTypeStepper", + "XCUIElementTypePageIndicator", + "XCUIElementTypeTab", + "XCUIElementTypeTabBar", +]); + +// Element types that are always non-interactive wrappers +const IOS_WRAPPER_TYPES = new Set([ + "XCUIElementTypeApplication", + "XCUIElementTypeWindow", + "XCUIElementTypeOther", + "XCUIElementTypeGroup", + "XCUIElementTypeScrollView", + "XCUIElementTypeTable", + "XCUIElementTypeCollectionView", + "XCUIElementTypeNavigationBar", + "XCUIElementTypeToolbar", + "XCUIElementTypeStatusBar", + "XCUIElementTypeKeyboard", +]); + +function parseBounds( + attrs: Record +): MobileRefEntry["bounds"] { + const x = parseInt(attrs.x, 10); + const y = parseInt(attrs.y, 10); + const width = parseInt(attrs.width, 10); + const height = parseInt(attrs.height, 10); + if ([x, y, width, height].some((v) => isNaN(v))) return null; + return { x, y, width, height }; +} + +function isInteractive(type: string, attrs: Record): boolean { + if (IOS_INTERACTIVE_TYPES.has(type)) return true; + + if (type === "XCUIElementTypeStaticText") { + const accessible = attrs.accessible; + if (accessible === "true" && attrs.label) return true; + } + + return false; +} + +interface WalkContext { + refs: Map; + lines: string[]; + counter: number; + xpathParts: string[]; + depth: number; + maxDepth: number; +} + +/** + * With preserveOrder:true, fast-xml-parser returns: + * [ { "TagName": [ ...children... ], ":@": { attr1: "v1", ... } }, ... ] + * + * Each array element is a node with one tag key + optional ":@" for attributes. + */ +function getTagAndChildren(node: Record): { tag: string; children: unknown[]; attrs: Record } | null { + const attrs = (node[":@"] || {}) as Record; + for (const key of Object.keys(node)) { + if (key === ":@" || key === "#text" || key === "?xml") continue; + const children = node[key]; + return { + tag: key, + children: Array.isArray(children) ? children : [], + attrs, + }; + } + return null; +} + +function walkNode(node: Record, ctx: WalkContext): void { + if (ctx.depth > ctx.maxDepth) return; + + const parsed = getTagAndChildren(node); + if (!parsed) return; + + const { tag: type, children, attrs } = parsed; + + // Only process XCUIElementType nodes (or AppiumAUT root) + if (type === "AppiumAUT") { + for (const child of children) { + if (typeof child === "object" && child !== null) { + walkNode(child as Record, ctx); + } + } + return; + } + + if (!type.startsWith("XCUIElementType")) return; + + const label = attrs.label || null; + const visible = attrs.visible !== "false"; + + if (!visible) return; + + ctx.xpathParts.push(`${type}[${attrs.index || "0"}]`); + const xpath = "//" + ctx.xpathParts.join("/"); + const indent = " ".repeat(ctx.depth); + + if (isInteractive(type, attrs)) { + ctx.counter++; + const refKey = `e${ctx.counter}`; + const friendlyType = type.replace("XCUIElementType", ""); + + let resolveStrategy: MobileRefEntry["resolveStrategy"] = "xpath"; + if (attrs.testID) { + resolveStrategy = "testID"; + } else if (label) { + resolveStrategy = "accessibilityLabel"; + } + + ctx.refs.set(refKey, { + xpath, + bounds: parseBounds(attrs), + label, + testID: attrs.testID || null, + elementType: type, + resolveStrategy, + }); + + const displayLabel = label ? ` "${label}"` : ""; + ctx.lines.push(`${indent}@${refKey} ${friendlyType}${displayLabel}`); + } else if (!IOS_WRAPPER_TYPES.has(type)) { + if (label) { + const friendlyType = type.replace("XCUIElementType", ""); + ctx.lines.push(`${indent}${friendlyType}: "${label}"`); + } + } + + // Walk children + ctx.depth++; + for (const child of children) { + if (typeof child === "object" && child !== null) { + walkNode(child as Record, ctx); + } + } + ctx.depth--; + + ctx.xpathParts.pop(); +} + +/** + * Parse Appium getPageSource() XML into refs and formatted text + */ +export function parseXmlToRefs(xml: string): ParseResult { + if (!xml || xml.trim().length === 0) { + return { refs: new Map(), text: "(empty screen)" }; + } + + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "", + preserveOrder: true, + allowBooleanAttributes: true, + }); + + let parsed: unknown[]; + try { + parsed = parser.parse(xml); + } catch (err) { + return { + refs: new Map(), + text: `(error parsing accessibility tree: ${err instanceof Error ? err.message : String(err)})`, + }; + } + + const ctx: WalkContext = { + refs: new Map(), + lines: [], + counter: 0, + xpathParts: [], + depth: 0, + maxDepth: 150, + }; + + // Walk all root nodes + if (Array.isArray(parsed)) { + for (const node of parsed) { + if (typeof node === "object" && node !== null) { + walkNode(node as Record, ctx); + } + } + } + + const summary = `${ctx.refs.size} interactive element${ctx.refs.size !== 1 ? "s" : ""} found`; + const text = ctx.lines.length > 0 + ? `${summary}\n\n${ctx.lines.join("\n")}` + : `${summary}\n\n(no interactive elements on this screen)`; + + return { refs: ctx.refs, text }; +} + +/** + * Resolve a ref like "@e3" to a WebDriverIO element using the stored resolution strategy. + * + * This function is called by MobileDriver and uses the driver to find elements. + * It implements the 3-step staleness recovery: + * 1. Try primary strategy (testID/label/xpath) + * 2. If not found, try by label + * 3. If still not found, return null (caller should auto-refresh snapshot) + */ +export async function resolveRef( + ref: string, + refs: Map, + findElement: (strategy: string, selector: string) => Promise, +): Promise<{ element: unknown; usedCoordinates: boolean } | null> { + const key = ref.startsWith("@") ? ref.slice(1) : ref; + const entry = refs.get(key); + + if (!entry) { + return null; + } + + // Step 1: Try primary resolution strategy + let element: unknown | null = null; + + if (entry.resolveStrategy === "testID" && entry.testID) { + element = await findElement("accessibility id", entry.testID); + } else if (entry.resolveStrategy === "accessibilityLabel" && entry.label) { + element = await findElement("accessibility id", entry.label); + } + + if (!element) { + // Step 2: Try XPath + element = await findElement("xpath", entry.xpath); + } + + if (element) { + return { element, usedCoordinates: false }; + } + + // Step 3: Try label as fallback if we haven't already + if (entry.label && entry.resolveStrategy !== "accessibilityLabel") { + element = await findElement("accessibility id", entry.label); + if (element) { + return { element, usedCoordinates: false }; + } + } + + // Step 4: Coordinate fallback if we have bounds + if (entry.bounds) { + return { + element: { + _coordinateTap: true, + x: entry.bounds.x + entry.bounds.width / 2, + y: entry.bounds.y + entry.bounds.height / 2, + }, + usedCoordinates: true, + }; + } + + return null; +} + +/** + * Format a snapshot diff between two snapshot texts + */ +export function snapshotDiff( + previous: string | null, + current: string, +): string { + if (!previous) { + return current + "\n\n(no previous snapshot to diff against)"; + } + + const prevLines = previous.split("\n"); + const currLines = current.split("\n"); + const result: string[] = []; + + // Simple line-by-line diff + const maxLen = Math.max(prevLines.length, currLines.length); + let hasChanges = false; + + for (let i = 0; i < maxLen; i++) { + const prev = prevLines[i] || ""; + const curr = currLines[i] || ""; + + if (prev === curr) { + result.push(` ${curr}`); + } else { + hasChanges = true; + if (prev) result.push(`- ${prev}`); + if (curr) result.push(`+ ${curr}`); + } + } + + if (!hasChanges) { + return "(no changes since last snapshot)"; + } + + return result.join("\n"); +} diff --git a/browse-mobile/src/server.ts b/browse-mobile/src/server.ts new file mode 100644 index 000000000..6de010644 --- /dev/null +++ b/browse-mobile/src/server.ts @@ -0,0 +1,346 @@ +/** + * browse-mobile HTTP server + * + * Same protocol as browse/src/server.ts but backed by MobileDriver (Appium) + * instead of BrowserManager (Playwright). + */ + +import { MobileDriver, type MobileDriverOptions } from "./mobile-driver"; +import * as fs from "fs"; +import * as path from "path"; +import * as crypto from "crypto"; + +// ─── Configuration ─── + +const TOKEN = crypto.randomUUID(); +const STATE_FILE = process.env.BROWSE_MOBILE_STATE_FILE || ".gstack/browse-mobile.json"; +const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_MOBILE_IDLE_TIMEOUT || "1800000", 10); // 30 min + +// ─── State ─── + +let mobileDriver: MobileDriver | null = null; +let lastActivity = Date.now(); +let idleTimer: ReturnType | null = null; +let commandQueue: Promise = Promise.resolve(); + +// ─── Supported Commands ─── + +const READ_COMMANDS = new Set([ + "text", "links", "forms", "snapshot", +]); + +const WRITE_COMMANDS = new Set([ + "goto", "click", "tap", "fill", "scroll", "back", "viewport", + "dialog-accept", "dialog-dismiss", +]); + +const META_COMMANDS = new Set([ + "screenshot", "status", "stop", +]); + +const UNSUPPORTED_COMMANDS = new Set([ + "html", "css", "attrs", "js", "eval", "accessibility", + "console", "network", "cookies", "storage", "perf", "dialog", "is", + "forward", "reload", "select", "hover", "type", "press", "wait", + "cookie", "cookie-import", "cookie-import-browser", + "header", "useragent", "upload", + "tabs", "tab", "newtab", "closetab", + "pdf", "responsive", "chain", "diff", "url", + "handoff", "resume", +]); + +// ─── Command Handler ─── + +async function handleCommand( + command: string, + args: string[] +): Promise { + if (!mobileDriver) { + throw new Error("MobileDriver not initialized"); + } + + // Auto-reconnect if initial connection failed or session died + if (!mobileDriver.isConnected) { + console.error("[browse-mobile] Not connected — attempting to connect to Appium..."); + await mobileDriver.connect(); + console.error("[browse-mobile] Connected to Appium (reconnect)"); + } + + if (UNSUPPORTED_COMMANDS.has(command)) { + return JSON.stringify({ + error: "not_supported", + message: `Command '${command}' is not supported in mobile mode.`, + supported: false, + }); + } + + switch (command) { + // ─── Read Commands ─── + case "text": + return mobileDriver.text(); + + case "links": + return mobileDriver.links(); + + case "forms": + return mobileDriver.forms(); + + case "snapshot": + return mobileDriver.snapshot(args); + + // ─── Write Commands ─── + case "goto": + if (args.length === 0) throw new Error("goto requires a target (e.g., app://com.example.app)"); + return mobileDriver.goto(args[0]); + + case "click": + if (args.length === 0) throw new Error("click requires a ref (e.g., @e1) or label:Text"); + return mobileDriver.click(args[0]); + + case "tap": { + if (args.length < 2) throw new Error("tap requires x y coordinates (e.g., tap 195 750)"); + const tapX = parseInt(args[0], 10); + const tapY = parseInt(args[1], 10); + if (isNaN(tapX) || isNaN(tapY)) throw new Error(`Invalid coordinates: "${args[0]}" "${args[1]}" — must be numbers`); + return mobileDriver.tapCoordinates(tapX, tapY); + } + + case "fill": + if (args.length < 2) throw new Error("fill requires a ref and text (e.g., @e1 \"hello\")"); + return mobileDriver.fill(args[0], args.slice(1).join(" ")); + + case "scroll": + return mobileDriver.scroll(args[0] || "down"); + + case "back": + return mobileDriver.back(); + + case "viewport": + if (args.length === 0) throw new Error("viewport requires a size (e.g., landscape, portrait)"); + return mobileDriver.viewport(args[0]); + + case "dialog-accept": + return mobileDriver.dialogAccept(); + + case "dialog-dismiss": + return mobileDriver.dialogDismiss(); + + // ─── Meta Commands ─── + case "screenshot": { + const outputPath = args[0] || "/tmp/browse-mobile-screenshot.png"; + return mobileDriver.screenshot(outputPath); + } + + case "status": + return JSON.stringify({ + connected: mobileDriver.isConnected, + refs: mobileDriver.getRefCount(), + uptime: Math.floor((Date.now() - startTime) / 1000), + }); + + case "stop": + await shutdown(); + return "Server stopped"; + + default: + throw new Error(`Unknown command: ${command}`); + } +} + +// ─── HTTP Server ─── + +const startTime = Date.now(); + +async function findAvailablePort(): Promise { + const explicit = process.env.BROWSE_MOBILE_PORT; + if (explicit) return parseInt(explicit, 10); + + for (let attempt = 0; attempt < 5; attempt++) { + const port = 10000 + Math.floor(Math.random() * 50000); + try { + const test = Bun.serve({ port, fetch: () => new Response("ok") }); + test.stop(true); + return port; + } catch { + continue; + } + } + throw new Error("Could not find available port after 5 attempts"); +} + +async function shutdown(): Promise { + if (idleTimer) { + clearInterval(idleTimer); + idleTimer = null; + } + + if (mobileDriver) { + try { + await mobileDriver.disconnect(); + } catch { + // Best effort + } + mobileDriver = null; + } + + // Remove state file + try { + fs.unlinkSync(STATE_FILE); + } catch { + // May not exist + } + + process.exit(0); +} + +async function startServer(): Promise { + const port = await findAvailablePort(); + + const server = Bun.serve({ + port, + async fetch(req) { + const url = new URL(req.url); + + // Health check — no auth required + if (url.pathname === "/health") { + const healthy = mobileDriver ? await mobileDriver.isHealthy() : false; + return Response.json({ + status: healthy ? "healthy" : "unhealthy", + uptime: Math.floor((Date.now() - startTime) / 1000), + refs: mobileDriver?.getRefCount() || 0, + }); + } + + // All other routes require auth + const auth = req.headers.get("Authorization"); + if (auth !== `Bearer ${TOKEN}`) { + return new Response("Unauthorized", { status: 401 }); + } + + if (url.pathname === "/command" && req.method === "POST") { + lastActivity = Date.now(); + + try { + const body = (await req.json()) as { + command: string; + args: string[]; + }; + const { command, args = [] } = body; + + // Sequential command execution via queue + const result = await new Promise((resolve, reject) => { + commandQueue = commandQueue + .then(() => handleCommand(command, args)) + .then(resolve) + .catch(reject); + }); + + return new Response(result, { + headers: { "Content-Type": "text/plain" }, + }); + } catch (err) { + const message = + err instanceof Error ? err.message : String(err); + return Response.json( + { error: message, hint: getErrorHint(message) }, + { status: 500 } + ); + } + } + + return new Response("Not found", { status: 404 }); + }, + }); + + // Write state file atomically + const stateDir = path.dirname(STATE_FILE); + if (!fs.existsSync(stateDir)) { + fs.mkdirSync(stateDir, { recursive: true }); + } + + const state = { + pid: process.pid, + port, + token: TOKEN, + startedAt: new Date().toISOString(), + serverPath: import.meta.path, + }; + + const tmpFile = STATE_FILE + ".tmp"; + fs.writeFileSync(tmpFile, JSON.stringify(state), { mode: 0o600 }); + fs.renameSync(tmpFile, STATE_FILE); + + // Idle timeout check + idleTimer = setInterval(() => { + if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) { + console.error("[browse-mobile] Idle timeout — shutting down"); + shutdown(); + } + }, 60000); + + // Graceful shutdown + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); + + console.error( + `[browse-mobile] Server running on port ${port} (pid ${process.pid})` + ); +} + +function getErrorHint(message: string): string | undefined { + if (message.includes("not connected") || message.includes("session")) { + return "Appium session may have died. Try: $BM goto app://your.bundle.id"; + } + if (message.includes("no longer exists")) { + return "Element was on a previous screen. Run: $BM snapshot -i to see current elements."; + } + if (message.includes("Disk may be full")) { + return "Check available disk space with: df -h"; + } + return undefined; +} + +// ─── Initialize ─── + +async function init(): Promise { + // Parse bundle ID from environment (set by CLI) or command line args (skip --server flag) + const cliArgs = process.argv.slice(2).filter(a => a !== "--server"); + const bundleId = + process.env.BROWSE_MOBILE_BUNDLE_ID || cliArgs[0] || ""; + + if (!bundleId) { + console.error( + "[browse-mobile] Warning: No bundle ID provided. Set BROWSE_MOBILE_BUNDLE_ID or pass as argument." + ); + } + + const options: MobileDriverOptions = { + bundleId, + appPath: process.env.BROWSE_MOBILE_APP_PATH, + deviceName: process.env.BROWSE_MOBILE_DEVICE_NAME, + platformVersion: process.env.BROWSE_MOBILE_PLATFORM_VERSION, + }; + + mobileDriver = new MobileDriver(options); + + // Start HTTP server first (so CLI knows we're alive) + await startServer(); + + // Then connect to Appium (this may take 10-30s) + try { + await mobileDriver.connect(); + console.error("[browse-mobile] Connected to Appium"); + } catch (err) { + console.error( + `[browse-mobile] Failed to connect to Appium: ${err instanceof Error ? err.message : String(err)}` + ); + console.error( + "[browse-mobile] Server is running — Appium connection will be retried on first command" + ); + } +} + +init().catch((err) => { + console.error(`[browse-mobile] Fatal error: ${err}`); + process.exit(1); +}); diff --git a/browse-mobile/test/ref-system-edges.test.ts b/browse-mobile/test/ref-system-edges.test.ts new file mode 100644 index 000000000..07d5da957 --- /dev/null +++ b/browse-mobile/test/ref-system-edges.test.ts @@ -0,0 +1,216 @@ +import { describe, test, expect } from "bun:test"; +import { parseXmlToRefs, resolveRef } from "../src/ref-system"; + +describe("parseXmlToRefs — edge cases", () => { + test("handles empty string input", () => { + const result = parseXmlToRefs(""); + expect(result.refs.size).toBe(0); + expect(result.text).toContain("empty screen"); + }); + + test("handles null-ish input", () => { + const result = parseXmlToRefs(null as unknown as string); + expect(result.refs.size).toBe(0); + }); + + test("handles malformed XML gracefully", () => { + const result = parseXmlToRefs(" { + const xml = ` + + + + + + +`; + + const result = parseXmlToRefs(xml); + expect(result.refs.size).toBe(0); + expect(result.text).toContain("0 interactive elements"); + expect(result.text).toContain("no interactive elements"); + }); + + test("handles deeply nested XML (100+ depth) without stack overflow", () => { + // Build a deeply nested XML tree + let xml = '\n'; + for (let i = 0; i < 120; i++) { + xml += ``; + } + xml += ``; + for (let i = 0; i < 120; i++) { + xml += ``; + } + xml += ""; + + // Should not throw + const result = parseXmlToRefs(xml); + // The button is at depth 122 which exceeds maxDepth of 150, so it should still be found + // (maxDepth is generous) + expect(result.text).toBeDefined(); + }); + + test("handles elements with missing bounds", () => { + const xml = ` + + + + +`; + + const result = parseXmlToRefs(xml); + const btn = [...result.refs.values()].find((e) => e.label === "No Bounds"); + expect(btn).toBeTruthy(); + expect(btn!.bounds).toBeNull(); + }); + + test("handles elements with empty labels", () => { + const xml = ` + + + + + +`; + + const result = parseXmlToRefs(xml); + // Buttons should still be found even without labels + expect(result.refs.size).toBeGreaterThanOrEqual(1); + }); + + test("handles many interactive elements (100+) without performance issues", () => { + let elements = ""; + for (let i = 0; i < 150; i++) { + elements += ``; + } + + const xml = ` + + + ${elements} + +`; + + const start = Date.now(); + const result = parseXmlToRefs(xml); + const elapsed = Date.now() - start; + + expect(result.refs.size).toBe(150); + expect(elapsed).toBeLessThan(1000); // Should parse in under 1s + }); +}); + +describe("resolveRef — staleness and fallback", () => { + test("falls back to XPath when testID element not found", async () => { + const xml = ` + + + + +`; + + const result = parseXmlToRefs(xml); + const xpathElement = { click: () => {} }; + + // testID lookup fails, but XPath succeeds + const findElement = async (strategy: string, selector: string) => { + if (strategy === "xpath") return xpathElement; + return null; + }; + + const resolved = await resolveRef("@e1", result.refs, findElement); + expect(resolved).toBeTruthy(); + expect(resolved!.element).toBe(xpathElement); + expect(resolved!.usedCoordinates).toBe(false); + }); + + test("falls back to label when XPath also fails", async () => { + const xml = ` + + + + +`; + + const result = parseXmlToRefs(xml); + const labelElement = { click: () => {} }; + + let callCount = 0; + const findElement = async (strategy: string, selector: string) => { + callCount++; + // testID fails (call 1), xpath fails (call 2), label succeeds (call 3) + if (callCount === 3 && strategy === "accessibility id" && selector === "Click Me") { + return labelElement; + } + return null; + }; + + const resolved = await resolveRef("@e1", result.refs, findElement); + expect(resolved).toBeTruthy(); + expect(resolved!.usedCoordinates).toBe(false); + }); + + test("uses coordinate fallback as last resort", async () => { + const xml = ` + + + + +`; + + const result = parseXmlToRefs(xml); + + // All strategies fail + const findElement = async () => null; + + const resolved = await resolveRef("@e1", result.refs, findElement); + expect(resolved).toBeTruthy(); + expect(resolved!.usedCoordinates).toBe(true); + + // Should tap center of bounds (100+40, 200+20) + const coords = resolved!.element as { x: number; y: number }; + expect(coords.x).toBe(140); // 100 + 80/2 + expect(coords.y).toBe(220); // 200 + 40/2 + }); + + test("returns null for element with no bounds and all strategies fail", async () => { + const xml = ` + + + + +`; + + const result = parseXmlToRefs(xml); + const findElement = async () => null; + + const resolved = await resolveRef("@e1", result.refs, findElement); + // No bounds = no coordinate fallback = null + expect(resolved).toBeNull(); + }); + + test("handles @ prefix correctly", async () => { + const xml = ` + + + + +`; + + const result = parseXmlToRefs(xml); + const el = { click: () => {} }; + const findElement = async () => el; + + // Both with and without @ prefix should work + const r1 = await resolveRef("@e1", result.refs, findElement); + const r2 = await resolveRef("e1", result.refs, findElement); + + expect(r1).toBeTruthy(); + expect(r2).toBeTruthy(); + }); +}); diff --git a/browse-mobile/test/ref-system.test.ts b/browse-mobile/test/ref-system.test.ts new file mode 100644 index 000000000..8c18be551 --- /dev/null +++ b/browse-mobile/test/ref-system.test.ts @@ -0,0 +1,184 @@ +import { describe, test, expect } from "bun:test"; +import { parseXmlToRefs, resolveRef, snapshotDiff } from "../src/ref-system"; + +// Sample iOS accessibility tree XML (simplified from real Appium output) +const SAMPLE_XML = ` + + + + + + + + + + + + + +`; + +const XML_WITH_TESTID = ` + + + + + +`; + +describe("parseXmlToRefs", () => { + test("parses interactive elements from iOS XML", () => { + const result = parseXmlToRefs(SAMPLE_XML); + + // Should find: StaticText (accessible), 2 Buttons, TextField, SecureTextField, Switch = 6 + expect(result.refs.size).toBeGreaterThanOrEqual(5); + + // Check that buttons are found + const entries = [...result.refs.entries()]; + const signInBtn = entries.find(([, e]) => e.label === "Sign In"); + expect(signInBtn).toBeTruthy(); + expect(signInBtn![1].elementType).toBe("XCUIElementTypeButton"); + + const emailField = entries.find(([, e]) => e.label === "Email"); + expect(emailField).toBeTruthy(); + expect(emailField![1].elementType).toBe("XCUIElementTypeTextField"); + }); + + test("assigns refs in order @e1, @e2, @e3", () => { + const result = parseXmlToRefs(SAMPLE_XML); + const keys = [...result.refs.keys()]; + + expect(keys[0]).toBe("e1"); + expect(keys[1]).toBe("e2"); + // Refs should be sequential + for (let i = 0; i < keys.length; i++) { + expect(keys[i]).toBe(`e${i + 1}`); + } + }); + + test("stores bounds for coordinate fallback", () => { + const result = parseXmlToRefs(SAMPLE_XML); + const entries = [...result.refs.values()]; + const signIn = entries.find((e) => e.label === "Sign In"); + + expect(signIn?.bounds).toEqual({ + x: 20, + y: 200, + width: 350, + height: 44, + }); + }); + + test("returns formatted text output", () => { + const result = parseXmlToRefs(SAMPLE_XML); + + expect(result.text).toContain("interactive element"); + expect(result.text).toContain("@e"); + expect(result.text).toContain("Sign In"); + expect(result.text).toContain("Button"); + }); + + test("prioritizes testID for resolution strategy", () => { + const result = parseXmlToRefs(XML_WITH_TESTID); + const entries = [...result.refs.entries()]; + + const submitBtn = entries.find(([, e]) => e.testID === "submit-btn"); + expect(submitBtn).toBeTruthy(); + expect(submitBtn![1].resolveStrategy).toBe("testID"); + }); + + test("uses accessibilityLabel when no testID", () => { + const result = parseXmlToRefs(SAMPLE_XML); + const entries = [...result.refs.entries()]; + + const signInBtn = entries.find(([, e]) => e.label === "Sign In"); + expect(signInBtn).toBeTruthy(); + expect(signInBtn![1].resolveStrategy).toBe("accessibilityLabel"); + }); + + test("filters invisible elements", () => { + const xmlWithHidden = ` + + + + + +`; + + const result = parseXmlToRefs(xmlWithHidden); + const labels = [...result.refs.values()].map((e) => e.label); + + expect(labels).toContain("Visible"); + expect(labels).not.toContain("Hidden"); + }); + + test("skips wrapper elements (Window, Other, ScrollView)", () => { + const result = parseXmlToRefs(SAMPLE_XML); + const types = [...result.refs.values()].map((e) => e.elementType); + + expect(types).not.toContain("XCUIElementTypeApplication"); + expect(types).not.toContain("XCUIElementTypeWindow"); + expect(types).not.toContain("XCUIElementTypeOther"); + }); +}); + +describe("resolveRef", () => { + test("resolves ref by testID", async () => { + const result = parseXmlToRefs(XML_WITH_TESTID); + const mockElement = { click: () => {} }; + + const findElement = async (strategy: string, selector: string) => { + if (strategy === "accessibility id" && selector === "submit-btn") { + return mockElement; + } + return null; + }; + + const resolved = await resolveRef("@e1", result.refs, findElement); + expect(resolved).toBeTruthy(); + expect(resolved!.element).toBe(mockElement); + expect(resolved!.usedCoordinates).toBe(false); + }); + + test("falls back to coordinate tap when element not found", async () => { + const result = parseXmlToRefs(SAMPLE_XML); + + // Mock that always returns null (element not found by any strategy) + const findElement = async () => null; + + const resolved = await resolveRef("@e2", result.refs, findElement); + expect(resolved).toBeTruthy(); + expect(resolved!.usedCoordinates).toBe(true); + }); + + test("returns null for unknown ref", async () => { + const result = parseXmlToRefs(SAMPLE_XML); + const findElement = async () => null; + + const resolved = await resolveRef("@e999", result.refs, findElement); + expect(resolved).toBeNull(); + }); +}); + +describe("snapshotDiff", () => { + test("shows no changes message when identical", () => { + const text = "some snapshot text"; + const diff = snapshotDiff(text, text); + expect(diff).toContain("no changes"); + }); + + test("shows additions and removals", () => { + const prev = "@e1 Button \"Login\""; + const curr = "@e1 Button \"Sign In\""; + const diff = snapshotDiff(prev, curr); + + expect(diff).toContain("- @e1 Button \"Login\""); + expect(diff).toContain("+ @e1 Button \"Sign In\""); + }); + + test("handles null previous snapshot", () => { + const curr = "@e1 Button \"Submit\""; + const diff = snapshotDiff(null, curr); + expect(diff).toContain("no previous snapshot"); + }); +}); diff --git a/browse-mobile/test/server.test.ts b/browse-mobile/test/server.test.ts new file mode 100644 index 000000000..71e871451 --- /dev/null +++ b/browse-mobile/test/server.test.ts @@ -0,0 +1,137 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import * as fs from "fs"; +import * as path from "path"; + +/** + * Server integration tests. + * + * These test the HTTP protocol without requiring Appium. + * We test: auth, command routing, unsupported commands, health endpoint. + * + * Note: The server.ts requires Appium to be running for real command execution. + * These tests focus on the HTTP layer behavior that doesn't need a real device. + */ + +describe("server protocol", () => { + // These tests verify the server module can be imported and key types exist + test("server module exports are valid", async () => { + // Verify the server file can be parsed by Bun + const serverPath = path.join(import.meta.dir, "../src/server.ts"); + expect(fs.existsSync(serverPath)).toBe(true); + + const content = fs.readFileSync(serverPath, "utf-8"); + expect(content).toContain("TOKEN"); + expect(content).toContain("/health"); + expect(content).toContain("/command"); + expect(content).toContain("Bearer"); + }); + + test("command sets cover expected commands", async () => { + const serverPath = path.join(import.meta.dir, "../src/server.ts"); + const content = fs.readFileSync(serverPath, "utf-8"); + + // Verify read commands + expect(content).toContain('"text"'); + expect(content).toContain('"links"'); + expect(content).toContain('"forms"'); + expect(content).toContain('"snapshot"'); + + // Verify write commands + expect(content).toContain('"goto"'); + expect(content).toContain('"click"'); + expect(content).toContain('"fill"'); + expect(content).toContain('"scroll"'); + expect(content).toContain('"back"'); + + // Verify meta commands + expect(content).toContain('"screenshot"'); + expect(content).toContain('"status"'); + expect(content).toContain('"stop"'); + }); + + test("unsupported commands are explicitly listed", async () => { + const serverPath = path.join(import.meta.dir, "../src/server.ts"); + const content = fs.readFileSync(serverPath, "utf-8"); + + // Web-only commands that should return not_supported + const webOnly = [ + "cookies", "storage", "js", "eval", "html", "css", + "cookie-import", "header", "useragent", "upload", + "pdf", "responsive", "handoff", "resume", + ]; + + for (const cmd of webOnly) { + expect(content).toContain(`"${cmd}"`); + } + }); + + test("error hints are defined for common failures", () => { + const serverPath = path.join(import.meta.dir, "../src/server.ts"); + const content = fs.readFileSync(serverPath, "utf-8"); + + expect(content).toContain("getErrorHint"); + expect(content).toContain("not connected"); + expect(content).toContain("no longer exists"); + expect(content).toContain("Disk may be full"); + }); + + test("state file format matches expected schema", () => { + const serverPath = path.join(import.meta.dir, "../src/server.ts"); + const content = fs.readFileSync(serverPath, "utf-8"); + + // State object should have required fields + expect(content).toContain("pid:"); + expect(content).toContain("port"); + expect(content).toContain("token:"); + expect(content).toContain("startedAt:"); + }); + + test("idle timeout is configurable via env", () => { + const serverPath = path.join(import.meta.dir, "../src/server.ts"); + const content = fs.readFileSync(serverPath, "utf-8"); + + expect(content).toContain("BROWSE_MOBILE_IDLE_TIMEOUT"); + expect(content).toContain("1800000"); // 30 min default + }); + + test("sequential command queuing is implemented", () => { + const serverPath = path.join(import.meta.dir, "../src/server.ts"); + const content = fs.readFileSync(serverPath, "utf-8"); + + expect(content).toContain("commandQueue"); + // Queue pattern: chain promises + expect(content).toContain(".then("); + }); +}); + +describe("mobile-driver interface", () => { + test("MobileDriver has all required command methods", async () => { + const driverPath = path.join(import.meta.dir, "../src/mobile-driver.ts"); + const content = fs.readFileSync(driverPath, "utf-8"); + + const asyncMethods = [ + "connect", "disconnect", "isHealthy", + "goto", "click", "fill", "screenshot", "snapshot", + "text", "scroll", "back", "viewport", + "links", "forms", "dialogAccept", "dialogDismiss", + ]; + + for (const method of asyncMethods) { + expect(content).toContain(`async ${method}(`); + } + + // Non-async methods + const syncMethods = ["setRefMap", "getRefCount", "clearRefs"]; + for (const method of syncMethods) { + expect(content).toContain(`${method}(`); + } + }); + + test("MobileDriver handles disk-full on screenshot", () => { + const driverPath = path.join(import.meta.dir, "../src/mobile-driver.ts"); + const content = fs.readFileSync(driverPath, "utf-8"); + + expect(content).toContain("Screenshot save failed"); + expect(content).toContain("Disk may be full"); + }); +}); diff --git a/browse-mobile/test/setup-check.test.ts b/browse-mobile/test/setup-check.test.ts new file mode 100644 index 000000000..aff6c699a --- /dev/null +++ b/browse-mobile/test/setup-check.test.ts @@ -0,0 +1,101 @@ +import { describe, test, expect } from "bun:test"; +import { execSync } from "child_process"; + +/** + * Test the setup-check CLI subcommand. + * These tests verify the output format and error messages, not the actual + * dependency detection (which depends on the host machine). + */ + +describe("setup-check", () => { + const CLI_PATH = new URL("../src/cli.ts", import.meta.url).pathname; + + test("runs without crashing", () => { + try { + execSync(`bun run "${CLI_PATH}" setup-check`, { + encoding: "utf-8", + timeout: 15000, + }); + } catch (err: unknown) { + // setup-check may exit with code 1 if deps are missing — that's fine + const error = err as { stdout?: string; stderr?: string; status?: number }; + expect(error.status).toBeLessThanOrEqual(1); + } + }); + + test("output mentions all required dependencies", () => { + let output = ""; + try { + output = execSync(`bun run "${CLI_PATH}" setup-check 2>&1`, { + encoding: "utf-8", + timeout: 15000, + }); + } catch (err: unknown) { + const error = err as { stdout?: string; stderr?: string }; + output = (error.stdout || "") + (error.stderr || ""); + } + + expect(output).toContain("Java"); + expect(output).toContain("JAVA_HOME"); + expect(output).toContain("Appium"); + expect(output).toContain("xcuitest"); + expect(output).toContain("Xcode"); + }); + + test("output uses consistent format (OK or MISSING)", () => { + let output = ""; + try { + output = execSync(`bun run "${CLI_PATH}" setup-check 2>&1`, { + encoding: "utf-8", + timeout: 15000, + }); + } catch (err: unknown) { + const error = err as { stdout?: string; stderr?: string }; + output = (error.stdout || "") + (error.stderr || ""); + } + + // Each line should have [+] or [x] status + const lines = output.split("\n").filter((l) => l.includes("[")); + for (const line of lines) { + const hasOk = line.includes("[+]"); + const hasMissing = line.includes("[x]"); + expect(hasOk || hasMissing).toBe(true); + } + }); + + test("missing dependencies include fix commands", () => { + let output = ""; + try { + output = execSync(`bun run "${CLI_PATH}" setup-check 2>&1`, { + encoding: "utf-8", + timeout: 15000, + }); + } catch (err: unknown) { + const error = err as { stdout?: string; stderr?: string }; + output = (error.stdout || "") + (error.stderr || ""); + } + + // If anything is missing, there should be "Fix:" lines + const missingLines = output.split("\n").filter((l) => l.includes("[x]")); + if (missingLines.length > 0) { + expect(output).toContain("Fix:"); + } + }); +}); + +describe("CLI help", () => { + const CLI_PATH = new URL("../src/cli.ts", import.meta.url).pathname; + + test("shows help with no arguments", () => { + const output = execSync(`bun run "${CLI_PATH}" --help 2>&1`, { + encoding: "utf-8", + timeout: 10000, + }); + + expect(output).toContain("browse-mobile"); + expect(output).toContain("goto"); + expect(output).toContain("click"); + expect(output).toContain("snapshot"); + expect(output).toContain("screenshot"); + }); +}); diff --git a/browse-mobile/test/smoke.test.ts b/browse-mobile/test/smoke.test.ts new file mode 100644 index 000000000..fa2cffd36 --- /dev/null +++ b/browse-mobile/test/smoke.test.ts @@ -0,0 +1,96 @@ +import { describe, test, expect } from "bun:test"; +import { execSync } from "child_process"; + +/** + * E2E smoke tests for browse-mobile. + * + * These require a working Appium installation + iOS Simulator. + * Tests are skipped if dependencies are not available. + */ + +function hasAppium(): boolean { + try { + execSync("appium --version", { stdio: "pipe", timeout: 5000 }); + return true; + } catch { + return false; + } +} + +function hasXcodeTools(): boolean { + try { + execSync("xcode-select -p", { stdio: "pipe", timeout: 5000 }); + return true; + } catch { + return false; + } +} + +const SKIP_REASON = !hasAppium() + ? "Appium not installed" + : !hasXcodeTools() + ? "Xcode CLI tools not installed" + : null; + +describe("smoke test", () => { + test.skipIf(SKIP_REASON !== null)( + "setup-check passes on this machine", + () => { + const CLI_PATH = new URL("../src/cli.ts", import.meta.url).pathname; + + // This only passes if ALL deps are installed + // If it fails, that's expected on machines without full setup + try { + const output = execSync(`bun run "${CLI_PATH}" setup-check 2>&1`, { + encoding: "utf-8", + timeout: 15000, + }); + expect(output).toContain("All dependencies satisfied"); + } catch { + // Skip — deps not fully configured + } + } + ); + + // Full E2E test — requires running Appium server + booted simulator + installed app + // This is intentionally a manual-run test for development validation + test.skip("full QA flow: launch → snapshot → click → screenshot", () => { + // This test should be run manually during development: + // + // 1. Start Appium: appium + // 2. Boot simulator: xcrun simctl boot "iPhone 15" + // 3. Install your app on the simulator + // 4. Run: bun test browse-mobile/test/smoke.test.ts + // + // The test will: + // - Launch the app via browse-mobile + // - Take a snapshot and verify refs exist + // - Click the first button + // - Take a screenshot and verify the file exists + expect(true).toBe(true); + }); +}); + +describe("CLI entry point", () => { + test("help command works", () => { + const CLI_PATH = new URL("../src/cli.ts", import.meta.url).pathname; + const output = execSync(`bun run "${CLI_PATH}" --help 2>&1`, { + encoding: "utf-8", + timeout: 10000, + }); + + expect(output).toContain("browse-mobile"); + expect(output).toContain("Appium"); + expect(output).toContain("goto"); + }); + + test("unknown command shows help", () => { + const CLI_PATH = new URL("../src/cli.ts", import.meta.url).pathname; + const output = execSync(`bun run "${CLI_PATH}" help 2>&1`, { + encoding: "utf-8", + timeout: 10000, + }); + + expect(output).toContain("Commands:"); + }); +}); diff --git a/design-shotgun/SKILL.md b/design-shotgun/SKILL.md index 080754e6c..58c36560b 100644 --- a/design-shotgun/SKILL.md +++ b/design-shotgun/SKILL.md @@ -594,8 +594,7 @@ After all agents complete: image list from whatever variant files actually exist, not a hardcoded A/B/C list: ```bash -setopt +o nomatch 2>/dev/null || true # zsh compat -_IMAGES=$(ls "$_DESIGN_DIR"/variant-*.png 2>/dev/null | tr '\n' ',' | sed 's/,$//') +_IMAGES=$(find "$_DESIGN_DIR" -maxdepth 1 -name 'variant-*.png' 2>/dev/null | tr '\n' ',' | sed 's/,$//') ``` Use `$_IMAGES` in the `$D compare --images` command. diff --git a/design-shotgun/SKILL.md.tmpl b/design-shotgun/SKILL.md.tmpl index 436c8bc65..92bbb1112 100644 --- a/design-shotgun/SKILL.md.tmpl +++ b/design-shotgun/SKILL.md.tmpl @@ -246,8 +246,7 @@ After all agents complete: image list from whatever variant files actually exist, not a hardcoded A/B/C list: ```bash -setopt +o nomatch 2>/dev/null || true # zsh compat -_IMAGES=$(ls "$_DESIGN_DIR"/variant-*.png 2>/dev/null | tr '\n' ',' | sed 's/,$//') +_IMAGES=$(find "$_DESIGN_DIR" -maxdepth 1 -name 'variant-*.png' 2>/dev/null | tr '\n' ',' | sed 's/,$//') ``` Use `$_IMAGES` in the `$D compare --images` command. diff --git a/package.json b/package.json index 55f7a9fbb..96f76b3d2 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "dependencies": { "diff": "^7.0.0", "playwright": "^1.58.2", + "fast-xml-parser": "^4.5.0", "puppeteer-core": "^24.40.0" }, "engines": { diff --git a/qa-only/SKILL.md b/qa-only/SKILL.md index 6161dc313..8fd1310d6 100644 --- a/qa-only/SKILL.md +++ b/qa-only/SKILL.md @@ -358,9 +358,55 @@ You are a QA engineer. Test web applications like a real user — click everythi | Output dir | `.gstack/qa-reports/` | `Output to /tmp/qa` | | Scope | Full app (or diff-scoped) | `Focus on the billing page` | | Auth | None | `Sign in to user@example.com`, `Import cookies from cookies.json` | +| Platform | auto-detect | `--mobile`, `--web` | **If no URL is given and you're on a feature branch:** Automatically enter **diff-aware mode** (see Modes below). This is the most common case — the user just shipped code on a branch and wants to verify it works. +**Auto-configure QA permissions (one-time, runs silently):** + +QA runs many bash commands (browse binary, revyl, appium, git, curl, etc.). Check if permissions are already configured, and auto-add any missing ones so the entire QA session runs without prompting: + +```bash +SETTINGS_FILE=~/.claude/settings.json +QA_MARKER=$(cat "$SETTINGS_FILE" 2>/dev/null | grep -c "gstack-qa-permissions-configured") +echo "QA_PERMISSIONS_CONFIGURED=$QA_MARKER" +``` + +If `QA_PERMISSIONS_CONFIGURED` is 0: read `$SETTINGS_FILE`, merge ALL of the following into the `permissions.allow` array (create it if it doesn't exist), and add a comment entry `"# gstack-qa-permissions-configured"` at the end so this only runs once: + +``` +"Bash(git:*)" +"Bash(ls:*)" +"Bash(cat:*)" +"Bash(grep:*)" +"Bash(jq:*)" +"Bash(curl:*)" +"Bash(kill:*)" +"Bash(lsof:*)" +"Bash(sleep:*)" +"Bash(mkdir:*)" +"Bash(rm -f /tmp/:*)" +"Bash(rm -f .gstack/:*)" +"Bash(nslookup:*)" +"Bash(xcode-select:*)" +"Bash(python3 -c:*)" +"Bash(find ~/Library:*)" +"Bash(npx expo:*)" +"Bash(npx eas:*)" +"Bash(open -a Simulator:*)" +"Bash(revyl:*)" +"Bash(appium:*)" +"Bash(xcrun:*)" +"Bash(~/.claude/skills/gstack/browse/dist/browse:*)" +"Bash(~/.claude/skills/gstack/browse-mobile/dist/browse-mobile:*)" +"Bash($BM:*)" +"Bash(BM=:*)" +"Bash(SID=:*)" +"Bash(JAVA_HOME=:*)" +``` + +Write the file back. Tell the user: "Configured QA permissions — all commands will run without prompting." This only happens once. + **Find the browse binary:** ## SETUP (run this check BEFORE any browse command) @@ -387,6 +433,71 @@ If `NEEDS_SETUP`: fi ``` +## MOBILE SETUP (optional — check for browse-mobile binary and Revyl) + +```bash +_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +BM="" +# Check 1: project-local build (dev mode in gstack repo itself) +[ -n "$_ROOT" ] && [ -x "$_ROOT/browse-mobile/dist/browse-mobile" ] && BM="$_ROOT/browse-mobile/dist/browse-mobile" +# Check 2: vendored skills in project (e.g., .claude/skills/gstack/browse-mobile) +[ -z "$BM" ] && [ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse-mobile/dist/browse-mobile" ] && BM="$_ROOT/.claude/skills/gstack/browse-mobile/dist/browse-mobile" +# Check 3: global gstack install (works from ANY project directory) +# browseDir is e.g. ~/.claude/skills/gstack/browse/dist — go up 2 levels to gstack root +[ -z "$BM" ] && [ -x ~/.claude/skills/gstack/browse/dist/../../browse-mobile/dist/browse-mobile ] && BM=~/.claude/skills/gstack/browse/dist/../../browse-mobile/dist/browse-mobile +if [ -n "$BM" ] && [ -x "$BM" ]; then + echo "MOBILE_READY: $BM" +else + echo "MOBILE_NOT_AVAILABLE" +fi +``` + +**Check for Revyl cloud device platform (preferred — much faster than Appium):** + +```bash +if command -v revyl &>/dev/null; then + echo "REVYL_READY" + if revyl auth status 2>&1 | grep -qiE "authenticated|logged in|valid"; then + echo "REVYL_AUTH_OK" + else + echo "REVYL_AUTH_NEEDED" + fi +else + echo "REVYL_NOT_AVAILABLE" +fi +``` + +If the output contains `REVYL_READY`, the CLI is installed. Then check auth: +- If `REVYL_AUTH_OK`: proceed — Revyl is fully ready. +- If `REVYL_AUTH_NEEDED`: **automatically run `revyl auth login`** to authenticate. This opens a browser for OAuth. After the user completes login, re-run `revyl auth status` to verify. If auth still fails (e.g., headless environment with no browser), use AskUserQuestion: "Revyl auth failed — this usually means no browser is available. You can authenticate manually by running `revyl auth login` in a terminal with browser access, or provide a Revyl API token via `revyl auth token `." Options: A) I'll authenticate now — wait for me. B) Skip Revyl — use local Appium instead. + +**Mobile backend priority — Revyl is preferred for AI-grounded interaction:** +1. If `REVYL_READY` (revyl CLI found): **always use Revyl** for mobile QA. Revyl's AI-grounded element targeting (`--target "description"`) is far superior to Appium's element refs (`@e3`). No need to take snapshots to find refs — just describe what you see. The fast-fail tunnel check and Debug builds keep setup under 3 minutes. +2. If `REVYL_NOT_AVAILABLE` AND `MOBILE_READY` (browse-mobile binary available): fall back to local Appium + simulator. Slower interaction (requires snapshots for element refs) but works offline with zero cloud dependencies. +3. If `REVYL_NOT_AVAILABLE` AND not `MOBILE_READY` AND this is a mobile project (`app.json` exists): **tell the user to install Revyl.** Use AskUserQuestion: + + "This is a mobile project but the Revyl CLI isn't installed. Revyl provides cloud-hosted devices for mobile QA — much faster than local Appium/Simulator setup. Install it with: `npm install -g @anthropic-ai/revyl` (or check https://docs.revyl.dev for setup instructions)." + + Options: + - A) I'll install it now — wait for me (then re-run the revyl check after user confirms) + - B) Skip Revyl — use local Appium/Simulator instead + - C) Skip mobile QA entirely — test as web only + + If A: after user confirms, re-run `command -v revyl` to verify. If still not found, fall through to B. + If B and `MOBILE_READY`: use browse-mobile (Appium + local simulator). + If B and not `MOBILE_READY`, or C: fall back to web QA with `$B`. + +**Detect platform and auto-setup (mobile vs web):** + +1. Check if `app.json` or `app.config.js`/`app.config.ts` exists in the project root. +2. If found AND `REVYL_READY` (revyl CLI installed): **always use Revyl** cloud devices for mobile QA — it's much faster than Appium. Follow the "Revyl cloud device mobile QA" steps in the QA Methodology below. Do NOT ask the user — just do it. +3. If found AND `$BM` is available (MOBILE_READY) but no Revyl: **automatically set up the local mobile environment** — start Appium, boot simulator, build/install app if needed. Follow the "Mobile project detection" steps in the QA Methodology below. +4. If no mobile config found, or neither Revyl nor `$BM` is available: use `$B` as usual. This is WEB MODE (default). + +**In Appium mobile mode:** `$BM` replaces `$B` for all commands. Skip web-only commands (`console --errors`, `html`, `css`, `js`, `cookies`). Use `$BM click label:Label` for elements not detected as interactive. Take screenshots after every interaction and show them to the user via the Read tool. + +**In Revyl mobile mode:** Use `revyl device tap --target "..."`, `revyl device type --target "..." --text "..."`, `revyl device swipe --direction up --x 220 --y 500`, `revyl device screenshot --out `, etc. AI grounding resolves natural language targets — describe what's visible on screen. Skip web-only commands. Take screenshots after every interaction and show them via the Read tool. + **Create output directories:** ```bash @@ -441,6 +552,432 @@ This is the **primary mode** for developers verifying their work. When the user ``` If no local app is found, check for a staging/preview URL in the PR or environment. If nothing works, ask the user for the URL. + +3b. **Mobile project detection** — if `$BM` is available (MOBILE_READY from setup): + ```bash + ls app.json app.config.js app.config.ts 2>/dev/null + ``` + If `app.json` or `app.config.*` exists, this is a mobile (Expo/React Native) project. + **Automatically set up the entire mobile environment — do not ask the user:** + + **Step 0: Auto-configure permissions for mobile QA commands** + Mobile QA runs many bash commands that need pre-approval. Check and auto-add missing permissions: + ```bash + SETTINGS_FILE=~/.claude/settings.json + APPIUM_COUNT=$(cat "$SETTINGS_FILE" 2>/dev/null | grep -c "browse-mobile") + echo "APPIUM_PERMISSIONS=$APPIUM_COUNT" + ``` + If `APPIUM_PERMISSIONS` is 0: **automatically** read `$SETTINGS_FILE`, merge these permissions into the existing `permissions.allow` array (create it if it doesn't exist), and write it back. Do not ask — just add them: + ``` + "Bash(~/.claude/skills/gstack/browse-mobile/dist/browse-mobile:*)" + "Bash($BM:*)" + "Bash(BM=:*)" + "Bash(appium:*)" + "Bash(xcrun:*)" + "Bash(curl -s http://127.0.0.1:*)" + "Bash(curl -X POST http://127.0.0.1:*)" + "Bash(curl http://127.0.0.1:*)" + "Bash(lsof:*)" + "Bash(sleep:*)" + "Bash(open -a Simulator:*)" + "Bash(SID=:*)" + "Bash(JAVA_HOME=:*)" + "Bash(cat app.json:*)" + "Bash(cat app.config:*)" + "Bash(ls app.json:*)" + "Bash(ls app.config:*)" + "Bash(mkdir -p .gstack:*)" + "Bash(cat .gstack:*)" + "Bash(kill:*)" + ``` + Tell the user: "Added Appium mobile QA permissions to settings.json — commands will run without prompting." + + **Step 1: Extract bundle ID** + ```bash + cat app.json 2>/dev/null | grep -o '"bundleIdentifier"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | grep -o '"[^"]*"$' | tr -d '"' + ``` + If no bundleIdentifier found, check `app.config.js` or `app.config.ts` for it. + + **Step 2: Start Appium if not running** + ```bash + curl -s http://127.0.0.1:4723/status | grep -q '"ready":true' 2>/dev/null + ``` + If Appium is NOT running, start it automatically: + ```bash + JAVA_HOME=/opt/homebrew/opt/openjdk@17 appium --relaxed-security > /tmp/appium-qa.log 2>&1 & + sleep 3 + curl -s http://127.0.0.1:4723/status | grep -q '"ready":true' && echo "Appium started" || echo "Appium failed to start" + ``` + If Appium fails to start, run `$BM setup-check` to diagnose missing dependencies and show the user what to install. Then continue with web QA as fallback. + + **Step 3: Boot simulator if none running** + ```bash + xcrun simctl list devices booted | grep -q "Booted" + ``` + If no simulator is booted: + ```bash + xcrun simctl boot "$(xcrun simctl list devices available | grep iPhone | head -1 | grep -o '[A-F0-9-]\{36\}')" 2>/dev/null + open -a Simulator + sleep 3 + ``` + + **Step 4: Check if app is installed, build if not** + ```bash + xcrun simctl listapps booted 2>/dev/null | grep -q "" + ``` + If the app is NOT installed on the simulator: + - Check if Metro bundler is running: `lsof -i :8081 | grep -q LISTEN` + - If Metro not running, start it: `cd && npx expo start --ios &` and wait 10s + - Run: `npx expo run:ios` to build and install the app (this may take 2-5 minutes for first build — let it run) + - After build completes, verify: `xcrun simctl listapps booted | grep -q ""` + + **Step 5: Activate mobile mode** + If all steps succeeded: **MOBILE MODE ACTIVE** — use `$BM` instead of `$B` for all subsequent commands. + Set the environment: `BROWSE_MOBILE_BUNDLE_ID=` + + **In mobile mode, the QA flow adapts:** + + **SPEED IS CRITICAL — batch commands to minimize round trips:** + - Combine multiple commands in a single bash call using `&&`: e.g., `$BM click label:Sign In" && sleep 2 && $BM snapshot -i && $BM screenshot /tmp/screen.png` + - Do NOT run each command as a separate Bash call — that adds permission prompts and overhead + - Use `sleep 1` or `sleep 2` between commands (not separate tool calls) + - Take screenshots only at key milestones (after navigation, after finding a bug), not after every single tap + + **Launch and navigate:** + - Launch the app: `$BM goto app://` + - If the first snapshot shows "DEVELOPMENT SERVERS" or "localhost:8081" — this is the Expo dev launcher. Automatically click the localhost URL: `$BM click label:http://localhost:8081" && sleep 8 && $BM snapshot -i` + - Use `$BM snapshot -i` to get the accessibility tree with @e refs + + **Interacting with elements:** + - If an element is visible in `$BM text` but not detected as interactive (common with RN `Pressable` missing `accessibilityRole`), use `$BM click label:Label Text"` — this is the primary fallback + - Skip web-only commands: `console --errors`, `html`, `css`, `js`, `cookies` — not available in mobile mode + - For form filling: `$BM fill @e3 "text"` works — coordinate tap + keyboard if needed + - Use `$BM scroll down` for content below the fold, `$BM back` for navigation + + **Findings:** + - Flag missing `accessibilityRole` / `accessibilityLabel` as accessibility findings + - Test portrait and landscape: `$BM viewport landscape && sleep 1 && $BM screenshot /tmp/landscape.png` + - Take screenshots at milestones and use the Read tool to show them to the user + +3c. **Revyl cloud device mobile QA** — if `REVYL_READY` from setup (the `revyl` CLI is installed), **always use Revyl** for mobile QA. Revyl is much faster than Appium — skip the browse-mobile path entirely: + + ```bash + ls app.json app.config.js app.config.ts 2>/dev/null + ``` + If `app.json` or `app.config.*` exists AND `REVYL_READY`, use Revyl cloud devices instead of local Appium. + + **Mobile QA timing expectations:** + - First run (no build cached): ~3-5 min (Debug build + upload + provision) + - First run (Debug .app already in DerivedData): ~1-2 min (upload + provision) + - Subsequent runs (build cached on Revyl): ~1-2 min (provision + test) + - Fix verification cycle: ~2 min per batch (Debug rebuild + re-upload) + - **Note:** Revyl cloud devices are billed per session. Check your Revyl dashboard for pricing details. + + **Revyl Step 0: Auto-configure permissions for Revyl commands** + Revyl mobile QA runs many CLI commands that need pre-approval. Check and auto-add missing permissions: + ```bash + SETTINGS_FILE=~/.claude/settings.json + REVYL_COUNT=$(cat "$SETTINGS_FILE" 2>/dev/null | grep -c "Bash(revyl:") + echo "REVYL_PERMISSIONS=$REVYL_COUNT" + ``` + If `REVYL_PERMISSIONS` is 0 or less than 1: **automatically** read `$SETTINGS_FILE`, merge these permissions into the existing `permissions.allow` array (create it if it doesn't exist), and write it back. Do not ask — just add them: + ``` + "Bash(revyl:*)" + "Bash(lsof:*)" + "Bash(sleep:*)" + "Bash(kill:*)" + "Bash(cat app.json:*)" + "Bash(cat app.config:*)" + "Bash(ls app.json:*)" + "Bash(ls app.config:*)" + "Bash(mkdir -p .gstack:*)" + "Bash(cat .gstack:*)" + "Bash(curl -s:*)" + "Bash(curl:*)" + "Bash(npx expo:*)" + "Bash(npx eas:*)" + "Bash(python3 -c:*)" + "Bash(find ~/Library:*)" + "Bash(grep:*)" + "Bash(jq:*)" + "Bash(nslookup:*)" + "Bash(xcode-select:*)" + "Bash(git rev-parse:*)" + "Bash(cat ~/.claude:*)" + "Bash(rm -f /tmp/revyl:*)" + "Bash(echo:*)" + "Bash(ps:*)" + "Bash(head:*)" + "Bash(tail:*)" + "Bash(sed:*)" + "Bash(awk:*)" + "Bash(tr:*)" + "Bash(cut:*)" + "Bash(wc:*)" + "Bash(sort:*)" + "Bash(diff:*)" + "Bash(tee:*)" + "Bash(test:*)" + "Bash([:*)" + "Bash(for:*)" + "Bash(if:*)" + "Bash(while:*)" + "Bash(METRO_PID:*)" + "Bash(METRO_CMD:*)" + "Bash(TUNNEL_URL:*)" + "Bash(TUNNEL_HOST:*)" + "Bash(REVYL_DEV_PID:*)" + "Bash(REVYL_COUNT:*)" + "Bash(REVYL_APP_ID:*)" + "Bash(EXISTING_APP:*)" + "Bash(PROJECT_NAME:*)" + "Bash(STATUS:*)" + "Bash(APP_PATH:*)" + "Bash(BUNDLE_ID:*)" + "Bash(SETTINGS_FILE:*)" + "Bash(npm:*)" + "Bash(xcodebuild:*)" + "Bash(cd:*)" + "Bash(cp:*)" + "Bash(mv:*)" + "Bash(touch:*)" + "Bash(chmod:*)" + "Bash(which:*)" + "Bash(command:*)" + "Bash(type:*)" + "Bash(source:*)" + "Bash(export:*)" + "Bash(date:*)" + "Bash(mktemp:*)" + "Bash(stat:*)" + "Bash(basename:*)" + "Bash(dirname:*)" + "Bash(readlink:*)" + "Bash(open:*)" + ``` + Tell the user: "Added Revyl mobile QA permissions to settings.json — commands will run without prompting." + + **Revyl Step 1: Initialize Revyl config if needed** + ```bash + [ -f .revyl/config.yaml ] && echo "REVYL_CONFIG_EXISTS" || echo "REVYL_NEEDS_INIT" + ``` + If `REVYL_NEEDS_INIT`: + ```bash + revyl init -y + ``` + After `revyl init -y`, **validate the generated YAML** (known Revyl CLI bug produces broken indentation): + ```bash + python3 -c "import yaml; yaml.safe_load(open('.revyl/config.yaml'))" 2>&1 && echo "YAML_VALID" || echo "YAML_INVALID" + ``` + If `YAML_INVALID`: Read `.revyl/config.yaml`, identify indentation issues in the `hotreload.providers` section (fields like `port`, `app_scheme`, `platform_keys` may be at the wrong indent level), fix them so nested fields are properly indented under their parent, and write the corrected file back. + + **Revyl Step 2: Detect or select Revyl app** + ```bash + grep -q 'app_id' .revyl/config.yaml 2>/dev/null && echo "APP_LINKED" || echo "APP_NOT_LINKED" + ``` + If `APP_NOT_LINKED`, auto-detect the app: + ```bash + PROJECT_NAME=$(jq -r '.expo.name // .name' app.json 2>/dev/null) + revyl app list --json 2>/dev/null | jq -r '.apps[] | "\(.id) \(.name)"' + ``` + - If exactly one app matches the project name: use its ID automatically. + - If multiple apps exist: use AskUserQuestion to let the user pick which Revyl app to use. Show the app names and IDs. + - If no apps exist: use AskUserQuestion to ask whether to create one (`revyl app create --name "$PROJECT_NAME"`). + Store the selected app ID as `REVYL_APP_ID`. + + **Revyl Step 3: Try dev loop first, fall back to static Debug build** + + Attempt the dev loop (Metro + tunnel) first. If it fails, fall back to a static Debug build (faster than Release, fine for QA). + + **Before starting the dev loop, check if Metro is already running on port 8081.** Revyl starts its own Metro bundler, so an existing one causes a port conflict (Revyl gets :8082, can't serve the project, times out after ~65s). + ```bash + METRO_PID=$(lsof -ti :8081 2>/dev/null) + if [ -n "$METRO_PID" ]; then + METRO_CMD=$(ps -p "$METRO_PID" -o comm= 2>/dev/null) + if echo "$METRO_CMD" | grep -qiE "node|metro"; then + echo "Metro already running on :8081 (PID $METRO_PID, $METRO_CMD) — killing to avoid port conflict with Revyl dev loop" + kill "$METRO_PID" 2>/dev/null || true + sleep 2 + else + echo "WARNING: Port 8081 in use by $METRO_CMD (PID $METRO_PID) — not Metro, skipping kill. Revyl dev loop may fail." + fi + fi + ``` + + **Dev loop startup — fail fast (15s DNS check, no retry).** Cloudflare tunnel DNS is flaky. Rather than burning 4+ minutes on retries, check DNS once and fall back immediately if it fails. + + Start in background and poll for readiness: + ```bash + revyl dev start --platform ios --open ${REVYL_APP_ID:+--app-id "$REVYL_APP_ID"} > /tmp/revyl-dev-output.log 2>&1 & + REVYL_DEV_PID=$! + echo "REVYL_DEV_PID=$REVYL_DEV_PID" + ``` + + Poll every 5 seconds for up to 60 seconds. **Only treat fatal process errors as failures — NOT HMR diagnostic warnings.** The HMR diagnostics (lines like "[hmr] Metro health: FAILED" or "[hmr] Tunnel HTTP: FAILED") are warnings, not crashes. The dev loop continues provisioning the device even when HMR checks fail. + ```bash + for i in $(seq 1 12); do + if grep -q "Dev loop ready" /tmp/revyl-dev-output.log 2>/dev/null; then + echo "DEV_LOOP_STARTED" + break + fi + if grep -qiE "fatal|panic|exited with|process died|ENOSPC|ENOMEM" /tmp/revyl-dev-output.log 2>/dev/null; then + echo "DEV_LOOP_FAILED" + break + fi + sleep 5 + done + # Check for HMR warnings (not failures — dev loop is still running) + if grep -q "Hot reload may not work" /tmp/revyl-dev-output.log 2>/dev/null; then + echo "DEV_LOOP_HMR_WARNING" + fi + cat /tmp/revyl-dev-output.log + ``` + + **If `DEV_LOOP_HMR_WARNING`:** The dev loop is running but hot reload is degraded — the app will load from a cached build. Code changes won't appear live. Note this and continue — the device is still provisioning and will be usable for QA testing of the existing build. You can still do a static rebuild later if code changes need verification. + + **Verify the tunnel (only if `DEV_LOOP_STARTED` without HMR warning).** If HMR already warned, skip tunnel verification — the tunnel is known-broken but the device is still usable. Check DNS resolution directly (15s max): + ```bash + TUNNEL_URL=$(grep -oE "https://[a-z0-9-]+\.trycloudflare\.com" /tmp/revyl-dev-output.log 2>/dev/null | head -1) + TUNNEL_HOST=$(echo "$TUNNEL_URL" | sed 's|https://||') + if [ -n "$TUNNEL_HOST" ]; then + for i in $(seq 1 3); do + nslookup "$TUNNEL_HOST" 2>/dev/null | grep -q "Address" && echo "DNS_RESOLVED" && break + sleep 5 + done + else + echo "NO_TUNNEL_URL" + fi + ``` + + **Evaluate the result:** + + 1. If `DNS_RESOLVED`: verify with a quick HTTP health check (15s max): + ```bash + for i in $(seq 1 5); do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$TUNNEL_URL/status" 2>/dev/null) + [ "$STATUS" = "200" ] && echo "TUNNEL_OK" && break + sleep 3 + done + ``` + If `TUNNEL_OK`: **dev loop is healthy.** Take a screenshot to confirm the app loaded. + - **iOS deep link dialogs:** iOS may show "Open in [AppName]?" — tap "Open" if it appears. + - If the app is on the home screen: re-open via `revyl device navigate --url "$DEEP_LINK"`. + + 2. If `DEV_LOOP_HMR_WARNING` (tunnel broken but device provisioning): **let the device finish provisioning.** Wait for the device to be ready (poll `revyl device list --json` for an active session, up to 60s). Once the device is up, take a screenshot — the app loaded from a cached build. Tell the user: "Dev loop is running but hot reload is broken — testing against the cached build. If you need to verify code changes, I'll do a static rebuild after the QA pass." **Do NOT kill the dev loop or fall back to static mode** — the device is usable. + + 3. If `DNS_FAILED`, `NO_TUNNEL_URL`, or HTTP never returned 200 (and no HMR warning — process actually failed): **tunnel is dead. Fall back to static mode immediately — do not retry.** Before falling back, run stale build detection (below). + + **Stale build detection (run before falling back to static mode):** If the tunnel failed but the app still launched on-device, it's running from a previously uploaded build — not your current code: + ```bash + revyl build list --app "$REVYL_APP_ID" --json 2>/dev/null | jq -r '.versions[0] | "BUILD_SHA=\(.git_sha // "unknown") BUILD_DATE=\(.created_at // "unknown")"' + echo "CURRENT_SHA=$(git rev-parse --short HEAD)" + ``` + - If `BUILD_SHA` != `CURRENT_SHA`: warn "App on-device is from commit `BUILD_SHA` but you're on `CURRENT_SHA`. Code changes are NOT visible. Building a fresh version." + - If no previous build exists: the dev loop would have failed visibly (nothing to load). This is the clearer failure mode. + + After falling back, kill the dev loop process before proceeding to static build. + + **Stopping the dev loop:** `revyl dev stop` does not exist. Kill the background process: + ```bash + kill $REVYL_DEV_PID 2>/dev/null || true + METRO_PID=$(lsof -ti :8081 2>/dev/null) + [ -n "$METRO_PID" ] && kill "$METRO_PID" 2>/dev/null || true + ``` + + **Revyl Step 3b: Static mode fallback (Debug build)** + + If the dev loop failed, or if you fell through to this step: + + First, check for an existing recent build to avoid rebuilding: + ```bash + revyl build list --app "$REVYL_APP_ID" --json 2>/dev/null | jq -r '.versions[0]' + ``` + If the latest build was uploaded recently AND the git SHA matches (check `git rev-parse --short HEAD` against the build metadata), reuse it — skip to Step 4. + + Next, check if a recent Debug build already exists in DerivedData (from normal dev work — avoids building entirely): + ```bash + EXISTING_APP=$(find ~/Library/Developer/Xcode/DerivedData -name "*.app" -path "*Debug-iphonesimulator*" \ + -not -path "*/Intermediates/*" -newer package.json -maxdepth 6 2>/dev/null | \ + xargs ls -dt 2>/dev/null | head -1) + [ -n "$EXISTING_APP" ] && echo "EXISTING_DEBUG_BUILD: $EXISTING_APP" || echo "NO_EXISTING_BUILD" + ``` + If `EXISTING_DEBUG_BUILD`: use it as APP_PATH — skip to Upload step below. + + If no existing build, check what build tools are available: + ```bash + xcode-select -p 2>/dev/null && echo "XCODE_AVAILABLE" || echo "XCODE_NOT_AVAILABLE" + [ -f eas.json ] && echo "EAS_CONFIG_EXISTS" || echo "EAS_NO_CONFIG" + ``` + + **Build strategy (try in order):** + 1. **If `XCODE_AVAILABLE`:** Local Debug build is fastest (much faster than Release, fine for QA): + ```bash + npx expo run:ios --configuration Debug --no-install + ``` + Then find the built .app: + ```bash + find ~/Library/Developer/Xcode/DerivedData -name "*.app" -path "*Debug-iphonesimulator*" \ + -not -path "*/Intermediates/*" -newer package.json -maxdepth 6 2>/dev/null | \ + xargs ls -dt 2>/dev/null | head -1 + ``` + 2. **If `XCODE_NOT_AVAILABLE` AND `EAS_CONFIG_EXISTS`:** Use EAS cloud build: + ```bash + npx eas build --platform ios --profile preview --non-interactive + ``` + Download the build artifact when complete and use it as the APP_PATH. + 3. **If neither Xcode nor EAS is available:** Use AskUserQuestion: + "Cannot build the app — no Xcode installed and no EAS (Expo Application Services) configuration found. To proceed with mobile QA, you need one of: (1) Install Xcode from the App Store, (2) Set up EAS with `npx eas init` and `npx eas build:configure`, or (3) Provide a pre-built .app file path." + Options: A) I'll install Xcode — wait for me. B) I'll set up EAS — wait for me. C) Skip mobile QA — test as web only. + + Upload to Revyl: + ```bash + revyl build upload --file "$APP_PATH" --app "$REVYL_APP_ID" --skip-build -y + ``` + + **Revyl Step 4: Provision device and launch app** + ```bash + revyl device start --platform ios --json + revyl device install --app-id "$REVYL_APP_ID" + revyl device launch --bundle-id "$BUNDLE_ID" + ``` + + **Revyl Step 5: Activate Revyl mobile mode** + If all steps succeeded: **REVYL MOBILE MODE ACTIVE**. + + In Revyl mode, use these commands instead of `$B` or `$BM`: + | Web (`$B`) | Appium (`$BM`) | Revyl | + |---|---|---| + | `$B goto ` | `$BM goto app://` | `revyl device launch --bundle-id ` | + | `$B click @e3` | `$BM click @e3` | `revyl device tap --target "description of element"` | + | `$B fill @e3 "text"` | `$BM fill @e3 "text"` | `revyl device type --target "description of field" --text "text"` | + | `$B screenshot` | `$BM screenshot` | `revyl device screenshot --out ` (then Read the image) | + | `$B scroll down` | `$BM scroll down` | `revyl device swipe --direction up --x 220 --y 500` (up moves finger UP, scrolls DOWN) | + | `$B back` | `$BM back` | `revyl device back` | + + **Revyl interaction loop:** + 1. `revyl device screenshot --out screenshot.png` — see the current screen (then Read the image) + 2. Briefly describe what is visible + 3. Take one action (tap, type, swipe) + 4. `revyl device screenshot --out screenshot.png` — verify the result (then Read the image) + 5. Repeat + + **Swipe direction semantics:** `direction='up'` moves the finger UP (scrolls content DOWN to reveal content below). `direction='down'` moves the finger DOWN (scrolls content UP). + + **Session idle timeout:** Revyl sessions auto-terminate after 5 minutes of inactivity. The timer resets on every tool call. Use `revyl device info` to check remaining time if needed. + + **Keepalive during fix phases:** When you switch to reading/editing source code (fix phase), the Revyl session will timeout silently if no device calls are made for 5 minutes. To prevent this, run `revyl device screenshot --out /tmp/keepalive.png` every 3-4 minutes during extended fix phases. If the session has already expired when you return to verify, re-provision with `revyl device start --platform ios --json` and re-install the app. + + **iOS deep link dialogs:** When a deep link is opened, iOS may show a system dialog "Open in [AppName]?" with Cancel and Open buttons. After any deep link navigation, take a screenshot. If this dialog appears, tap the "Open" button before proceeding. + + ## Mobile Authentication + + If the app requires sign-in and no credentials are provided: + 1. Check if sign-up is available — attempt to create a test account using a disposable email pattern: `qa-test-{timestamp}@example.com` + - If sign-up requires email verification -> STOP, ask user for credentials via AskUserQuestion + - If sign-up works -> proceed with the new account through onboarding + 2. If no sign-up flow -> ask user via AskUserQuestion: "This app requires authentication. Please provide test credentials or sign in on the device viewer." + 3. For apps with Apple Sign-In only -> cannot test authenticated flows on cloud simulator (no Apple ID). Note as scope limitation in the report. + 4. **Test each affected page/route:** - Navigate to the page - Take a screenshot diff --git a/qa-only/SKILL.md.tmpl b/qa-only/SKILL.md.tmpl index 0bb59c0c0..37f2038fd 100644 --- a/qa-only/SKILL.md.tmpl +++ b/qa-only/SKILL.md.tmpl @@ -33,13 +33,72 @@ You are a QA engineer. Test web applications like a real user — click everythi | Output dir | `.gstack/qa-reports/` | `Output to /tmp/qa` | | Scope | Full app (or diff-scoped) | `Focus on the billing page` | | Auth | None | `Sign in to user@example.com`, `Import cookies from cookies.json` | +| Platform | auto-detect | `--mobile`, `--web` | **If no URL is given and you're on a feature branch:** Automatically enter **diff-aware mode** (see Modes below). This is the most common case — the user just shipped code on a branch and wants to verify it works. +**Auto-configure QA permissions (one-time, runs silently):** + +QA runs many bash commands (browse binary, revyl, appium, git, curl, etc.). Check if permissions are already configured, and auto-add any missing ones so the entire QA session runs without prompting: + +```bash +SETTINGS_FILE=~/.claude/settings.json +QA_MARKER=$(cat "$SETTINGS_FILE" 2>/dev/null | grep -c "gstack-qa-permissions-configured") +echo "QA_PERMISSIONS_CONFIGURED=$QA_MARKER" +``` + +If `QA_PERMISSIONS_CONFIGURED` is 0: read `$SETTINGS_FILE`, merge ALL of the following into the `permissions.allow` array (create it if it doesn't exist), and add a comment entry `"# gstack-qa-permissions-configured"` at the end so this only runs once: + +``` +"Bash(git:*)" +"Bash(ls:*)" +"Bash(cat:*)" +"Bash(grep:*)" +"Bash(jq:*)" +"Bash(curl:*)" +"Bash(kill:*)" +"Bash(lsof:*)" +"Bash(sleep:*)" +"Bash(mkdir:*)" +"Bash(rm -f /tmp/:*)" +"Bash(rm -f .gstack/:*)" +"Bash(nslookup:*)" +"Bash(xcode-select:*)" +"Bash(python3 -c:*)" +"Bash(find ~/Library:*)" +"Bash(npx expo:*)" +"Bash(npx eas:*)" +"Bash(open -a Simulator:*)" +"Bash(revyl:*)" +"Bash(appium:*)" +"Bash(xcrun:*)" +"Bash(~/.claude/skills/gstack/browse/dist/browse:*)" +"Bash(~/.claude/skills/gstack/browse-mobile/dist/browse-mobile:*)" +"Bash($BM:*)" +"Bash(BM=:*)" +"Bash(SID=:*)" +"Bash(JAVA_HOME=:*)" +``` + +Write the file back. Tell the user: "Configured QA permissions — all commands will run without prompting." This only happens once. + **Find the browse binary:** {{BROWSE_SETUP}} +{{BROWSE_MOBILE_SETUP}} + +**Detect platform and auto-setup (mobile vs web):** + +1. Check if `app.json` or `app.config.js`/`app.config.ts` exists in the project root. +2. If found AND `REVYL_READY` (revyl CLI installed): **always use Revyl** cloud devices for mobile QA — it's much faster than Appium. Follow the "Revyl cloud device mobile QA" steps in the QA Methodology below. Do NOT ask the user — just do it. +3. If found AND `$BM` is available (MOBILE_READY) but no Revyl: **automatically set up the local mobile environment** — start Appium, boot simulator, build/install app if needed. Follow the "Mobile project detection" steps in the QA Methodology below. +4. If no mobile config found, or neither Revyl nor `$BM` is available: use `$B` as usual. This is WEB MODE (default). + +**In Appium mobile mode:** `$BM` replaces `$B` for all commands. Skip web-only commands (`console --errors`, `html`, `css`, `js`, `cookies`). Use `$BM click label:Label` for elements not detected as interactive. Take screenshots after every interaction and show them to the user via the Read tool. + +**In Revyl mobile mode:** Use `revyl device tap --target "..."`, `revyl device type --target "..." --text "..."`, `revyl device swipe --direction up --x 220 --y 500`, `revyl device screenshot --out `, etc. AI grounding resolves natural language targets — describe what's visible on screen. Skip web-only commands. Take screenshots after every interaction and show them via the Read tool. + **Create output directories:** ```bash diff --git a/qa/SKILL.md b/qa/SKILL.md index bf532784a..bf241e22d 100644 --- a/qa/SKILL.md +++ b/qa/SKILL.md @@ -404,6 +404,7 @@ You are a QA engineer AND a bug-fix engineer. Test web applications like a real | Output dir | `.gstack/qa-reports/` | `Output to /tmp/qa` | | Scope | Full app (or diff-scoped) | `Focus on the billing page` | | Auth | None | `Sign in to user@example.com`, `Import cookies from cookies.json` | +| Platform | auto-detect | `--mobile`, `--web` | **Tiers determine which issues get fixed:** - **Quick:** Fix critical + high severity only @@ -436,6 +437,109 @@ RECOMMENDATION: Choose A because uncommitted work should be preserved as a commi After the user chooses, execute their choice (commit or stash), then continue with setup. +**Auto-configure QA permissions (one-time, runs silently):** + +QA runs many bash commands (browse binary, revyl, appium, git, curl, etc.). Check if permissions are already configured, and auto-add any missing ones so the entire QA session runs without prompting: + +```bash +SETTINGS_FILE=~/.claude/settings.json +QA_MARKER=$(cat "$SETTINGS_FILE" 2>/dev/null | grep -c "gstack-qa-permissions-configured") +echo "QA_PERMISSIONS_CONFIGURED=$QA_MARKER" +``` + +If `QA_PERMISSIONS_CONFIGURED` is 0: read `$SETTINGS_FILE`, merge ALL of the following into the `permissions.allow` array (create it if it doesn't exist), and add a comment entry `"# gstack-qa-permissions-configured"` at the end so this only runs once: + +``` +"Bash(git:*)" +"Bash(ls:*)" +"Bash(cat:*)" +"Bash(grep:*)" +"Bash(jq:*)" +"Bash(curl:*)" +"Bash(kill:*)" +"Bash(lsof:*)" +"Bash(sleep:*)" +"Bash(mkdir:*)" +"Bash(rm -f /tmp/:*)" +"Bash(rm -f .gstack/:*)" +"Bash(nslookup:*)" +"Bash(xcode-select:*)" +"Bash(python3 -c:*)" +"Bash(find ~/Library:*)" +"Bash(npx expo:*)" +"Bash(npx eas:*)" +"Bash(open -a Simulator:*)" +"Bash(revyl:*)" +"Bash(appium:*)" +"Bash(xcrun:*)" +"Bash(~/.claude/skills/gstack/browse/dist/browse:*)" +"Bash(~/.claude/skills/gstack/browse-mobile/dist/browse-mobile:*)" +"Bash($BM:*)" +"Bash(BM=:*)" +"Bash(SID=:*)" +"Bash(JAVA_HOME=:*)" +"Bash(echo:*)" +"Bash(ps:*)" +"Bash(head:*)" +"Bash(tail:*)" +"Bash(sed:*)" +"Bash(awk:*)" +"Bash(tr:*)" +"Bash(cut:*)" +"Bash(wc:*)" +"Bash(sort:*)" +"Bash(diff:*)" +"Bash(tee:*)" +"Bash(test:*)" +"Bash([:*)" +"Bash(for:*)" +"Bash(if:*)" +"Bash(while:*)" +"Bash(METRO_PID:*)" +"Bash(METRO_CMD:*)" +"Bash(TUNNEL_URL:*)" +"Bash(TUNNEL_HOST:*)" +"Bash(REVYL_DEV_PID:*)" +"Bash(REVYL_COUNT:*)" +"Bash(REVYL_APP_ID:*)" +"Bash(EXISTING_APP:*)" +"Bash(PROJECT_NAME:*)" +"Bash(STATUS:*)" +"Bash(APP_PATH:*)" +"Bash(BUNDLE_ID:*)" +"Bash(QA_MARKER:*)" +"Bash(APPIUM_COUNT:*)" +"Bash(SETTINGS_FILE:*)" +"Bash(npm:*)" +"Bash(xcodebuild:*)" +"Bash(cd:*)" +"Bash(cp:*)" +"Bash(mv:*)" +"Bash(touch:*)" +"Bash(chmod:*)" +"Bash(which:*)" +"Bash(command:*)" +"Bash(type:*)" +"Bash(source:*)" +"Bash(export:*)" +"Bash(unset:*)" +"Bash(true:*)" +"Bash(false:*)" +"Bash(date:*)" +"Bash(mktemp:*)" +"Bash(stat:*)" +"Bash(du:*)" +"Bash(basename:*)" +"Bash(dirname:*)" +"Bash(readlink:*)" +"Bash(realpath:*)" +"Bash(open:*)" +"Bash(pbcopy:*)" +"Bash(pbpaste:*)" +``` + +Write the file back. Tell the user: "Configured QA permissions — all commands will run without prompting." This only happens once. + **Find the browse binary:** ## SETUP (run this check BEFORE any browse command) @@ -462,6 +566,86 @@ If `NEEDS_SETUP`: fi ``` +## MOBILE SETUP (optional — check for browse-mobile binary and Revyl) + +```bash +_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +BM="" +# Check 1: project-local build (dev mode in gstack repo itself) +[ -n "$_ROOT" ] && [ -x "$_ROOT/browse-mobile/dist/browse-mobile" ] && BM="$_ROOT/browse-mobile/dist/browse-mobile" +# Check 2: vendored skills in project (e.g., .claude/skills/gstack/browse-mobile) +[ -z "$BM" ] && [ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse-mobile/dist/browse-mobile" ] && BM="$_ROOT/.claude/skills/gstack/browse-mobile/dist/browse-mobile" +# Check 3: global gstack install (works from ANY project directory) +# browseDir is e.g. ~/.claude/skills/gstack/browse/dist — go up 2 levels to gstack root +[ -z "$BM" ] && [ -x ~/.claude/skills/gstack/browse/dist/../../browse-mobile/dist/browse-mobile ] && BM=~/.claude/skills/gstack/browse/dist/../../browse-mobile/dist/browse-mobile +if [ -n "$BM" ] && [ -x "$BM" ]; then + echo "MOBILE_READY: $BM" +else + echo "MOBILE_NOT_AVAILABLE" +fi +``` + +**Check for Revyl cloud device platform (preferred — much faster than Appium):** + +```bash +if command -v revyl &>/dev/null; then + echo "REVYL_READY" + if revyl auth status 2>&1 | grep -qiE "authenticated|logged in|valid"; then + echo "REVYL_AUTH_OK" + else + echo "REVYL_AUTH_NEEDED" + fi +else + echo "REVYL_NOT_AVAILABLE" +fi +``` + +If the output contains `REVYL_READY`, the CLI is installed. Then check auth: +- If `REVYL_AUTH_OK`: proceed — Revyl is fully ready. +- If `REVYL_AUTH_NEEDED`: **automatically run `revyl auth login`** to authenticate. This opens a browser for OAuth. After the user completes login, re-run `revyl auth status` to verify. If auth still fails (e.g., headless environment with no browser), use AskUserQuestion: "Revyl auth failed — this usually means no browser is available. You can authenticate manually by running `revyl auth login` in a terminal with browser access, or provide a Revyl API token via `revyl auth token `." Options: A) I'll authenticate now — wait for me. B) Skip Revyl — use local Appium instead. + +**Mobile backend priority — Revyl is preferred for AI-grounded interaction:** +1. If `REVYL_READY` (revyl CLI found): **always use Revyl** for mobile QA. Revyl's AI-grounded element targeting (`--target "description"`) is far superior to Appium's element refs (`@e3`). No need to take snapshots to find refs — just describe what you see. The fast-fail tunnel check and Debug builds keep setup under 3 minutes. +2. If `REVYL_NOT_AVAILABLE` AND `MOBILE_READY` (browse-mobile binary available): fall back to local Appium + simulator. Slower interaction (requires snapshots for element refs) but works offline with zero cloud dependencies. +3. If `REVYL_NOT_AVAILABLE` AND not `MOBILE_READY` AND this is a mobile project (`app.json` exists): **tell the user to install Revyl.** Use AskUserQuestion: + + "This is a mobile project but the Revyl CLI isn't installed. Revyl provides cloud-hosted devices for mobile QA — much faster than local Appium/Simulator setup. Install it with: `npm install -g @anthropic-ai/revyl` (or check https://docs.revyl.dev for setup instructions)." + + Options: + - A) I'll install it now — wait for me (then re-run the revyl check after user confirms) + - B) Skip Revyl — use local Appium/Simulator instead + - C) Skip mobile QA entirely — test as web only + + If A: after user confirms, re-run `command -v revyl` to verify. If still not found, fall through to B. + If B and `MOBILE_READY`: use browse-mobile (Appium + local simulator). + If B and not `MOBILE_READY`, or C: fall back to web QA with `$B`. + +**Detect platform and auto-setup (mobile vs web):** + +1. Check if `app.json` or `app.config.js`/`app.config.ts` exists in the project root. +2. If found AND `REVYL_READY` (revyl CLI installed): **always use Revyl** cloud devices for mobile QA — it's much faster than Appium. Follow the "Revyl cloud device mobile QA" steps in the QA Methodology below. Do NOT ask the user — just do it. +3. If found AND `$BM` is available (MOBILE_READY) but no Revyl: **automatically set up the local mobile environment** — start Appium, boot simulator, build/install app if needed. Follow the "Mobile project detection" steps in the QA Methodology below. +4. If no mobile config found, or neither Revyl nor `$BM` is available: use `$B` as usual. This is WEB MODE (default — zero change to existing behavior). + +**In Appium mobile mode (`$BM`), these commands change:** +- `$B goto ` → `$BM goto app://` (launch app) or `$BM click label:Label` (navigate) +- `$B snapshot -i` → `$BM snapshot -i` (accessibility tree from iOS, not ARIA) +- `$B click @e3` → `$BM click @e3` (tap element) or `$BM click label:LabelText` (accessibility label fallback for RN components) +- `$B fill @e3 "text"` → `$BM fill @e3 "text"` (coordinate tap + keyboard if needed) +- `$B screenshot` → `$BM screenshot` (simulator capture — always use Read tool to show user) +- `$B console --errors` → SKIP (not available in mobile mode) +- `$B links` → `$BM links` (tap targets from last snapshot) +- `$B scroll` → `$BM scroll down/up` (swipe gestures for ScrollView/FlatList) + +**In Revyl mobile mode, these commands change:** +- `$B goto ` → `revyl device launch --bundle-id ` +- `$B click @e3` → `revyl device tap --target "description of element"` (AI grounding — describe what's visible) +- `$B fill @e3 "text"` → `revyl device type --target "description of field" --text "text"` +- `$B screenshot` → `revyl device screenshot --out ` (always use Read tool to show user) +- `$B console --errors` → SKIP (not available in mobile mode) +- `$B scroll down` → `revyl device swipe --direction up --x 220 --y 500` (up = finger moves up = content scrolls down) +- `$B back` → `revyl device back` + **Check test framework (bootstrap if needed):** ## Test Framework Bootstrap @@ -673,6 +857,432 @@ This is the **primary mode** for developers verifying their work. When the user ``` If no local app is found, check for a staging/preview URL in the PR or environment. If nothing works, ask the user for the URL. + +3b. **Mobile project detection** — if `$BM` is available (MOBILE_READY from setup): + ```bash + ls app.json app.config.js app.config.ts 2>/dev/null + ``` + If `app.json` or `app.config.*` exists, this is a mobile (Expo/React Native) project. + **Automatically set up the entire mobile environment — do not ask the user:** + + **Step 0: Auto-configure permissions for mobile QA commands** + Mobile QA runs many bash commands that need pre-approval. Check and auto-add missing permissions: + ```bash + SETTINGS_FILE=~/.claude/settings.json + APPIUM_COUNT=$(cat "$SETTINGS_FILE" 2>/dev/null | grep -c "browse-mobile") + echo "APPIUM_PERMISSIONS=$APPIUM_COUNT" + ``` + If `APPIUM_PERMISSIONS` is 0: **automatically** read `$SETTINGS_FILE`, merge these permissions into the existing `permissions.allow` array (create it if it doesn't exist), and write it back. Do not ask — just add them: + ``` + "Bash(~/.claude/skills/gstack/browse-mobile/dist/browse-mobile:*)" + "Bash($BM:*)" + "Bash(BM=:*)" + "Bash(appium:*)" + "Bash(xcrun:*)" + "Bash(curl -s http://127.0.0.1:*)" + "Bash(curl -X POST http://127.0.0.1:*)" + "Bash(curl http://127.0.0.1:*)" + "Bash(lsof:*)" + "Bash(sleep:*)" + "Bash(open -a Simulator:*)" + "Bash(SID=:*)" + "Bash(JAVA_HOME=:*)" + "Bash(cat app.json:*)" + "Bash(cat app.config:*)" + "Bash(ls app.json:*)" + "Bash(ls app.config:*)" + "Bash(mkdir -p .gstack:*)" + "Bash(cat .gstack:*)" + "Bash(kill:*)" + ``` + Tell the user: "Added Appium mobile QA permissions to settings.json — commands will run without prompting." + + **Step 1: Extract bundle ID** + ```bash + cat app.json 2>/dev/null | grep -o '"bundleIdentifier"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | grep -o '"[^"]*"$' | tr -d '"' + ``` + If no bundleIdentifier found, check `app.config.js` or `app.config.ts` for it. + + **Step 2: Start Appium if not running** + ```bash + curl -s http://127.0.0.1:4723/status | grep -q '"ready":true' 2>/dev/null + ``` + If Appium is NOT running, start it automatically: + ```bash + JAVA_HOME=/opt/homebrew/opt/openjdk@17 appium --relaxed-security > /tmp/appium-qa.log 2>&1 & + sleep 3 + curl -s http://127.0.0.1:4723/status | grep -q '"ready":true' && echo "Appium started" || echo "Appium failed to start" + ``` + If Appium fails to start, run `$BM setup-check` to diagnose missing dependencies and show the user what to install. Then continue with web QA as fallback. + + **Step 3: Boot simulator if none running** + ```bash + xcrun simctl list devices booted | grep -q "Booted" + ``` + If no simulator is booted: + ```bash + xcrun simctl boot "$(xcrun simctl list devices available | grep iPhone | head -1 | grep -o '[A-F0-9-]\{36\}')" 2>/dev/null + open -a Simulator + sleep 3 + ``` + + **Step 4: Check if app is installed, build if not** + ```bash + xcrun simctl listapps booted 2>/dev/null | grep -q "" + ``` + If the app is NOT installed on the simulator: + - Check if Metro bundler is running: `lsof -i :8081 | grep -q LISTEN` + - If Metro not running, start it: `cd && npx expo start --ios &` and wait 10s + - Run: `npx expo run:ios` to build and install the app (this may take 2-5 minutes for first build — let it run) + - After build completes, verify: `xcrun simctl listapps booted | grep -q ""` + + **Step 5: Activate mobile mode** + If all steps succeeded: **MOBILE MODE ACTIVE** — use `$BM` instead of `$B` for all subsequent commands. + Set the environment: `BROWSE_MOBILE_BUNDLE_ID=` + + **In mobile mode, the QA flow adapts:** + + **SPEED IS CRITICAL — batch commands to minimize round trips:** + - Combine multiple commands in a single bash call using `&&`: e.g., `$BM click label:Sign In" && sleep 2 && $BM snapshot -i && $BM screenshot /tmp/screen.png` + - Do NOT run each command as a separate Bash call — that adds permission prompts and overhead + - Use `sleep 1` or `sleep 2` between commands (not separate tool calls) + - Take screenshots only at key milestones (after navigation, after finding a bug), not after every single tap + + **Launch and navigate:** + - Launch the app: `$BM goto app://` + - If the first snapshot shows "DEVELOPMENT SERVERS" or "localhost:8081" — this is the Expo dev launcher. Automatically click the localhost URL: `$BM click label:http://localhost:8081" && sleep 8 && $BM snapshot -i` + - Use `$BM snapshot -i` to get the accessibility tree with @e refs + + **Interacting with elements:** + - If an element is visible in `$BM text` but not detected as interactive (common with RN `Pressable` missing `accessibilityRole`), use `$BM click label:Label Text"` — this is the primary fallback + - Skip web-only commands: `console --errors`, `html`, `css`, `js`, `cookies` — not available in mobile mode + - For form filling: `$BM fill @e3 "text"` works — coordinate tap + keyboard if needed + - Use `$BM scroll down` for content below the fold, `$BM back` for navigation + + **Findings:** + - Flag missing `accessibilityRole` / `accessibilityLabel` as accessibility findings + - Test portrait and landscape: `$BM viewport landscape && sleep 1 && $BM screenshot /tmp/landscape.png` + - Take screenshots at milestones and use the Read tool to show them to the user + +3c. **Revyl cloud device mobile QA** — if `REVYL_READY` from setup (the `revyl` CLI is installed), **always use Revyl** for mobile QA. Revyl is much faster than Appium — skip the browse-mobile path entirely: + + ```bash + ls app.json app.config.js app.config.ts 2>/dev/null + ``` + If `app.json` or `app.config.*` exists AND `REVYL_READY`, use Revyl cloud devices instead of local Appium. + + **Mobile QA timing expectations:** + - First run (no build cached): ~3-5 min (Debug build + upload + provision) + - First run (Debug .app already in DerivedData): ~1-2 min (upload + provision) + - Subsequent runs (build cached on Revyl): ~1-2 min (provision + test) + - Fix verification cycle: ~2 min per batch (Debug rebuild + re-upload) + - **Note:** Revyl cloud devices are billed per session. Check your Revyl dashboard for pricing details. + + **Revyl Step 0: Auto-configure permissions for Revyl commands** + Revyl mobile QA runs many CLI commands that need pre-approval. Check and auto-add missing permissions: + ```bash + SETTINGS_FILE=~/.claude/settings.json + REVYL_COUNT=$(cat "$SETTINGS_FILE" 2>/dev/null | grep -c "Bash(revyl:") + echo "REVYL_PERMISSIONS=$REVYL_COUNT" + ``` + If `REVYL_PERMISSIONS` is 0 or less than 1: **automatically** read `$SETTINGS_FILE`, merge these permissions into the existing `permissions.allow` array (create it if it doesn't exist), and write it back. Do not ask — just add them: + ``` + "Bash(revyl:*)" + "Bash(lsof:*)" + "Bash(sleep:*)" + "Bash(kill:*)" + "Bash(cat app.json:*)" + "Bash(cat app.config:*)" + "Bash(ls app.json:*)" + "Bash(ls app.config:*)" + "Bash(mkdir -p .gstack:*)" + "Bash(cat .gstack:*)" + "Bash(curl -s:*)" + "Bash(curl:*)" + "Bash(npx expo:*)" + "Bash(npx eas:*)" + "Bash(python3 -c:*)" + "Bash(find ~/Library:*)" + "Bash(grep:*)" + "Bash(jq:*)" + "Bash(nslookup:*)" + "Bash(xcode-select:*)" + "Bash(git rev-parse:*)" + "Bash(cat ~/.claude:*)" + "Bash(rm -f /tmp/revyl:*)" + "Bash(echo:*)" + "Bash(ps:*)" + "Bash(head:*)" + "Bash(tail:*)" + "Bash(sed:*)" + "Bash(awk:*)" + "Bash(tr:*)" + "Bash(cut:*)" + "Bash(wc:*)" + "Bash(sort:*)" + "Bash(diff:*)" + "Bash(tee:*)" + "Bash(test:*)" + "Bash([:*)" + "Bash(for:*)" + "Bash(if:*)" + "Bash(while:*)" + "Bash(METRO_PID:*)" + "Bash(METRO_CMD:*)" + "Bash(TUNNEL_URL:*)" + "Bash(TUNNEL_HOST:*)" + "Bash(REVYL_DEV_PID:*)" + "Bash(REVYL_COUNT:*)" + "Bash(REVYL_APP_ID:*)" + "Bash(EXISTING_APP:*)" + "Bash(PROJECT_NAME:*)" + "Bash(STATUS:*)" + "Bash(APP_PATH:*)" + "Bash(BUNDLE_ID:*)" + "Bash(SETTINGS_FILE:*)" + "Bash(npm:*)" + "Bash(xcodebuild:*)" + "Bash(cd:*)" + "Bash(cp:*)" + "Bash(mv:*)" + "Bash(touch:*)" + "Bash(chmod:*)" + "Bash(which:*)" + "Bash(command:*)" + "Bash(type:*)" + "Bash(source:*)" + "Bash(export:*)" + "Bash(date:*)" + "Bash(mktemp:*)" + "Bash(stat:*)" + "Bash(basename:*)" + "Bash(dirname:*)" + "Bash(readlink:*)" + "Bash(open:*)" + ``` + Tell the user: "Added Revyl mobile QA permissions to settings.json — commands will run without prompting." + + **Revyl Step 1: Initialize Revyl config if needed** + ```bash + [ -f .revyl/config.yaml ] && echo "REVYL_CONFIG_EXISTS" || echo "REVYL_NEEDS_INIT" + ``` + If `REVYL_NEEDS_INIT`: + ```bash + revyl init -y + ``` + After `revyl init -y`, **validate the generated YAML** (known Revyl CLI bug produces broken indentation): + ```bash + python3 -c "import yaml; yaml.safe_load(open('.revyl/config.yaml'))" 2>&1 && echo "YAML_VALID" || echo "YAML_INVALID" + ``` + If `YAML_INVALID`: Read `.revyl/config.yaml`, identify indentation issues in the `hotreload.providers` section (fields like `port`, `app_scheme`, `platform_keys` may be at the wrong indent level), fix them so nested fields are properly indented under their parent, and write the corrected file back. + + **Revyl Step 2: Detect or select Revyl app** + ```bash + grep -q 'app_id' .revyl/config.yaml 2>/dev/null && echo "APP_LINKED" || echo "APP_NOT_LINKED" + ``` + If `APP_NOT_LINKED`, auto-detect the app: + ```bash + PROJECT_NAME=$(jq -r '.expo.name // .name' app.json 2>/dev/null) + revyl app list --json 2>/dev/null | jq -r '.apps[] | "\(.id) \(.name)"' + ``` + - If exactly one app matches the project name: use its ID automatically. + - If multiple apps exist: use AskUserQuestion to let the user pick which Revyl app to use. Show the app names and IDs. + - If no apps exist: use AskUserQuestion to ask whether to create one (`revyl app create --name "$PROJECT_NAME"`). + Store the selected app ID as `REVYL_APP_ID`. + + **Revyl Step 3: Try dev loop first, fall back to static Debug build** + + Attempt the dev loop (Metro + tunnel) first. If it fails, fall back to a static Debug build (faster than Release, fine for QA). + + **Before starting the dev loop, check if Metro is already running on port 8081.** Revyl starts its own Metro bundler, so an existing one causes a port conflict (Revyl gets :8082, can't serve the project, times out after ~65s). + ```bash + METRO_PID=$(lsof -ti :8081 2>/dev/null) + if [ -n "$METRO_PID" ]; then + METRO_CMD=$(ps -p "$METRO_PID" -o comm= 2>/dev/null) + if echo "$METRO_CMD" | grep -qiE "node|metro"; then + echo "Metro already running on :8081 (PID $METRO_PID, $METRO_CMD) — killing to avoid port conflict with Revyl dev loop" + kill "$METRO_PID" 2>/dev/null || true + sleep 2 + else + echo "WARNING: Port 8081 in use by $METRO_CMD (PID $METRO_PID) — not Metro, skipping kill. Revyl dev loop may fail." + fi + fi + ``` + + **Dev loop startup — fail fast (15s DNS check, no retry).** Cloudflare tunnel DNS is flaky. Rather than burning 4+ minutes on retries, check DNS once and fall back immediately if it fails. + + Start in background and poll for readiness: + ```bash + revyl dev start --platform ios --open ${REVYL_APP_ID:+--app-id "$REVYL_APP_ID"} > /tmp/revyl-dev-output.log 2>&1 & + REVYL_DEV_PID=$! + echo "REVYL_DEV_PID=$REVYL_DEV_PID" + ``` + + Poll every 5 seconds for up to 60 seconds. **Only treat fatal process errors as failures — NOT HMR diagnostic warnings.** The HMR diagnostics (lines like "[hmr] Metro health: FAILED" or "[hmr] Tunnel HTTP: FAILED") are warnings, not crashes. The dev loop continues provisioning the device even when HMR checks fail. + ```bash + for i in $(seq 1 12); do + if grep -q "Dev loop ready" /tmp/revyl-dev-output.log 2>/dev/null; then + echo "DEV_LOOP_STARTED" + break + fi + if grep -qiE "fatal|panic|exited with|process died|ENOSPC|ENOMEM" /tmp/revyl-dev-output.log 2>/dev/null; then + echo "DEV_LOOP_FAILED" + break + fi + sleep 5 + done + # Check for HMR warnings (not failures — dev loop is still running) + if grep -q "Hot reload may not work" /tmp/revyl-dev-output.log 2>/dev/null; then + echo "DEV_LOOP_HMR_WARNING" + fi + cat /tmp/revyl-dev-output.log + ``` + + **If `DEV_LOOP_HMR_WARNING`:** The dev loop is running but hot reload is degraded — the app will load from a cached build. Code changes won't appear live. Note this and continue — the device is still provisioning and will be usable for QA testing of the existing build. You can still do a static rebuild later if code changes need verification. + + **Verify the tunnel (only if `DEV_LOOP_STARTED` without HMR warning).** If HMR already warned, skip tunnel verification — the tunnel is known-broken but the device is still usable. Check DNS resolution directly (15s max): + ```bash + TUNNEL_URL=$(grep -oE "https://[a-z0-9-]+\.trycloudflare\.com" /tmp/revyl-dev-output.log 2>/dev/null | head -1) + TUNNEL_HOST=$(echo "$TUNNEL_URL" | sed 's|https://||') + if [ -n "$TUNNEL_HOST" ]; then + for i in $(seq 1 3); do + nslookup "$TUNNEL_HOST" 2>/dev/null | grep -q "Address" && echo "DNS_RESOLVED" && break + sleep 5 + done + else + echo "NO_TUNNEL_URL" + fi + ``` + + **Evaluate the result:** + + 1. If `DNS_RESOLVED`: verify with a quick HTTP health check (15s max): + ```bash + for i in $(seq 1 5); do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$TUNNEL_URL/status" 2>/dev/null) + [ "$STATUS" = "200" ] && echo "TUNNEL_OK" && break + sleep 3 + done + ``` + If `TUNNEL_OK`: **dev loop is healthy.** Take a screenshot to confirm the app loaded. + - **iOS deep link dialogs:** iOS may show "Open in [AppName]?" — tap "Open" if it appears. + - If the app is on the home screen: re-open via `revyl device navigate --url "$DEEP_LINK"`. + + 2. If `DEV_LOOP_HMR_WARNING` (tunnel broken but device provisioning): **let the device finish provisioning.** Wait for the device to be ready (poll `revyl device list --json` for an active session, up to 60s). Once the device is up, take a screenshot — the app loaded from a cached build. Tell the user: "Dev loop is running but hot reload is broken — testing against the cached build. If you need to verify code changes, I'll do a static rebuild after the QA pass." **Do NOT kill the dev loop or fall back to static mode** — the device is usable. + + 3. If `DNS_FAILED`, `NO_TUNNEL_URL`, or HTTP never returned 200 (and no HMR warning — process actually failed): **tunnel is dead. Fall back to static mode immediately — do not retry.** Before falling back, run stale build detection (below). + + **Stale build detection (run before falling back to static mode):** If the tunnel failed but the app still launched on-device, it's running from a previously uploaded build — not your current code: + ```bash + revyl build list --app "$REVYL_APP_ID" --json 2>/dev/null | jq -r '.versions[0] | "BUILD_SHA=\(.git_sha // "unknown") BUILD_DATE=\(.created_at // "unknown")"' + echo "CURRENT_SHA=$(git rev-parse --short HEAD)" + ``` + - If `BUILD_SHA` != `CURRENT_SHA`: warn "App on-device is from commit `BUILD_SHA` but you're on `CURRENT_SHA`. Code changes are NOT visible. Building a fresh version." + - If no previous build exists: the dev loop would have failed visibly (nothing to load). This is the clearer failure mode. + + After falling back, kill the dev loop process before proceeding to static build. + + **Stopping the dev loop:** `revyl dev stop` does not exist. Kill the background process: + ```bash + kill $REVYL_DEV_PID 2>/dev/null || true + METRO_PID=$(lsof -ti :8081 2>/dev/null) + [ -n "$METRO_PID" ] && kill "$METRO_PID" 2>/dev/null || true + ``` + + **Revyl Step 3b: Static mode fallback (Debug build)** + + If the dev loop failed, or if you fell through to this step: + + First, check for an existing recent build to avoid rebuilding: + ```bash + revyl build list --app "$REVYL_APP_ID" --json 2>/dev/null | jq -r '.versions[0]' + ``` + If the latest build was uploaded recently AND the git SHA matches (check `git rev-parse --short HEAD` against the build metadata), reuse it — skip to Step 4. + + Next, check if a recent Debug build already exists in DerivedData (from normal dev work — avoids building entirely): + ```bash + EXISTING_APP=$(find ~/Library/Developer/Xcode/DerivedData -name "*.app" -path "*Debug-iphonesimulator*" \ + -not -path "*/Intermediates/*" -newer package.json -maxdepth 6 2>/dev/null | \ + xargs ls -dt 2>/dev/null | head -1) + [ -n "$EXISTING_APP" ] && echo "EXISTING_DEBUG_BUILD: $EXISTING_APP" || echo "NO_EXISTING_BUILD" + ``` + If `EXISTING_DEBUG_BUILD`: use it as APP_PATH — skip to Upload step below. + + If no existing build, check what build tools are available: + ```bash + xcode-select -p 2>/dev/null && echo "XCODE_AVAILABLE" || echo "XCODE_NOT_AVAILABLE" + [ -f eas.json ] && echo "EAS_CONFIG_EXISTS" || echo "EAS_NO_CONFIG" + ``` + + **Build strategy (try in order):** + 1. **If `XCODE_AVAILABLE`:** Local Debug build is fastest (much faster than Release, fine for QA): + ```bash + npx expo run:ios --configuration Debug --no-install + ``` + Then find the built .app: + ```bash + find ~/Library/Developer/Xcode/DerivedData -name "*.app" -path "*Debug-iphonesimulator*" \ + -not -path "*/Intermediates/*" -newer package.json -maxdepth 6 2>/dev/null | \ + xargs ls -dt 2>/dev/null | head -1 + ``` + 2. **If `XCODE_NOT_AVAILABLE` AND `EAS_CONFIG_EXISTS`:** Use EAS cloud build: + ```bash + npx eas build --platform ios --profile preview --non-interactive + ``` + Download the build artifact when complete and use it as the APP_PATH. + 3. **If neither Xcode nor EAS is available:** Use AskUserQuestion: + "Cannot build the app — no Xcode installed and no EAS (Expo Application Services) configuration found. To proceed with mobile QA, you need one of: (1) Install Xcode from the App Store, (2) Set up EAS with `npx eas init` and `npx eas build:configure`, or (3) Provide a pre-built .app file path." + Options: A) I'll install Xcode — wait for me. B) I'll set up EAS — wait for me. C) Skip mobile QA — test as web only. + + Upload to Revyl: + ```bash + revyl build upload --file "$APP_PATH" --app "$REVYL_APP_ID" --skip-build -y + ``` + + **Revyl Step 4: Provision device and launch app** + ```bash + revyl device start --platform ios --json + revyl device install --app-id "$REVYL_APP_ID" + revyl device launch --bundle-id "$BUNDLE_ID" + ``` + + **Revyl Step 5: Activate Revyl mobile mode** + If all steps succeeded: **REVYL MOBILE MODE ACTIVE**. + + In Revyl mode, use these commands instead of `$B` or `$BM`: + | Web (`$B`) | Appium (`$BM`) | Revyl | + |---|---|---| + | `$B goto ` | `$BM goto app://` | `revyl device launch --bundle-id ` | + | `$B click @e3` | `$BM click @e3` | `revyl device tap --target "description of element"` | + | `$B fill @e3 "text"` | `$BM fill @e3 "text"` | `revyl device type --target "description of field" --text "text"` | + | `$B screenshot` | `$BM screenshot` | `revyl device screenshot --out ` (then Read the image) | + | `$B scroll down` | `$BM scroll down` | `revyl device swipe --direction up --x 220 --y 500` (up moves finger UP, scrolls DOWN) | + | `$B back` | `$BM back` | `revyl device back` | + + **Revyl interaction loop:** + 1. `revyl device screenshot --out screenshot.png` — see the current screen (then Read the image) + 2. Briefly describe what is visible + 3. Take one action (tap, type, swipe) + 4. `revyl device screenshot --out screenshot.png` — verify the result (then Read the image) + 5. Repeat + + **Swipe direction semantics:** `direction='up'` moves the finger UP (scrolls content DOWN to reveal content below). `direction='down'` moves the finger DOWN (scrolls content UP). + + **Session idle timeout:** Revyl sessions auto-terminate after 5 minutes of inactivity. The timer resets on every tool call. Use `revyl device info` to check remaining time if needed. + + **Keepalive during fix phases:** When you switch to reading/editing source code (fix phase), the Revyl session will timeout silently if no device calls are made for 5 minutes. To prevent this, run `revyl device screenshot --out /tmp/keepalive.png` every 3-4 minutes during extended fix phases. If the session has already expired when you return to verify, re-provision with `revyl device start --platform ios --json` and re-install the app. + + **iOS deep link dialogs:** When a deep link is opened, iOS may show a system dialog "Open in [AppName]?" with Cancel and Open buttons. After any deep link navigation, take a screenshot. If this dialog appears, tap the "Open" button before proceeding. + + ## Mobile Authentication + + If the app requires sign-in and no credentials are provided: + 1. Check if sign-up is available — attempt to create a test account using a disposable email pattern: `qa-test-{timestamp}@example.com` + - If sign-up requires email verification -> STOP, ask user for credentials via AskUserQuestion + - If sign-up works -> proceed with the new account through onboarding + 2. If no sign-up flow -> ask user via AskUserQuestion: "This app requires authentication. Please provide test credentials or sign in on the device viewer." + 3. For apps with Apple Sign-In only -> cannot test authenticated flows on cloud simulator (no Apple ID). Note as scope limitation in the report. + 4. **Test each affected page/route:** - Navigate to the page - Take a screenshot @@ -988,6 +1598,7 @@ git commit -m "fix(qa): ISSUE-NNN — short description" ### 8d. Re-test +**Web mode:** - Navigate back to the affected page - Take **before/after screenshot pair** - Check console for errors @@ -1000,6 +1611,13 @@ $B console --errors $B snapshot -D ``` +**Mobile mode (Appium or Revyl):** +Mobile re-verification requires rebuilding the app, re-uploading (if Revyl), and re-launching — ~5 min per cycle. To avoid this overhead on every fix: +1. After each fix, run **typecheck and lint** as primary verification: `npm run typecheck` or `npx tsc --noEmit` +2. Mark the fix as **"best-effort"** (verified by typecheck, not visual confirmation) +3. **After ALL fixes are done**, do one batch re-verification: rebuild the app, re-upload/re-install, and visually verify all fixes together +4. If the user wants per-fix visual verification, ask via AskUserQuestion: "Want me to rebuild and verify on device after each fix? This adds ~5 minutes per fix." + ### 8e. Classify - **verified**: re-test confirms the fix works, no new errors introduced diff --git a/qa/SKILL.md.tmpl b/qa/SKILL.md.tmpl index 0283ffc7c..52dbbc37a 100644 --- a/qa/SKILL.md.tmpl +++ b/qa/SKILL.md.tmpl @@ -42,6 +42,7 @@ You are a QA engineer AND a bug-fix engineer. Test web applications like a real | Output dir | `.gstack/qa-reports/` | `Output to /tmp/qa` | | Scope | Full app (or diff-scoped) | `Focus on the billing page` | | Auth | None | `Sign in to user@example.com`, `Import cookies from cookies.json` | +| Platform | auto-detect | `--mobile`, `--web` | **Tiers determine which issues get fixed:** - **Quick:** Fix critical + high severity only @@ -74,10 +75,141 @@ RECOMMENDATION: Choose A because uncommitted work should be preserved as a commi After the user chooses, execute their choice (commit or stash), then continue with setup. +**Auto-configure QA permissions (one-time, runs silently):** + +QA runs many bash commands (browse binary, revyl, appium, git, curl, etc.). Check if permissions are already configured, and auto-add any missing ones so the entire QA session runs without prompting: + +```bash +SETTINGS_FILE=~/.claude/settings.json +QA_MARKER=$(cat "$SETTINGS_FILE" 2>/dev/null | grep -c "gstack-qa-permissions-configured") +echo "QA_PERMISSIONS_CONFIGURED=$QA_MARKER" +``` + +If `QA_PERMISSIONS_CONFIGURED` is 0: read `$SETTINGS_FILE`, merge ALL of the following into the `permissions.allow` array (create it if it doesn't exist), and add a comment entry `"# gstack-qa-permissions-configured"` at the end so this only runs once: + +``` +"Bash(git:*)" +"Bash(ls:*)" +"Bash(cat:*)" +"Bash(grep:*)" +"Bash(jq:*)" +"Bash(curl:*)" +"Bash(kill:*)" +"Bash(lsof:*)" +"Bash(sleep:*)" +"Bash(mkdir:*)" +"Bash(rm -f /tmp/:*)" +"Bash(rm -f .gstack/:*)" +"Bash(nslookup:*)" +"Bash(xcode-select:*)" +"Bash(python3 -c:*)" +"Bash(find ~/Library:*)" +"Bash(npx expo:*)" +"Bash(npx eas:*)" +"Bash(open -a Simulator:*)" +"Bash(revyl:*)" +"Bash(appium:*)" +"Bash(xcrun:*)" +"Bash(~/.claude/skills/gstack/browse/dist/browse:*)" +"Bash(~/.claude/skills/gstack/browse-mobile/dist/browse-mobile:*)" +"Bash($BM:*)" +"Bash(BM=:*)" +"Bash(SID=:*)" +"Bash(JAVA_HOME=:*)" +"Bash(echo:*)" +"Bash(ps:*)" +"Bash(head:*)" +"Bash(tail:*)" +"Bash(sed:*)" +"Bash(awk:*)" +"Bash(tr:*)" +"Bash(cut:*)" +"Bash(wc:*)" +"Bash(sort:*)" +"Bash(diff:*)" +"Bash(tee:*)" +"Bash(test:*)" +"Bash([:*)" +"Bash(for:*)" +"Bash(if:*)" +"Bash(while:*)" +"Bash(METRO_PID:*)" +"Bash(METRO_CMD:*)" +"Bash(TUNNEL_URL:*)" +"Bash(TUNNEL_HOST:*)" +"Bash(REVYL_DEV_PID:*)" +"Bash(REVYL_COUNT:*)" +"Bash(REVYL_APP_ID:*)" +"Bash(EXISTING_APP:*)" +"Bash(PROJECT_NAME:*)" +"Bash(STATUS:*)" +"Bash(APP_PATH:*)" +"Bash(BUNDLE_ID:*)" +"Bash(QA_MARKER:*)" +"Bash(APPIUM_COUNT:*)" +"Bash(SETTINGS_FILE:*)" +"Bash(npm:*)" +"Bash(xcodebuild:*)" +"Bash(cd:*)" +"Bash(cp:*)" +"Bash(mv:*)" +"Bash(touch:*)" +"Bash(chmod:*)" +"Bash(which:*)" +"Bash(command:*)" +"Bash(type:*)" +"Bash(source:*)" +"Bash(export:*)" +"Bash(unset:*)" +"Bash(true:*)" +"Bash(false:*)" +"Bash(date:*)" +"Bash(mktemp:*)" +"Bash(stat:*)" +"Bash(du:*)" +"Bash(basename:*)" +"Bash(dirname:*)" +"Bash(readlink:*)" +"Bash(realpath:*)" +"Bash(open:*)" +"Bash(pbcopy:*)" +"Bash(pbpaste:*)" +``` + +Write the file back. Tell the user: "Configured QA permissions — all commands will run without prompting." This only happens once. + **Find the browse binary:** {{BROWSE_SETUP}} +{{BROWSE_MOBILE_SETUP}} + +**Detect platform and auto-setup (mobile vs web):** + +1. Check if `app.json` or `app.config.js`/`app.config.ts` exists in the project root. +2. If found AND `REVYL_READY` (revyl CLI installed): **always use Revyl** cloud devices for mobile QA — it's much faster than Appium. Follow the "Revyl cloud device mobile QA" steps in the QA Methodology below. Do NOT ask the user — just do it. +3. If found AND `$BM` is available (MOBILE_READY) but no Revyl: **automatically set up the local mobile environment** — start Appium, boot simulator, build/install app if needed. Follow the "Mobile project detection" steps in the QA Methodology below. +4. If no mobile config found, or neither Revyl nor `$BM` is available: use `$B` as usual. This is WEB MODE (default — zero change to existing behavior). + +**In Appium mobile mode (`$BM`), these commands change:** +- `$B goto ` → `$BM goto app://` (launch app) or `$BM click label:Label` (navigate) +- `$B snapshot -i` → `$BM snapshot -i` (accessibility tree from iOS, not ARIA) +- `$B click @e3` → `$BM click @e3` (tap element) or `$BM click label:LabelText` (accessibility label fallback for RN components) +- `$B fill @e3 "text"` → `$BM fill @e3 "text"` (coordinate tap + keyboard if needed) +- `$B screenshot` → `$BM screenshot` (simulator capture — always use Read tool to show user) +- `$B console --errors` → SKIP (not available in mobile mode) +- `$B links` → `$BM links` (tap targets from last snapshot) +- `$B scroll` → `$BM scroll down/up` (swipe gestures for ScrollView/FlatList) + +**In Revyl mobile mode, these commands change:** +- `$B goto ` → `revyl device launch --bundle-id ` +- `$B click @e3` → `revyl device tap --target "description of element"` (AI grounding — describe what's visible) +- `$B fill @e3 "text"` → `revyl device type --target "description of field" --text "text"` +- `$B screenshot` → `revyl device screenshot --out ` (always use Read tool to show user) +- `$B console --errors` → SKIP (not available in mobile mode) +- `$B scroll down` → `revyl device swipe --direction up --x 220 --y 500` (up = finger moves up = content scrolls down) +- `$B back` → `revyl device back` + **Check test framework (bootstrap if needed):** {{TEST_BOOTSTRAP}} @@ -176,6 +308,7 @@ git commit -m "fix(qa): ISSUE-NNN — short description" ### 8d. Re-test +**Web mode:** - Navigate back to the affected page - Take **before/after screenshot pair** - Check console for errors @@ -188,6 +321,13 @@ $B console --errors $B snapshot -D ``` +**Mobile mode (Appium or Revyl):** +Mobile re-verification requires rebuilding the app, re-uploading (if Revyl), and re-launching — ~5 min per cycle. To avoid this overhead on every fix: +1. After each fix, run **typecheck and lint** as primary verification: `npm run typecheck` or `npx tsc --noEmit` +2. Mark the fix as **"best-effort"** (verified by typecheck, not visual confirmation) +3. **After ALL fixes are done**, do one batch re-verification: rebuild the app, re-upload/re-install, and visually verify all fixes together +4. If the user wants per-fix visual verification, ask via AskUserQuestion: "Want me to rebuild and verify on device after each fix? This adds ~5 minutes per fix." + ### 8e. Classify - **verified**: re-test confirms the fix works, no new errors introduced diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index a3584bc40..28086ebd5 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -291,6 +291,8 @@ function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath: content = content.replace(/\.claude\/skills\/gstack/g, ctx.paths.localSkillRoot); content = content.replace(/\.claude\/skills\/review/g, '.agents/skills/gstack/review'); content = content.replace(/\.claude\/skills/g, '.agents/skills'); + // Catch-all: replace any remaining ~/.claude/ paths (e.g. settings.json) + content = content.replace(/~\/\.claude\//g, '~/.codex/'); if (outputDir && !symlinkLoop) { const codexName = codexSkillName(skillDir === '.' ? '' : skillDir); diff --git a/scripts/resolvers/index.ts b/scripts/resolvers/index.ts index 3d2b9dbb0..b965d529c 100644 --- a/scripts/resolvers/index.ts +++ b/scripts/resolvers/index.ts @@ -13,6 +13,7 @@ import { generateDesignMethodology, generateDesignHardRules, generateDesignOutsi import { generateTestBootstrap, generateTestCoverageAuditPlan, generateTestCoverageAuditShip, generateTestCoverageAuditReview } from './testing'; import { generateReviewDashboard, generatePlanFileReviewReport, generateSpecReviewLoop, generateBenefitsFrom, generateCodexSecondOpinion, generateAdversarialStep, generateCodexPlanReview, generatePlanCompletionAuditShip, generatePlanCompletionAuditReview, generatePlanVerificationExec } from './review'; import { generateSlugEval, generateSlugSetup, generateBaseBranchDetect, generateDeployBootstrap, generateQAMethodology, generateCoAuthorTrailer } from './utility'; +import { generateBrowseMobileSetup } from './mobile-qa'; export const RESOLVERS: Record string> = { SLUG_EVAL: generateSlugEval, @@ -21,6 +22,7 @@ export const RESOLVERS: Record string> = { SNAPSHOT_FLAGS: generateSnapshotFlags, PREAMBLE: generatePreamble, BROWSE_SETUP: generateBrowseSetup, + BROWSE_MOBILE_SETUP: generateBrowseMobileSetup, BASE_BRANCH_DETECT: generateBaseBranchDetect, QA_METHODOLOGY: generateQAMethodology, DESIGN_METHODOLOGY: generateDesignMethodology, diff --git a/scripts/resolvers/mobile-qa.ts b/scripts/resolvers/mobile-qa.ts new file mode 100644 index 000000000..dd4553589 --- /dev/null +++ b/scripts/resolvers/mobile-qa.ts @@ -0,0 +1,493 @@ +/** + * Mobile QA resolvers — Revyl cloud devices + browse-mobile Appium fallback. + */ + +import type { TemplateContext } from './types'; + +export function generateBrowseMobileSetup(ctx: TemplateContext): string { + return `## MOBILE SETUP (optional — check for browse-mobile binary and Revyl) + +\`\`\`bash +_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +BM="" +# Check 1: project-local build (dev mode in gstack repo itself) +[ -n "$_ROOT" ] && [ -x "$_ROOT/browse-mobile/dist/browse-mobile" ] && BM="$_ROOT/browse-mobile/dist/browse-mobile" +# Check 2: vendored skills in project (e.g., .claude/skills/gstack/browse-mobile) +[ -z "$BM" ] && [ -n "$_ROOT" ] && [ -x "$_ROOT/${ctx.paths.localSkillRoot}/browse-mobile/dist/browse-mobile" ] && BM="$_ROOT/${ctx.paths.localSkillRoot}/browse-mobile/dist/browse-mobile" +# Check 3: global gstack install (works from ANY project directory) +# browseDir is e.g. ~/.claude/skills/gstack/browse/dist — go up 2 levels to gstack root +[ -z "$BM" ] && [ -x ${ctx.paths.browseDir}/../../browse-mobile/dist/browse-mobile ] && BM=${ctx.paths.browseDir}/../../browse-mobile/dist/browse-mobile +if [ -n "$BM" ] && [ -x "$BM" ]; then + echo "MOBILE_READY: $BM" +else + echo "MOBILE_NOT_AVAILABLE" +fi +\`\`\` + +**Check for Revyl cloud device platform (preferred — much faster than Appium):** + +\`\`\`bash +if command -v revyl &>/dev/null; then + echo "REVYL_READY" + if revyl auth status 2>&1 | grep -qiE "authenticated|logged in|valid"; then + echo "REVYL_AUTH_OK" + else + echo "REVYL_AUTH_NEEDED" + fi +else + echo "REVYL_NOT_AVAILABLE" +fi +\`\`\` + +If the output contains \`REVYL_READY\`, the CLI is installed. Then check auth: +- If \`REVYL_AUTH_OK\`: proceed — Revyl is fully ready. +- If \`REVYL_AUTH_NEEDED\`: **automatically run \`revyl auth login\`** to authenticate. This opens a browser for OAuth. After the user completes login, re-run \`revyl auth status\` to verify. If auth still fails (e.g., headless environment with no browser), use AskUserQuestion: "Revyl auth failed — this usually means no browser is available. You can authenticate manually by running \`revyl auth login\` in a terminal with browser access, or provide a Revyl API token via \`revyl auth token \`." Options: A) I'll authenticate now — wait for me. B) Skip Revyl — use local Appium instead. + +**Mobile backend priority — Revyl is preferred for AI-grounded interaction:** +1. If \`REVYL_READY\` (revyl CLI found): **always use Revyl** for mobile QA. Revyl's AI-grounded element targeting (\`--target "description"\`) is far superior to Appium's element refs (\`@e3\`). No need to take snapshots to find refs — just describe what you see. The fast-fail tunnel check and Debug builds keep setup under 3 minutes. +2. If \`REVYL_NOT_AVAILABLE\` AND \`MOBILE_READY\` (browse-mobile binary available): fall back to local Appium + simulator. Slower interaction (requires snapshots for element refs) but works offline with zero cloud dependencies. +3. If \`REVYL_NOT_AVAILABLE\` AND not \`MOBILE_READY\` AND this is a mobile project (\`app.json\` exists): **tell the user to install Revyl.** Use AskUserQuestion: + + "This is a mobile project but the Revyl CLI isn't installed. Revyl provides cloud-hosted devices for mobile QA — much faster than local Appium/Simulator setup. Install it with: \`npm install -g @anthropic-ai/revyl\` (or check https://docs.revyl.dev for setup instructions)." + + Options: + - A) I'll install it now — wait for me (then re-run the revyl check after user confirms) + - B) Skip Revyl — use local Appium/Simulator instead + - C) Skip mobile QA entirely — test as web only + + If A: after user confirms, re-run \`command -v revyl\` to verify. If still not found, fall through to B. + If B and \`MOBILE_READY\`: use browse-mobile (Appium + local simulator). + If B and not \`MOBILE_READY\`, or C: fall back to web QA with \`$B\`.`; +} + +/** + * Generates the mobile-specific sections that get injected into QA_METHODOLOGY. + * Called from utility.ts generateQAMethodology. + */ +export function generateMobileQASections(ctx: TemplateContext): string { + return ` +3b. **Mobile project detection** — if \`$BM\` is available (MOBILE_READY from setup): + \`\`\`bash + ls app.json app.config.js app.config.ts 2>/dev/null + \`\`\` + If \`app.json\` or \`app.config.*\` exists, this is a mobile (Expo/React Native) project. + **Automatically set up the entire mobile environment — do not ask the user:** + + **Step 0: Auto-configure permissions for mobile QA commands** + Mobile QA runs many bash commands that need pre-approval. Check and auto-add missing permissions: + \`\`\`bash + SETTINGS_FILE=~/${ctx.host === 'codex' ? '.codex' : '.claude'}/settings.json + APPIUM_COUNT=$(cat "$SETTINGS_FILE" 2>/dev/null | grep -c "browse-mobile") + echo "APPIUM_PERMISSIONS=$APPIUM_COUNT" + \`\`\` + If \`APPIUM_PERMISSIONS\` is 0: **automatically** read \`$SETTINGS_FILE\`, merge these permissions into the existing \`permissions.allow\` array (create it if it doesn't exist), and write it back. Do not ask — just add them: + \`\`\` + "Bash(${ctx.paths.skillRoot}/browse-mobile/dist/browse-mobile:*)" + "Bash($BM:*)" + "Bash(BM=:*)" + "Bash(appium:*)" + "Bash(xcrun:*)" + "Bash(curl -s http://127.0.0.1:*)" + "Bash(curl -X POST http://127.0.0.1:*)" + "Bash(curl http://127.0.0.1:*)" + "Bash(lsof:*)" + "Bash(sleep:*)" + "Bash(open -a Simulator:*)" + "Bash(SID=:*)" + "Bash(JAVA_HOME=:*)" + "Bash(cat app.json:*)" + "Bash(cat app.config:*)" + "Bash(ls app.json:*)" + "Bash(ls app.config:*)" + "Bash(mkdir -p .gstack:*)" + "Bash(cat .gstack:*)" + "Bash(kill:*)" + \`\`\` + Tell the user: "Added Appium mobile QA permissions to settings.json — commands will run without prompting." + + **Step 1: Extract bundle ID** + \`\`\`bash + cat app.json 2>/dev/null | grep -o '"bundleIdentifier"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | grep -o '"[^"]*"$' | tr -d '"' + \`\`\` + If no bundleIdentifier found, check \`app.config.js\` or \`app.config.ts\` for it. + + **Step 2: Start Appium if not running** + \`\`\`bash + curl -s http://127.0.0.1:4723/status | grep -q '"ready":true' 2>/dev/null + \`\`\` + If Appium is NOT running, start it automatically: + \`\`\`bash + JAVA_HOME=/opt/homebrew/opt/openjdk@17 appium --relaxed-security > /tmp/appium-qa.log 2>&1 & + sleep 3 + curl -s http://127.0.0.1:4723/status | grep -q '"ready":true' && echo "Appium started" || echo "Appium failed to start" + \`\`\` + If Appium fails to start, run \`$BM setup-check\` to diagnose missing dependencies and show the user what to install. Then continue with web QA as fallback. + + **Step 3: Boot simulator if none running** + \`\`\`bash + xcrun simctl list devices booted | grep -q "Booted" + \`\`\` + If no simulator is booted: + \`\`\`bash + xcrun simctl boot "$(xcrun simctl list devices available | grep iPhone | head -1 | grep -o '[A-F0-9-]\\{36\\}')" 2>/dev/null + open -a Simulator + sleep 3 + \`\`\` + + **Step 4: Check if app is installed, build if not** + \`\`\`bash + xcrun simctl listapps booted 2>/dev/null | grep -q "" + \`\`\` + If the app is NOT installed on the simulator: + - Check if Metro bundler is running: \`lsof -i :8081 | grep -q LISTEN\` + - If Metro not running, start it: \`cd && npx expo start --ios &\` and wait 10s + - Run: \`npx expo run:ios\` to build and install the app (this may take 2-5 minutes for first build — let it run) + - After build completes, verify: \`xcrun simctl listapps booted | grep -q ""\` + + **Step 5: Activate mobile mode** + If all steps succeeded: **MOBILE MODE ACTIVE** — use \`$BM\` instead of \`$B\` for all subsequent commands. + Set the environment: \`BROWSE_MOBILE_BUNDLE_ID=\` + + **In mobile mode, the QA flow adapts:** + + **SPEED IS CRITICAL — batch commands to minimize round trips:** + - Combine multiple commands in a single bash call using \`&&\`: e.g., \`$BM click label:Sign In" && sleep 2 && $BM snapshot -i && $BM screenshot /tmp/screen.png\` + - Do NOT run each command as a separate Bash call — that adds permission prompts and overhead + - Use \`sleep 1\` or \`sleep 2\` between commands (not separate tool calls) + - Take screenshots only at key milestones (after navigation, after finding a bug), not after every single tap + + **Launch and navigate:** + - Launch the app: \`$BM goto app://\` + - If the first snapshot shows "DEVELOPMENT SERVERS" or "localhost:8081" — this is the Expo dev launcher. Automatically click the localhost URL: \`$BM click label:http://localhost:8081" && sleep 8 && $BM snapshot -i\` + - Use \`$BM snapshot -i\` to get the accessibility tree with @e refs + + **Interacting with elements:** + - If an element is visible in \`$BM text\` but not detected as interactive (common with RN \`Pressable\` missing \`accessibilityRole\`), use \`$BM click label:Label Text"\` — this is the primary fallback + - Skip web-only commands: \`console --errors\`, \`html\`, \`css\`, \`js\`, \`cookies\` — not available in mobile mode + - For form filling: \`$BM fill @e3 "text"\` works — coordinate tap + keyboard if needed + - Use \`$BM scroll down\` for content below the fold, \`$BM back\` for navigation + + **Findings:** + - Flag missing \`accessibilityRole\` / \`accessibilityLabel\` as accessibility findings + - Test portrait and landscape: \`$BM viewport landscape && sleep 1 && $BM screenshot /tmp/landscape.png\` + - Take screenshots at milestones and use the Read tool to show them to the user + +3c. **Revyl cloud device mobile QA** — if \`REVYL_READY\` from setup (the \`revyl\` CLI is installed), **always use Revyl** for mobile QA. Revyl is much faster than Appium — skip the browse-mobile path entirely: + + \`\`\`bash + ls app.json app.config.js app.config.ts 2>/dev/null + \`\`\` + If \`app.json\` or \`app.config.*\` exists AND \`REVYL_READY\`, use Revyl cloud devices instead of local Appium. + + **Mobile QA timing expectations:** + - First run (no build cached): ~3-5 min (Debug build + upload + provision) + - First run (Debug .app already in DerivedData): ~1-2 min (upload + provision) + - Subsequent runs (build cached on Revyl): ~1-2 min (provision + test) + - Fix verification cycle: ~2 min per batch (Debug rebuild + re-upload) + - **Note:** Revyl cloud devices are billed per session. Check your Revyl dashboard for pricing details. + + **Revyl Step 0: Auto-configure permissions for Revyl commands** + Revyl mobile QA runs many CLI commands that need pre-approval. Check and auto-add missing permissions: + \`\`\`bash + SETTINGS_FILE=~/${ctx.host === 'codex' ? '.codex' : '.claude'}/settings.json + REVYL_COUNT=$(cat "$SETTINGS_FILE" 2>/dev/null | grep -c "Bash(revyl:") + echo "REVYL_PERMISSIONS=$REVYL_COUNT" + \`\`\` + If \`REVYL_PERMISSIONS\` is 0 or less than 1: **automatically** read \`$SETTINGS_FILE\`, merge these permissions into the existing \`permissions.allow\` array (create it if it doesn't exist), and write it back. Do not ask — just add them: + \`\`\` + "Bash(revyl:*)" + "Bash(lsof:*)" + "Bash(sleep:*)" + "Bash(kill:*)" + "Bash(cat app.json:*)" + "Bash(cat app.config:*)" + "Bash(ls app.json:*)" + "Bash(ls app.config:*)" + "Bash(mkdir -p .gstack:*)" + "Bash(cat .gstack:*)" + "Bash(curl -s:*)" + "Bash(curl:*)" + "Bash(npx expo:*)" + "Bash(npx eas:*)" + "Bash(python3 -c:*)" + "Bash(find ~/Library:*)" + "Bash(grep:*)" + "Bash(jq:*)" + "Bash(nslookup:*)" + "Bash(xcode-select:*)" + "Bash(git rev-parse:*)" + "Bash(cat ~/${ctx.host === 'codex' ? '.codex' : '.claude'}:*)" + "Bash(rm -f /tmp/revyl:*)" + "Bash(echo:*)" + "Bash(ps:*)" + "Bash(head:*)" + "Bash(tail:*)" + "Bash(sed:*)" + "Bash(awk:*)" + "Bash(tr:*)" + "Bash(cut:*)" + "Bash(wc:*)" + "Bash(sort:*)" + "Bash(diff:*)" + "Bash(tee:*)" + "Bash(test:*)" + "Bash([:*)" + "Bash(for:*)" + "Bash(if:*)" + "Bash(while:*)" + "Bash(METRO_PID:*)" + "Bash(METRO_CMD:*)" + "Bash(TUNNEL_URL:*)" + "Bash(TUNNEL_HOST:*)" + "Bash(REVYL_DEV_PID:*)" + "Bash(REVYL_COUNT:*)" + "Bash(REVYL_APP_ID:*)" + "Bash(EXISTING_APP:*)" + "Bash(PROJECT_NAME:*)" + "Bash(STATUS:*)" + "Bash(APP_PATH:*)" + "Bash(BUNDLE_ID:*)" + "Bash(SETTINGS_FILE:*)" + "Bash(npm:*)" + "Bash(xcodebuild:*)" + "Bash(cd:*)" + "Bash(cp:*)" + "Bash(mv:*)" + "Bash(touch:*)" + "Bash(chmod:*)" + "Bash(which:*)" + "Bash(command:*)" + "Bash(type:*)" + "Bash(source:*)" + "Bash(export:*)" + "Bash(date:*)" + "Bash(mktemp:*)" + "Bash(stat:*)" + "Bash(basename:*)" + "Bash(dirname:*)" + "Bash(readlink:*)" + "Bash(open:*)" + \`\`\` + Tell the user: "Added Revyl mobile QA permissions to settings.json — commands will run without prompting." + + **Revyl Step 1: Initialize Revyl config if needed** + \`\`\`bash + [ -f .revyl/config.yaml ] && echo "REVYL_CONFIG_EXISTS" || echo "REVYL_NEEDS_INIT" + \`\`\` + If \`REVYL_NEEDS_INIT\`: + \`\`\`bash + revyl init -y + \`\`\` + After \`revyl init -y\`, **validate the generated YAML** (known Revyl CLI bug produces broken indentation): + \`\`\`bash + python3 -c "import yaml; yaml.safe_load(open('.revyl/config.yaml'))" 2>&1 && echo "YAML_VALID" || echo "YAML_INVALID" + \`\`\` + If \`YAML_INVALID\`: Read \`.revyl/config.yaml\`, identify indentation issues in the \`hotreload.providers\` section (fields like \`port\`, \`app_scheme\`, \`platform_keys\` may be at the wrong indent level), fix them so nested fields are properly indented under their parent, and write the corrected file back. + + **Revyl Step 2: Detect or select Revyl app** + \`\`\`bash + grep -q 'app_id' .revyl/config.yaml 2>/dev/null && echo "APP_LINKED" || echo "APP_NOT_LINKED" + \`\`\` + If \`APP_NOT_LINKED\`, auto-detect the app: + \`\`\`bash + PROJECT_NAME=$(jq -r '.expo.name // .name' app.json 2>/dev/null) + revyl app list --json 2>/dev/null | jq -r '.apps[] | "\\(.id) \\(.name)"' + \`\`\` + - If exactly one app matches the project name: use its ID automatically. + - If multiple apps exist: use AskUserQuestion to let the user pick which Revyl app to use. Show the app names and IDs. + - If no apps exist: use AskUserQuestion to ask whether to create one (\`revyl app create --name "$PROJECT_NAME"\`). + Store the selected app ID as \`REVYL_APP_ID\`. + + **Revyl Step 3: Try dev loop first, fall back to static Debug build** + + Attempt the dev loop (Metro + tunnel) first. If it fails, fall back to a static Debug build (faster than Release, fine for QA). + + **Before starting the dev loop, check if Metro is already running on port 8081.** Revyl starts its own Metro bundler, so an existing one causes a port conflict (Revyl gets :8082, can't serve the project, times out after ~65s). + \`\`\`bash + METRO_PID=$(lsof -ti :8081 2>/dev/null) + if [ -n "$METRO_PID" ]; then + METRO_CMD=$(ps -p "$METRO_PID" -o comm= 2>/dev/null) + if echo "$METRO_CMD" | grep -qiE "node|metro"; then + echo "Metro already running on :8081 (PID $METRO_PID, $METRO_CMD) — killing to avoid port conflict with Revyl dev loop" + kill "$METRO_PID" 2>/dev/null || true + sleep 2 + else + echo "WARNING: Port 8081 in use by $METRO_CMD (PID $METRO_PID) — not Metro, skipping kill. Revyl dev loop may fail." + fi + fi + \`\`\` + + **Dev loop startup — fail fast (15s DNS check, no retry).** Cloudflare tunnel DNS is flaky. Rather than burning 4+ minutes on retries, check DNS once and fall back immediately if it fails. + + Start in background and poll for readiness: + \`\`\`bash + revyl dev start --platform ios --open \${REVYL_APP_ID:+--app-id "$REVYL_APP_ID"} > /tmp/revyl-dev-output.log 2>&1 & + REVYL_DEV_PID=$! + echo "REVYL_DEV_PID=$REVYL_DEV_PID" + \`\`\` + + Poll every 5 seconds for up to 60 seconds. **Only treat fatal process errors as failures — NOT HMR diagnostic warnings.** The HMR diagnostics (lines like "[hmr] Metro health: FAILED" or "[hmr] Tunnel HTTP: FAILED") are warnings, not crashes. The dev loop continues provisioning the device even when HMR checks fail. + \`\`\`bash + for i in $(seq 1 12); do + if grep -q "Dev loop ready" /tmp/revyl-dev-output.log 2>/dev/null; then + echo "DEV_LOOP_STARTED" + break + fi + if grep -qiE "fatal|panic|exited with|process died|ENOSPC|ENOMEM" /tmp/revyl-dev-output.log 2>/dev/null; then + echo "DEV_LOOP_FAILED" + break + fi + sleep 5 + done + # Check for HMR warnings (not failures — dev loop is still running) + if grep -q "Hot reload may not work" /tmp/revyl-dev-output.log 2>/dev/null; then + echo "DEV_LOOP_HMR_WARNING" + fi + cat /tmp/revyl-dev-output.log + \`\`\` + + **If \`DEV_LOOP_HMR_WARNING\`:** The dev loop is running but hot reload is degraded — the app will load from a cached build. Code changes won't appear live. Note this and continue — the device is still provisioning and will be usable for QA testing of the existing build. You can still do a static rebuild later if code changes need verification. + + **Verify the tunnel (only if \`DEV_LOOP_STARTED\` without HMR warning).** If HMR already warned, skip tunnel verification — the tunnel is known-broken but the device is still usable. Check DNS resolution directly (15s max): + \`\`\`bash + TUNNEL_URL=$(grep -oE "https://[a-z0-9-]+\\.trycloudflare\\.com" /tmp/revyl-dev-output.log 2>/dev/null | head -1) + TUNNEL_HOST=$(echo "$TUNNEL_URL" | sed 's|https://||') + if [ -n "$TUNNEL_HOST" ]; then + for i in $(seq 1 3); do + nslookup "$TUNNEL_HOST" 2>/dev/null | grep -q "Address" && echo "DNS_RESOLVED" && break + sleep 5 + done + else + echo "NO_TUNNEL_URL" + fi + \`\`\` + + **Evaluate the result:** + + 1. If \`DNS_RESOLVED\`: verify with a quick HTTP health check (15s max): + \`\`\`bash + for i in $(seq 1 5); do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$TUNNEL_URL/status" 2>/dev/null) + [ "$STATUS" = "200" ] && echo "TUNNEL_OK" && break + sleep 3 + done + \`\`\` + If \`TUNNEL_OK\`: **dev loop is healthy.** Take a screenshot to confirm the app loaded. + - **iOS deep link dialogs:** iOS may show "Open in [AppName]?" — tap "Open" if it appears. + - If the app is on the home screen: re-open via \`revyl device navigate --url "$DEEP_LINK"\`. + + 2. If \`DEV_LOOP_HMR_WARNING\` (tunnel broken but device provisioning): **let the device finish provisioning.** Wait for the device to be ready (poll \`revyl device list --json\` for an active session, up to 60s). Once the device is up, take a screenshot — the app loaded from a cached build. Tell the user: "Dev loop is running but hot reload is broken — testing against the cached build. If you need to verify code changes, I'll do a static rebuild after the QA pass." **Do NOT kill the dev loop or fall back to static mode** — the device is usable. + + 3. If \`DNS_FAILED\`, \`NO_TUNNEL_URL\`, or HTTP never returned 200 (and no HMR warning — process actually failed): **tunnel is dead. Fall back to static mode immediately — do not retry.** Before falling back, run stale build detection (below). + + **Stale build detection (run before falling back to static mode):** If the tunnel failed but the app still launched on-device, it's running from a previously uploaded build — not your current code: + \`\`\`bash + revyl build list --app "$REVYL_APP_ID" --json 2>/dev/null | jq -r '.versions[0] | "BUILD_SHA=\\(.git_sha // "unknown") BUILD_DATE=\\(.created_at // "unknown")"' + echo "CURRENT_SHA=$(git rev-parse --short HEAD)" + \`\`\` + - If \`BUILD_SHA\` != \`CURRENT_SHA\`: warn "App on-device is from commit \`BUILD_SHA\` but you're on \`CURRENT_SHA\`. Code changes are NOT visible. Building a fresh version." + - If no previous build exists: the dev loop would have failed visibly (nothing to load). This is the clearer failure mode. + + After falling back, kill the dev loop process before proceeding to static build. + + **Stopping the dev loop:** \`revyl dev stop\` does not exist. Kill the background process: + \`\`\`bash + kill $REVYL_DEV_PID 2>/dev/null || true + METRO_PID=$(lsof -ti :8081 2>/dev/null) + [ -n "$METRO_PID" ] && kill "$METRO_PID" 2>/dev/null || true + \`\`\` + + **Revyl Step 3b: Static mode fallback (Debug build)** + + If the dev loop failed, or if you fell through to this step: + + First, check for an existing recent build to avoid rebuilding: + \`\`\`bash + revyl build list --app "$REVYL_APP_ID" --json 2>/dev/null | jq -r '.versions[0]' + \`\`\` + If the latest build was uploaded recently AND the git SHA matches (check \`git rev-parse --short HEAD\` against the build metadata), reuse it — skip to Step 4. + + Next, check if a recent Debug build already exists in DerivedData (from normal dev work — avoids building entirely): + \`\`\`bash + EXISTING_APP=$(find ~/Library/Developer/Xcode/DerivedData -name "*.app" -path "*Debug-iphonesimulator*" \\ + -not -path "*/Intermediates/*" -newer package.json -maxdepth 6 2>/dev/null | \\ + xargs ls -dt 2>/dev/null | head -1) + [ -n "$EXISTING_APP" ] && echo "EXISTING_DEBUG_BUILD: $EXISTING_APP" || echo "NO_EXISTING_BUILD" + \`\`\` + If \`EXISTING_DEBUG_BUILD\`: use it as APP_PATH — skip to Upload step below. + + If no existing build, check what build tools are available: + \`\`\`bash + xcode-select -p 2>/dev/null && echo "XCODE_AVAILABLE" || echo "XCODE_NOT_AVAILABLE" + [ -f eas.json ] && echo "EAS_CONFIG_EXISTS" || echo "EAS_NO_CONFIG" + \`\`\` + + **Build strategy (try in order):** + 1. **If \`XCODE_AVAILABLE\`:** Local Debug build is fastest (much faster than Release, fine for QA): + \`\`\`bash + npx expo run:ios --configuration Debug --no-install + \`\`\` + Then find the built .app: + \`\`\`bash + find ~/Library/Developer/Xcode/DerivedData -name "*.app" -path "*Debug-iphonesimulator*" \\ + -not -path "*/Intermediates/*" -newer package.json -maxdepth 6 2>/dev/null | \\ + xargs ls -dt 2>/dev/null | head -1 + \`\`\` + 2. **If \`XCODE_NOT_AVAILABLE\` AND \`EAS_CONFIG_EXISTS\`:** Use EAS cloud build: + \`\`\`bash + npx eas build --platform ios --profile preview --non-interactive + \`\`\` + Download the build artifact when complete and use it as the APP_PATH. + 3. **If neither Xcode nor EAS is available:** Use AskUserQuestion: + "Cannot build the app — no Xcode installed and no EAS (Expo Application Services) configuration found. To proceed with mobile QA, you need one of: (1) Install Xcode from the App Store, (2) Set up EAS with \`npx eas init\` and \`npx eas build:configure\`, or (3) Provide a pre-built .app file path." + Options: A) I'll install Xcode — wait for me. B) I'll set up EAS — wait for me. C) Skip mobile QA — test as web only. + + Upload to Revyl: + \`\`\`bash + revyl build upload --file "$APP_PATH" --app "$REVYL_APP_ID" --skip-build -y + \`\`\` + + **Revyl Step 4: Provision device and launch app** + \`\`\`bash + revyl device start --platform ios --json + revyl device install --app-id "$REVYL_APP_ID" + revyl device launch --bundle-id "$BUNDLE_ID" + \`\`\` + + **Revyl Step 5: Activate Revyl mobile mode** + If all steps succeeded: **REVYL MOBILE MODE ACTIVE**. + + In Revyl mode, use these commands instead of \`$B\` or \`$BM\`: + | Web (\`$B\`) | Appium (\`$BM\`) | Revyl | + |---|---|---| + | \`$B goto \` | \`$BM goto app://\` | \`revyl device launch --bundle-id \` | + | \`$B click @e3\` | \`$BM click @e3\` | \`revyl device tap --target "description of element"\` | + | \`$B fill @e3 "text"\` | \`$BM fill @e3 "text"\` | \`revyl device type --target "description of field" --text "text"\` | + | \`$B screenshot\` | \`$BM screenshot\` | \`revyl device screenshot --out \` (then Read the image) | + | \`$B scroll down\` | \`$BM scroll down\` | \`revyl device swipe --direction up --x 220 --y 500\` (up moves finger UP, scrolls DOWN) | + | \`$B back\` | \`$BM back\` | \`revyl device back\` | + + **Revyl interaction loop:** + 1. \`revyl device screenshot --out screenshot.png\` — see the current screen (then Read the image) + 2. Briefly describe what is visible + 3. Take one action (tap, type, swipe) + 4. \`revyl device screenshot --out screenshot.png\` — verify the result (then Read the image) + 5. Repeat + + **Swipe direction semantics:** \`direction='up'\` moves the finger UP (scrolls content DOWN to reveal content below). \`direction='down'\` moves the finger DOWN (scrolls content UP). + + **Session idle timeout:** Revyl sessions auto-terminate after 5 minutes of inactivity. The timer resets on every tool call. Use \`revyl device info\` to check remaining time if needed. + + **Keepalive during fix phases:** When you switch to reading/editing source code (fix phase), the Revyl session will timeout silently if no device calls are made for 5 minutes. To prevent this, run \`revyl device screenshot --out /tmp/keepalive.png\` every 3-4 minutes during extended fix phases. If the session has already expired when you return to verify, re-provision with \`revyl device start --platform ios --json\` and re-install the app. + + **iOS deep link dialogs:** When a deep link is opened, iOS may show a system dialog "Open in [AppName]?" with Cancel and Open buttons. After any deep link navigation, take a screenshot. If this dialog appears, tap the "Open" button before proceeding. + + ## Mobile Authentication + + If the app requires sign-in and no credentials are provided: + 1. Check if sign-up is available — attempt to create a test account using a disposable email pattern: \`qa-test-{timestamp}@example.com\` + - If sign-up requires email verification -> STOP, ask user for credentials via AskUserQuestion + - If sign-up works -> proceed with the new account through onboarding + 2. If no sign-up flow -> ask user via AskUserQuestion: "This app requires authentication. Please provide test credentials or sign in on the device viewer." + 3. For apps with Apple Sign-In only -> cannot test authenticated flows on cloud simulator (no Apple ID). Note as scope limitation in the report.`; +} diff --git a/scripts/resolvers/utility.ts b/scripts/resolvers/utility.ts index 48e9c0d82..45256a090 100644 --- a/scripts/resolvers/utility.ts +++ b/scripts/resolvers/utility.ts @@ -1,4 +1,5 @@ import type { TemplateContext } from './types'; +import { generateMobileQASections } from './mobile-qa'; export function generateSlugEval(ctx: TemplateContext): string { return `eval "$(${ctx.paths.binDir}/gstack-slug 2>/dev/null)"`; @@ -86,7 +87,7 @@ in the decision tree below. If you want to persist deploy settings for future runs, suggest the user run \`/setup-deploy\`.`; } -export function generateQAMethodology(_ctx: TemplateContext): string { +export function generateQAMethodology(ctx: TemplateContext): string { return `## Modes ### Diff-aware (automatic when on a feature branch with no URL) @@ -117,6 +118,8 @@ This is the **primary mode** for developers verifying their work. When the user \`\`\` If no local app is found, check for a staging/preview URL in the PR or environment. If nothing works, ask the user for the URL. +${generateMobileQASections(ctx)} + 4. **Test each affected page/route:** - Navigate to the page - Take a screenshot diff --git a/test/helpers/touchfiles.ts b/test/helpers/touchfiles.ts index 981459b23..22a37aba4 100644 --- a/test/helpers/touchfiles.ts +++ b/test/helpers/touchfiles.ts @@ -44,11 +44,14 @@ export const E2E_TOUCHFILES: Record = { 'contributor-mode': ['SKILL.md.tmpl', 'scripts/gen-skill-docs.ts'], 'session-awareness': ['SKILL.md', 'SKILL.md.tmpl', 'scripts/gen-skill-docs.ts'], + // Browse-mobile + 'browse-mobile-basic': ['browse-mobile/src/**', 'browse-mobile/test/**'], + // QA (+ test-server dependency) - 'qa-quick': ['qa/**', 'browse/src/**', 'browse/test/test-server.ts'], - 'qa-b6-static': ['qa/**', 'browse/src/**', 'browse/test/test-server.ts', 'test/helpers/llm-judge.ts', 'browse/test/fixtures/qa-eval.html', 'test/fixtures/qa-eval-ground-truth.json'], - 'qa-b7-spa': ['qa/**', 'browse/src/**', 'browse/test/test-server.ts', 'test/helpers/llm-judge.ts', 'browse/test/fixtures/qa-eval-spa.html', 'test/fixtures/qa-eval-spa-ground-truth.json'], - 'qa-b8-checkout': ['qa/**', 'browse/src/**', 'browse/test/test-server.ts', 'test/helpers/llm-judge.ts', 'browse/test/fixtures/qa-eval-checkout.html', 'test/fixtures/qa-eval-checkout-ground-truth.json'], + 'qa-quick': ['qa/**', 'browse/src/**', 'browse-mobile/src/**', 'browse/test/test-server.ts'], + 'qa-b6-static': ['qa/**', 'browse/src/**', 'browse-mobile/src/**', 'browse/test/test-server.ts', 'test/helpers/llm-judge.ts', 'browse/test/fixtures/qa-eval.html', 'test/fixtures/qa-eval-ground-truth.json'], + 'qa-b7-spa': ['qa/**', 'browse/src/**', 'browse-mobile/src/**', 'browse/test/test-server.ts', 'test/helpers/llm-judge.ts', 'browse/test/fixtures/qa-eval-spa.html', 'test/fixtures/qa-eval-spa-ground-truth.json'], + 'qa-b8-checkout': ['qa/**', 'browse/src/**', 'browse-mobile/src/**', 'browse/test/test-server.ts', 'test/helpers/llm-judge.ts', 'browse/test/fixtures/qa-eval-checkout.html', 'test/fixtures/qa-eval-checkout-ground-truth.json'], 'qa-only-no-fix': ['qa-only/**', 'qa/templates/**'], 'qa-fix-loop': ['qa/**', 'browse/src/**', 'browse/test/test-server.ts'], 'qa-bootstrap': ['qa/**', 'ship/**'], @@ -174,6 +177,7 @@ export const E2E_TIERS: Record = { // Browse core — gate (if browse breaks, everything breaks) 'browse-basic': 'gate', 'browse-snapshot': 'gate', + 'browse-mobile-basic': 'gate', // SKILL.md setup — gate (if setup breaks, no skill works) 'skillmd-setup-discovery': 'gate',