[
  {
    "path": ".editorconfig",
    "content": "# Copied from https://youtrack.jetbrains.com/issue/FL-15599/No-way-of-disabling-Java-Kotlin-wildcard-imports\n\n[*.java]\nij_java_class_count_to_use_import_on_demand = 1024\nij_java_names_count_to_use_import_on_demand = 1024\n\n[*.kt]\nij_kotlin_name_count_to_use_star_import = 1024\nij_kotlin_name_count_to_use_star_import_for_members = 1024\n"
  },
  {
    "path": ".gitattributes",
    "content": "#\n# https://help.github.com/articles/dealing-with-line-endings/\n#\n# These are explicitly windows files and should use crlf\n*.bat           text eol=crlf\n\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yaml",
    "content": "name: Report a bug\ndescription: You have a problem with Maestro.\nbody:\n  - type: markdown\n    attributes:\n      value: >\n        ### Thank you for using Maestro!\n\n\n        Before creating a new issue, please first search the [existing issues]\n        and make sure it hasn't been reported before.\n\n\n        If you are sure that you have found a bug that hasn't been reported yet,\n        or if our documentation doesn't have an answer to what you're looking\n        for, then please fill out this template.\n\n\n        ---\n\n\n        [existing issues]: https://github.com/mobile-dev-inc/maestro\n  - type: checkboxes\n    attributes:\n      label: Is there an existing issue for this?\n      description: |\n        Please search to see if an issue already exists for the bug you encountered.\n      options:\n      - label: I have searched the existing issues and didn't find mine.\n        required: true\n  - type: textarea\n    validations:\n      required: true\n    attributes:\n      label: Steps to reproduce\n      description: >\n        Create a [minimal, reproducible example] that:\n\n        1. Demonstrates the problem\n\n        2. Explains how to reproduce the problem with detailed step-by-step\n        instructions\n\n\n        **In addition to the detailed step-by-step instructions**, you must include\n        information about the device you're encountering the issue on\n        (e.g. physical Android or iOS simulator), and the OS version\n        (e.g. Android 9, Android 14 with Play Services, or iOS 18).\n\n\n        **It's critical that you include your test flow file**. In general, try\n        to include as much additional details as possible to make it easier for\n        us to understand and fix the problem. Screenshots and videos are\n        welcome.\n\n           > [!TIP]\n          > If you're recording a video on Android, we recommend enabling these options to show taps and gestures:\n          > ```\n          > adb shell settings put system show_touches 1\n          > adb shell settings put system pointer_location 1\n          > ```\n\n\n         > [!WARNING]\n        > Issues that cannot be reproduced are much more likely to be closed.\n\n\n        [minimal, reproducible example]: https://stackoverflow.com/help/minimal-reproducible-example\n      placeholder: |\n        Example good reproduction steps:\n        1. Clone https://github.com/your_username/your_repo_with_bug and `cd` into it\n        2. Start Android emulator (Pixel 7, API 34, with Google Play)\n        3. Build app: `./gradlew :app:assembleDebug`\n        4. Run the problematic flow and see it fail: `maestro test .maestro/flow.yaml`\n  - type: textarea\n    validations:\n      required: true\n    attributes:\n      label: Actual results\n      description: Please explain what is happening.\n  - type: textarea\n    validations:\n      required: true\n    attributes:\n      label: Expected results\n      description: Please explain what you expect to happen.\n  - type: textarea\n    validations:\n      required: true\n    attributes:\n      label: About app\n      description: >\n        Include information about the app you're testing:\n      \n        - Is this an open source or closed source project?\n          - If open source, please share link to the repo\n          - If closed source, please share app binary and/or an isolated, reproducible sample\n        - Is this a native or cross-platform app?\n\n        - Framework used to build the app\n          - e.g. UIKit, SwiftUI, Android Views, Compose, React Native, or NativeScript\n          - If applicable, version of the framework (e.g. Flutter 3.22.0, Compose 1.62.0)\n          - If applicable, minimum and target Android SDK/iOS version (e.g. minSdk 21, targetSdk 34)\n      placeholder: |\n        The info you enter here will make it easier to resolve your issue. For example:\n        - This is an open source app, available at https://github.com/wikimedia/wikipedia-ios\n        - It's a native iOS app. There is also an Android version, but the issue is only on iOS.\n        - It's built mainly with UIKit, minimum iOS deployment target is 13.0\n      \n  - type: textarea\n    validations:\n      required: true\n    attributes:\n      label: About environment\n      description: |\n        Include information about machine you're running Maestro on:\n      \n        - Java version (e.g. OpenJDK 17, Eclipse Temurin 8). To find it, run `java -version`\n        - OS and its version (e.g. macOS 13.1 Ventura, Ubuntu 24.04, Arch (btw))\n        - Processor architecture (x86_64, arm64)\n      placeholder: |\n        The info you enter here will make it easier to resolve your issue. For example:\n        - I'm on M1 MacBook Air, with macOS 14.5 Sonoma and Xcode 15.4.\n  - type: textarea\n    attributes:\n      label: Logs\n      description: >\n        Include the full logs of the command you're running. The zip files\n        created with `maestro bugreport` can be uploaded here as well.\n\n\n        Things to keep in mind:\n\n\n        - If you're running more than single command, include its logs in a\n          separate backticks block.\n\n\n        - If the logs are too large to be uploaded to Github, you may upload\n          them as a `txt` file or use online tools like https://pastebin.com and\n          share the link. Just make sure the link won't break in the future.\n\n\n        - **Do not upload screenshots of text**. Instead, use code blocks or the\n          above mentioned ways to upload logs.\n\n\n        - **Make sure the logs are well formatted**. If you post garbled logs, it\n          will make it harder for us to help you.\n      value: |\n        <details>\n        <summary>Logs</summary>\n\n        ```\n        <!-- Replace this line with your logs. *DO NOT* remove the backticks! -->\n        ```\n\n        </details>\n  - type: input\n    validations:\n      required: true\n    attributes:\n      label: Maestro version\n      description: >\n        Provide version of Maestro CLI where the problem occurs. Run\n        `maestro --version` to find out.\n      placeholder: 1.36.0\n  - type: dropdown\n    validations:\n      required: true\n    attributes:\n      label: How did you install Maestro?\n      options:\n        - install script (https://get.maestro.mobile.dev)\n        - Homebrew\n        - built from source (please include commit hash in the text area below)\n        - other (please specify in the text area below)\n      default: 0\n  - type: textarea\n    validations:\n      required: false\n    attributes:\n      label: Anything else?\n      description: >\n        Links? Other issues? StackOverflow threads? Anything that will give us\n        more context about the issue you are encountering will be helpful.\n\n         > [!TIP]\n        > You can attach images or log files by clicking this area to highlight it and then dragging files in.\n  - type: markdown\n    attributes:\n      value: >\n        Now that you've filled all the required information above, you're ready\n        to submit the issue.\n\n\n        **Please check what your issue looks like after creating it**. If it\n        contains garbled code and logs, please take some time to adjust it so\n        it's easier to parse.\n\n\n        **Try reading your issue as if you were seeing it for the first time**.\n        Does it read well? Is it easy to understand? Is the formatting correct?\n        If not, please improve it.\n\n\n        Thank you for helping us improve Maestro and keeping our issue tracker\n        in a good shape!\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yaml",
    "content": "name: Suggest a feature\ndescription: You want to share a new idea to improve Maestro.\nbody:\n  - type: markdown\n    attributes:\n      value: >\n        ### Thank you for using Maestro!\n\n\n        We can't wait to hear your idea!\n\n\n        First though, please search the [existing issues] to see if an issue\n        already exists for the feature you need. Maybe someone already did the\n        job for you and you don't need to fill this template.\n\n\n        ---\n\n\n        If you are sure that the feature you want to suggest hasn't been\n        requested before, or if our documentation doesn't have an answer to what\n        you're looking for, then fill out the template below. Please bear in\n        mind that duplicates and insufficiently described feature requests will\n        be closed.\n\n\n        [existing issues]: https://github.com/mobile-dev-inc/maestro/issues\n  - type: textarea\n    attributes:\n      label: Use case\n      description: >\n        Please tell us more about the use case you have that led to you wanting\n        this new feature.\n\n\n        Is your feature request related to a problem? Please give a clear and\n        concise description of what the problem is. This will help avoid the\n        [XY problem].\n\n\n        Describe the alternative solutions you've considered and the tradeoffs\n        they come with. The more context you can provide, the better.\n\n\n        [XY problem]: https://en.wikipedia.org/wiki/XY_problem\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: Proposal\n      description: >\n        Briefly but precisely describe what the new feature should look like\n        from the user perspective.\n\n\n        Consider attaching something showing what you are imagining:\n         * code samples (maybe you already know )\n         * API design ideas (e.g. of new YAML commands)\n    validations:\n      required: true\n  - type: textarea\n    validations:\n      required: false\n    attributes:\n      label: Anything else?\n      description: >\n        Links? Other issues? StackOverflow threads? Anything that will give us\n        more context about this feature request will be helpful.\n\n         > [!TIP]\n        > You can attach images or other files by clicking this area to highlight it and then dragging files in.\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "## Proposed changes\n\ncopilot:summary\n\n## Testing\n\n<!--- Please describe how you tested your changes. -->\n\n> **Does this need e2e tests?** Please consider contributing them to the [demo app](https://github.com/mobile-dev-inc/demo_app) repository.\n\n## Issues fixed\n"
  },
  {
    "path": ".github/scripts/boot_simulator.sh",
    "content": "#!/bin/bash\n\n# Specify the device type and runtime as per your requirements\nDEVICE_TYPE=\"${DEVICE_TYPE:-iPhone 15 Pro}\"\nRUNTIME=\"${RUNTIME:-iOS18.6}\"\n\n# Create a unique identifier for the new simulator to avoid naming conflicts\nSIMULATOR_NAME=\"Simulator_$(uuidgen)\"\n\necho \"Creating a new iOS simulator: $SIMULATOR_NAME (Device: $DEVICE_TYPE, Runtime: $RUNTIME)\"\n\n# Create the simulator\nsimulator_id=$(xcrun simctl create \"$SIMULATOR_NAME\" \"$DEVICE_TYPE\" $RUNTIME)\necho \"Simulator ID: $simulator_id created.\"\n\n# Boot the simulator\necho \"Booting the simulator...\"\nxcrun simctl boot \"$simulator_id\"\n\n# Wait for the simulator to be fully booted\nwhile true; do\n    # Check the current state of the simulator\n    state=$(xcrun simctl list | grep \"$simulator_id\" | grep -o \"Booted\" || true)\n\n    if [ \"$state\" == \"Booted\" ]; then\n        echo \"Simulator $SIMULATOR_NAME is now ready.\"\n        break\n    else\n        echo \"Waiting for the simulator to be ready...\"\n        sleep 5 # sleep for 5 seconds before checking again to avoid spamming\n    fi\ndone"
  },
  {
    "path": ".github/workflows/close-inactive-issues.yaml",
    "content": "# Close issues that have had \"waiting for customer response\" label for too long.\n\n# This workflow is based on a very similar one from Flutter\n# https://github.com/flutter/flutter/blob/3.22.0/.github/workflows/no-response.yaml\n\nname: close inactive issues\n\non:\n  issue_comment:\n    types: [created]\n  schedule:\n    - cron: '0 */6 * * *'\n\npermissions:\n  issues: write\n\njobs:\n  main:\n    runs-on: ubuntu-latest\n    if: github.repository == 'mobile-dev-inc/maestro'\n    steps:\n      - uses: godofredoc/no-response@0ce2dc0e63e1c7d2b87752ceed091f6d32c9df09\n        with:\n          token: ${{ github.token }}\n          closeComment: >\n            Without additional information, we can't resolve this issue. We're\n            therefore reluctantly going to close it.\n\n\n            Feel free to open a new issue with all the required information\n            provided, including a [minimal, reproducible sample]. When creating\n            a new issue, please make sure to diligently fill out the issue\n            template.\n\n\n            Thank you for your contribution to our open-source community!\n\n\n            [minimal, reproducible sample]: https://stackoverflow.com/help/minimal-reproducible-example\n          # Number of days of inactivity before an issue is closed.\n          daysUntilClose: 14\n          # Only issues with this label will be closed (if they are inactive).\n          responseRequiredLabel: waiting for customer response\n"
  },
  {
    "path": ".github/workflows/lock-closed-issues.yaml",
    "content": "# Lock closed issues that have been inactive for a while.\n\n# This workflow is copied from Flutter\n# https://github.com/flutter/flutter/blob/3.22.0/.github/workflows/lock.yaml\n\nname: lock closed issues\n\npermissions:\n  issues: write\n\non:\n  schedule:\n    - cron: '0 */6 * * *'\n\njobs:\n  lock:\n    permissions:\n      issues: write\n    runs-on: ubuntu-latest\n    if: github.repository == 'mobile-dev-inc/maestro'\n    steps:\n      - uses: dessant/lock-threads@v5\n        with:\n          process-only: issues\n          github-token: ${{ github.token }}\n          # Number of days of inactivity before a closed issue is locked.\n          issue-inactive-days: 7\n          issue-comment: >\n            This issue has been automatically locked since there has not been\n            any recent activity after it was closed. If you are still\n            experiencing a similar problem, please file a new issue. Make\n            sure to follow the template and provide all the information\n            necessary to reproduce the issue.\n\n            Thank you for helping keep us our issue tracker clean!\n"
  },
  {
    "path": ".github/workflows/publish-cli.yaml",
    "content": "name: Publish CLI\n\non:\n  workflow_dispatch:\n\njobs:\n  publish:\n    runs-on: ubuntu-latest\n    if: github.repository == 'mobile-dev-inc/maestro'\n\n    steps:\n      - name: Clone repository\n        uses: actions/checkout@v4\n\n      - name: Set up Java\n        uses: actions/setup-java@v4\n        with:\n          distribution: zulu\n          java-version: 17\n          cache: gradle\n\n      - name: Publish CLI\n        run: ./gradlew :maestro-cli:jreleaserFullRelease --no-daemon --no-parallel\n        env:\n          JRELEASER_GITHUB_TOKEN: ${{ secrets.JRELEASER_GITHUB_TOKEN }}\n\n      - name: Print jReleaser log\n        if: always()\n        run: cat maestro-cli/build/jreleaser/trace.log\n"
  },
  {
    "path": ".github/workflows/publish-release.yaml",
    "content": "name: Publish Release\n\non:\n  workflow_dispatch:\n  push:\n    tags:\n      - 'v*'\n\nenv:\n  ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.ORG_GRADLE_PROJECT_MAVENCENTRALUSERNAME }}\n  ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.ORG_GRADLE_PROJECT_MAVENCENTRALPASSWORD }}\n\njobs:\n  publish:\n    runs-on: ubuntu-latest\n    if: github.repository == 'mobile-dev-inc/maestro'\n\n    steps:\n      - name: Clone repository\n        uses: actions/checkout@v4\n\n      - name: Set up Java\n        uses: actions/setup-java@v4\n        with:\n          distribution: zulu\n          java-version: 17\n          cache: gradle\n\n      - name: Retrieve version\n        run: |\n          echo \"VERSION_NAME=$(cat gradle.properties | grep -w \"VERSION_NAME\" | cut -d'=' -f2)\" >> $GITHUB_ENV\n\n      - name: Upload Maestro utils release\n        run: ./gradlew clean :maestro-utils:publishToMavenCentral --no-daemon --no-parallel\n        if: ${{ !endsWith(env.VERSION_NAME, '-SNAPSHOT') }}\n        env:\n          ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }}\n          ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}\n          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ env.ORG_GRADLE_PROJECT_mavenCentralUsername }}\n          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ env.ORG_GRADLE_PROJECT_mavenCentralPassword }}\n\n      - name: Upload Maestro client release\n        run: ./gradlew clean :maestro-client:publishToMavenCentral --no-daemon --no-parallel\n        if: ${{ !endsWith(env.VERSION_NAME, '-SNAPSHOT') }}\n        env:\n          ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }}\n          ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}\n          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ env.ORG_GRADLE_PROJECT_mavenCentralUsername }}\n          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ env.ORG_GRADLE_PROJECT_mavenCentralPassword }}\n\n      - name: Upload Maestro ios release\n        run: ./gradlew clean :maestro-ios:publishToMavenCentral --no-daemon --no-parallel\n        if: ${{ !endsWith(env.VERSION_NAME, '-SNAPSHOT') }}\n        env:\n          ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }}\n          ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}\n          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ env.ORG_GRADLE_PROJECT_mavenCentralUsername }}\n          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ env.ORG_GRADLE_PROJECT_mavenCentralPassword }}\n\n      - name: Upload Maestro orchestra release\n        run: ./gradlew clean :maestro-orchestra:publishToMavenCentral --no-daemon --no-parallel\n        if: ${{ !endsWith(env.VERSION_NAME, '-SNAPSHOT') }}\n        env:\n          ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }}\n          ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}\n          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ env.ORG_GRADLE_PROJECT_mavenCentralUsername }}\n          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ env.ORG_GRADLE_PROJECT_mavenCentralPassword }}\n\n      - name: Upload Maestro Orchestra Models release\n        run: ./gradlew clean :maestro-orchestra-models:publishToMavenCentral --no-daemon --no-parallel\n        if: ${{ !endsWith(env.VERSION_NAME, '-SNAPSHOT') }}\n        env:\n          ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }}\n          ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}\n          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ env.ORG_GRADLE_PROJECT_mavenCentralUsername }}\n          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ env.ORG_GRADLE_PROJECT_mavenCentralPassword }}\n\n      - name: Upload Maestro Proto release\n        run: ./gradlew clean :maestro-proto:publishToMavenCentral --no-daemon --no-parallel\n        if: ${{ !endsWith(env.VERSION_NAME, '-SNAPSHOT') }}\n        env:\n          ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }}\n          ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}\n          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ env.ORG_GRADLE_PROJECT_mavenCentralUsername }}\n          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ env.ORG_GRADLE_PROJECT_mavenCentralPassword }}\n\n      - name: Upload Maestro XCUiTest Driver\n        run: ./gradlew clean :maestro-ios-driver:publishToMavenCentral --no-daemon --no-parallel\n        if: ${{ !endsWith(env.VERSION_NAME, '-SNAPSHOT') }}\n        env:\n          ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }}\n          ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}\n          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ env.ORG_GRADLE_PROJECT_mavenCentralUsername }}\n          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ env.ORG_GRADLE_PROJECT_mavenCentralPassword }}\n\n      - name: Upload Maestro AI release\n        run: ./gradlew clean :maestro-ai:publishToMavenCentral --no-daemon --no-parallel\n        if: ${{ !endsWith(env.VERSION_NAME, '-SNAPSHOT') }}\n        env:\n          ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }}\n          ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}\n          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ env.ORG_GRADLE_PROJECT_mavenCentralUsername }}\n          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ env.ORG_GRADLE_PROJECT_mavenCentralPassword }}\n\n      - name: Upload Maestro Web release\n        run: ./gradlew clean :maestro-web:publishToMavenCentral --no-daemon --no-parallel\n        if: ${{ !endsWith(env.VERSION_NAME, '-SNAPSHOT') }}\n        env:\n          ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }}\n          ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}\n          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ env.ORG_GRADLE_PROJECT_mavenCentralUsername }}\n          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ env.ORG_GRADLE_PROJECT_mavenCentralPassword }}\n\n      - name: Upload Maestro CLI release\n        run: ./gradlew clean :maestro-cli:publishToMavenCentral --no-daemon --no-parallel\n        if: ${{ !endsWith(env.VERSION_NAME, '-SNAPSHOT') }}\n        env:\n          ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_PRIVATE_KEY }}\n          ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_PASSWORD }}\n          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ env.ORG_GRADLE_PROJECT_mavenCentralUsername }}\n          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ env.ORG_GRADLE_PROJECT_mavenCentralPassword }}"
  },
  {
    "path": ".github/workflows/publish-snapshot.yaml",
    "content": "name: Publish Snapshot\n\non:\n  workflow_dispatch:\n  push:\n    branches:\n      - main\n\njobs:\n  publish:\n    runs-on: ubuntu-latest\n    if: github.repository == 'mobile-dev-inc/maestro'\n\n    steps:\n      - name: Clone repository\n        uses: actions/checkout@v4\n\n      - name: Set up Java\n        uses: actions/setup-java@v4\n        with:\n          distribution: zulu\n          java-version: 17\n          cache: gradle\n\n      - name: Retrieve version\n        run: |\n          echo \"VERSION_NAME=$(cat gradle.properties | grep -w \"VERSION_NAME\" | cut -d'=' -f2)\" >> $GITHUB_ENV\n\n      - name: Upload Maestro utils release\n        run: ./gradlew clean :maestro-utils:publish --no-daemon --no-parallel\n        if: endsWith(env.VERSION_NAME, '-SNAPSHOT')\n        env:\n          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }}\n          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }}\n\n      - name: Upload Maestro client release\n        run: ./gradlew clean :maestro-client:publish --no-daemon --no-parallel\n        if: endsWith(env.VERSION_NAME, '-SNAPSHOT')\n        env:\n          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }}\n          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }}\n\n      - name: Upload Maestro ios release\n        run: ./gradlew clean :maestro-ios:publish --no-daemon --no-parallel\n        if: endsWith(env.VERSION_NAME, '-SNAPSHOT')\n        env:\n          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }}\n          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }}\n\n      - name: Upload Maestro orchestra release\n        run: ./gradlew clean :maestro-orchestra:publish --no-daemon --no-parallel\n        if: endsWith(env.VERSION_NAME, '-SNAPSHOT')\n        env:\n          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }}\n          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }}\n\n      - name: Upload Maestro Orchestra Models release\n        run: ./gradlew clean :maestro-orchestra-models:publish --no-daemon --no-parallel\n        if: endsWith(env.VERSION_NAME, '-SNAPSHOT')\n        env:\n          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }}\n          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }}\n\n      - name: Upload Maestro Proto release\n        run: ./gradlew clean :maestro-proto:publish --no-daemon --no-parallel\n        if: endsWith(env.VERSION_NAME, '-SNAPSHOT')\n        env:\n          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }}\n          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }}\n\n      - name: Upload Maestro XCUiTest Driver\n        run: ./gradlew clean :maestro-ios-driver:publish --no-daemon --no-parallel\n        if: endsWith(env.VERSION_NAME, '-SNAPSHOT')\n        env:\n            ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }}\n            ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }}\n\n      - name: Upload Maestro AI\n        run: ./gradlew clean :maestro-ai:publish --no-daemon --no-parallel\n        if: endsWith(env.VERSION_NAME, '-SNAPSHOT')\n        env:\n          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }}\n          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }}\n\n      - name: Upload Maestro web\n        run: ./gradlew clean :maestro-web:publish --no-daemon --no-parallel\n        if: endsWith(env.VERSION_NAME, '-SNAPSHOT')\n        env:\n          ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }}\n          ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }}\n"
  },
  {
    "path": ".github/workflows/test-e2e-ios-intel.yaml",
    "content": "name: Test E2E on iOS (Intel)\n\non:\n  workflow_dispatch:\n\njobs:\n  build:\n    name: Build on Java ${{ matrix.java-version }}\n    runs-on: macos-latest\n    timeout-minutes: 20\n\n    strategy:\n      fail-fast: false\n      matrix:\n        java-version: [17]\n\n    steps:\n      - name: Clone repository\n        uses: actions/checkout@v6\n\n      - name: Set up Java\n        uses: actions/setup-java@v5\n        with:\n          distribution: zulu\n          java-version: ${{ matrix.java-version }}\n          cache: gradle\n\n      # Do not rebuild this - let's test the one that is in the repo\n      #- name: Build xctest-runner\n      #  run: ./maestro-ios-xctest-runner/build-maestro-ios-runner.sh | xcbeautify\n\n      - name: Build Maestro CLI\n        run: ./gradlew :maestro-cli:distZip\n\n      - name: Upload zipped Maestro CLI artifact\n        uses: actions/upload-artifact@v6\n        with:\n          name: maestro-cli-jdk${{ matrix.java-version }}-run_id${{ github.run_id }}\n          path: maestro-cli/build/distributions/maestro.zip\n          retention-days: 1\n\n      - name: Upload build/Products to artifacts\n        uses: actions/upload-artifact@v6\n        with:\n          name: build__Products-jdk${{ matrix.java-version }}\n          path: build/Products\n          retention-days: 1\n\n  test-ios:\n    name: Test on iOS\n    runs-on: macos-15-intel\n    needs: build\n    timeout-minutes: 120\n\n    env:\n      MAESTRO_DRIVER_STARTUP_TIMEOUT: 240000 # 240s\n      MAESTRO_CLI_LOG_PATTERN_CONSOLE: '%d{HH:mm:ss.SSS} [%5level] %logger.%method: %msg%n'\n\n    steps:\n      - name: Clone repository (only needed for the e2e directory)\n        uses: actions/checkout@v6\n\n      - name: Set up JDK\n        uses: actions/setup-java@v5\n        with:\n          distribution: zulu\n          java-version: 17\n\n      - name: Download artifacts\n        uses: actions/download-artifact@v7\n        with:\n          name: maestro-cli-jdk17-run_id${{ github.run_id }}\n\n      - name: Add Maestro CLI executable to PATH\n        run: |\n          unzip maestro.zip -d maestro_extracted\n          echo \"$PWD/maestro_extracted/maestro/bin\" >> $GITHUB_PATH\n\n      - name: Check if Maestro CLI executable starts up\n        run: |\n          maestro --help\n          maestro --version\n\n      - name: Boot Simulator\n        run: |\n          xcrun simctl list runtimes\n          export RUNTIME=\"iOS18.5\"\n          export DEVICE_TYPE=\"iPhone 16\"\n          ./.github/scripts/boot_simulator.sh\n\n      - name: Download apps\n        working-directory: ${{ github.workspace }}/e2e\n        run: ./download_apps ios\n\n      - name: Install apps\n        working-directory: ${{ github.workspace }}/e2e\n        run: ./install_apps ios\n\n      - name: Start screen recording\n        run: |\n          xcrun simctl io booted recordVideo --codec h264 ~/screenrecord.mp4 &\n          echo $! > ~/screenrecord.pid\n\n      - name: Run tests\n        working-directory: ${{ github.workspace }}/e2e\n        timeout-minutes: 120\n        run: ./run_tests ios\n\n      - name: Stop screen recording\n        if: success() || failure()\n        run: kill -SIGINT \"$(cat ~/screenrecord.pid)\"\n\n      - name: Upload ~/.maestro artifacts\n        uses: actions/upload-artifact@v6\n        if: success() || failure()\n        with:\n          name: maestro-root-dir-ios\n          path: ~/.maestro\n          retention-days: 7\n          include-hidden-files: true\n\n      - name: Upload xctest runner logs\n        uses: actions/upload-artifact@v6\n        if: success() || failure()\n        with:\n          name: xctest_runner_logs\n          path: ~/Library/Logs/maestro/xctest_runner_logs\n          retention-days: 7\n          include-hidden-files: true\n\n      - name: Upload screen recording of Simulator\n        uses: actions/upload-artifact@v6\n        if: success() || failure()\n        with:\n          name: maestro-screenrecord-ios.mp4\n          path: ~/screenrecord.mp4\n          retention-days: 7\n"
  },
  {
    "path": ".github/workflows/test-e2e-prod.yaml",
    "content": "name: Test E2E (prod)\n\non:\n  workflow_dispatch:\n  schedule:\n    - cron: '0 * * * *'\n\njobs:\n  test-cloud-production:\n    # This job is copied from \"e2e-production\" in mobile-dev-inc/monorepo.\n    # We want it here so open-source users can also have some visibility into it.\n\n    runs-on: ubuntu-latest\n    if: github.repository == 'mobile-dev-inc/maestro'\n\n    steps:\n      - name: Clone repository\n        uses: actions/checkout@v4\n\n      - name: Set up Java\n        uses: actions/setup-java@v4\n        with:\n          distribution: zulu\n          java-version: 17\n\n      - name: Install Maestro\n        run: |\n          curl -Ls --retry 3 --retry-all-errors \"https://get.maestro.mobile.dev\" | bash\n          echo \"${HOME}/.maestro/bin\" >> $GITHUB_PATH\n\n      - name: Print Maestro version\n        run: maestro --version\n\n      - name: Download samples\n        run: maestro download-samples\n\n      - name: Run iOS test\n        run: |\n          maestro cloud \\\n            --apiKey ${{ secrets.E2E_MOBILE_DEV_API_KEY }} \\\n            --timeout 180 \\\n            --fail-on-cancellation \\\n            --include-tags=advanced \\\n            samples/sample.zip samples\n\n      - name: Run Android test\n        run: |\n          maestro cloud \\\n            --apiKey ${{ secrets.E2E_MOBILE_DEV_API_KEY }} \\\n            --fail-on-cancellation \\\n            --include-tags advanced \\\n            samples/sample.apk samples\n\n      - name: Send Slack message\n        if: failure()\n        run: |\n          curl --request POST \\\n              --url \"${{ secrets.E2E_SLACK_WEBHOOK_URL }}\" \\\n              --header 'Content-Type: application/json' \\\n              --data '{\n            \"text\": \"🚨 *Maestro E2E Test Failed*\\nStatus: 'Failure'\\nRun: <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View details>\"\n          }'\n\n      # - name: Trigger alert on failure\n      #   if: ${{ false }}\n      #   # if: failure()\n      #   run: |\n      #     curl --request POST \\\n      #         --url \"https://events.pagerduty.com/v2/enqueue\" \\\n      #         --header 'Content-Type: application/json' \\\n      #         --data '{\n      #       \"payload\": {\n      #         \"summary\": \"E2E test failed\",\n      #         \"source\": \"E2E test\",\n      #         \"severity\": \"critical\"\n      #       },\n      #       \"routing_key\": \"${{ secrets.E2E_PAGER_DUTY_INTEGRATION_KEY }}\",\n      #       \"event_action\": \"trigger\",\n      #       \"links\": [\n      #           {\n      #             \"href\": \"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\",\n      #             \"text\": \"Failed E2E test - Github Action\"\n      #           }\n      #       ]\n      #     }'\n"
  },
  {
    "path": ".github/workflows/test-e2e.yaml",
    "content": "name: Test E2E\n\non:\n  workflow_dispatch:\n  pull_request:\n\njobs:\n  build:\n    name: Build on Java ${{ matrix.java-version }}\n    runs-on: macos-latest\n    timeout-minutes: 20\n\n    strategy:\n      fail-fast: false\n      matrix:\n        java-version: [17]\n\n    steps:\n      - name: Clone repository\n        uses: actions/checkout@v6\n\n      - name: Set up Java\n        uses: actions/setup-java@v5\n        with:\n          distribution: zulu\n          java-version: ${{ matrix.java-version }}\n          cache: gradle\n\n      - name: Build xctest-runner\n        run: ./maestro-ios-xctest-runner/build-maestro-ios-runner.sh | xcbeautify\n\n      - name: Build Maestro CLI\n        run: ./gradlew :maestro-cli:distZip\n\n      - name: Upload zipped Maestro CLI artifact\n        uses: actions/upload-artifact@v6\n        with:\n          name: maestro-cli-jdk${{ matrix.java-version }}-run_id${{ github.run_id }}\n          path: maestro-cli/build/distributions/maestro.zip\n          retention-days: 1\n\n      - name: Upload build/Products to artifacts\n        uses: actions/upload-artifact@v6\n        with:\n          name: build__Products-jdk${{ matrix.java-version }}\n          path: build/Products\n          retention-days: 1\n\n  test-web:\n    name: Test on Web\n    runs-on: ubuntu-latest\n    needs: build\n\n    steps:\n      - name: Clone repository (only needed for the e2e directory)\n        uses: actions/checkout@v6\n\n      - name: Set up demo_app workspace\n        run: |\n          git clone --depth 1 https://github.com/mobile-dev-inc/demo_app /tmp/demo_app\n          mkdir -p ${{ github.workspace }}/e2e/workspaces/demo_app\n          cp -r /tmp/demo_app/.maestro/. ${{ github.workspace }}/e2e/workspaces/demo_app/\n\n      - name: Set up Java\n        uses: actions/setup-java@v5\n        with:\n          distribution: zulu\n          java-version: 17\n\n      - name: Set up Chrome\n        uses: browser-actions/setup-chrome@v2\n        with:\n          chrome-version: 142\n\n      - name: Download artifacts\n        uses: actions/download-artifact@v7\n        with:\n          name: maestro-cli-jdk17-run_id${{ github.run_id }}\n\n      - name: Add Maestro CLI executable to PATH\n        run: |\n          unzip maestro.zip -d maestro_extracted\n          echo \"$PWD/maestro_extracted/maestro/bin\" >> $GITHUB_PATH\n\n      - name: Check if Maestro CLI executable starts up\n        run: |\n          maestro --help\n          maestro --version\n          \n      - name: Run tests\n        working-directory: ${{ github.workspace }}/e2e\n        timeout-minutes: 20\n        run: ./run_tests web\n\n      - name: Upload ~/.maestro artifacts\n        uses: actions/upload-artifact@v6\n        if: success() || failure()\n        with:\n          name: maestro-root-dir-web\n          path: ~/.maestro\n          retention-days: 7\n          include-hidden-files: true\n\n  test-android:\n    name: Test on Android\n    runs-on: ubuntu-latest\n    needs: build\n    timeout-minutes: 60\n\n    env:\n      ANDROID_HOME: /home/runner/androidsdk\n      ANDROID_SDK_ROOT: /home/runner/androidsdk\n      ANDROID_AVD_HOME: /home/runner/.config/.android/avd/\n      ANDROID_OS_IMAGE: system-images;android-32;google_apis;x86_64\n      ANDROID_PLATFORM: platforms;android-34\n\n    steps:\n      - name: Enable KVM group perms\n        run: |\n          echo 'KERNEL==\"kvm\", GROUP=\"kvm\", MODE=\"0666\", OPTIONS+=\"static_node=kvm\"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules\n          sudo udevadm control --reload-rules\n          sudo udevadm trigger --name-match=kvm\n\n      - name: Clone repository (only needed for the e2e directory)\n        uses: actions/checkout@v6\n\n      - name: Set up demo_app workspace\n        run: |\n          git clone --depth 1 https://github.com/mobile-dev-inc/demo_app /tmp/demo_app\n          mkdir -p ${{ github.workspace }}/e2e/workspaces/demo_app\n          cp -r /tmp/demo_app/.maestro/. ${{ github.workspace }}/e2e/workspaces/demo_app/\n\n      - name: Set up Java\n        uses: actions/setup-java@v5\n        with:\n          distribution: zulu\n          java-version: 17\n\n      - name: Download Maestro build from previous job\n        uses: actions/download-artifact@v7\n        with:\n          name: maestro-cli-jdk17-run_id${{ github.run_id }}\n\n      - name: Add Maestro CLI executable to PATH\n        run: |\n          unzip maestro.zip -d maestro_extracted\n          echo \"$PWD/maestro_extracted/maestro/bin\" >> $GITHUB_PATH\n\n      - name: Check if Maestro CLI executable starts up\n        run: |\n          maestro --help\n          maestro --version\n\n      - name: Set up mobile-dev-inc/bartek-scripts (for install_android_sdk script)\n        run: |\n          git clone https://github.com/mobile-dev-inc/bartek-scripts.git $HOME/scripts\n          echo \"$HOME/scripts/bin\" >> $GITHUB_PATH\n\n      - name: Set up Android Command-line Tools\n        run: |\n          # v13 - see https://stackoverflow.com/a/78890086/7009800\n          install_android_sdk https://dl.google.com/android/repository/commandlinetools-linux-12266719_latest.zip\n          echo \"$ANDROID_HOME/cmdline-tools/latest/bin:$PATH\" >> $GITHUB_PATH\n\n      - name: Set up Android SDK components\n        run: |\n          yes | sdkmanager --licenses\n          sdkmanager --install emulator\n          echo \"$ANDROID_HOME/emulator\" >> $GITHUB_PATH\n          sdkmanager --install \"platform-tools\"\n          echo \"$ANDROID_HOME/platform-tools\" >> $GITHUB_PATH\n          sdkmanager --install \"$ANDROID_PLATFORM\"\n          sdkmanager --install \"$ANDROID_OS_IMAGE\"\n\n      - name: Create AVD\n        run: |\n          avdmanager -s create avd \\\n            --package \"$ANDROID_OS_IMAGE\" \\\n            --name \"MyAVD\"\n\n          echo \"DEBUG INFO\"\n          \n          avdmanager list avd\n\n          echo \"ANDROID_PREFS_ROOT=$ANDROID_PREFS_ROOT\"\n          echo \"ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT\"\n          echo \"ANDROID_HOME=$ANDROID_HOME\"\n          echo \"ANDROID_SDK_HOME=$ANDROID_SDK_HOME\"\n          echo \"ANDROID_AVD_HOME=$ANDROID_AVD_HOME\"\n          echo \"ANDROID_EMULATOR_HOME=$ANDROID_EMULATOR_HOME\"\n          \n          echo \"HOME=$HOME\"\n          \n          cat << EOF >> ~/.config/.android/avd/MyAVD.avd/config.ini\n          hw.cpu.ncore=2\n          hw.gpu.enabled=yes\n          hw.gpu.mode=swiftshader_indirect\n          hw.ramSize=3072\n          disk.dataPartition.size=4G\n          vm.heapSize=576\n          hw.lcd.density=440\n          hw.lcd.height=2220\n          hw.lcd.width=1080\n          EOF\n\n      - name: Run AVD\n        run: |\n          emulator @MyAVD \\\n            -verbose -no-snapshot -no-window -no-audio -no-boot-anim -accel on -camera-back none -qemu -m 3072 \\\n            >~/emulator_stdout.log \\\n            2>~/emulator_stderr.log &\n\n      - name: Wait for AVD to start up\n        run: |\n          adb wait-for-device && echo 'Emulator device online'\n          adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;' && echo 'Emulator booted'\n\n          # This is also a prerequiste\n          while true; do\n            adb shell service list | grep 'package' && echo 'service \"package\" is active!' && break\n            echo 'waiting for service \"package\" to start'\n            sleep 1\n          done\n\n      - name: Download apps\n        working-directory: ${{ github.workspace }}/e2e\n        run: ./download_apps android\n\n      - name: Install apps\n        working-directory: ${{ github.workspace }}/e2e\n        run: ./install_apps android\n\n\n\n      - name: Start screen recording of AVD\n        run: |\n          adb shell screenrecord /sdcard/screenrecord.mp4 &\n          echo $! > ~/screenrecord.pid\n\n      - name: Run tests\n        working-directory: ${{ github.workspace }}/e2e\n        timeout-minutes: 20\n        run: ./run_tests android\n\n      - name: Stop screen recording of AVD\n        if: success() || failure()\n        run: |\n          kill -SIGINT \"$(cat ~/screenrecord.pid)\" || echo \"failed to kill screenrecord: code $?\" && exit 0\n          sleep 5 # prevent video file corruption\n          adb pull /sdcard/screenrecord.mp4 ~/screenrecord.mp4\n\n      - name: Upload ~/.maestro artifacts\n        uses: actions/upload-artifact@v6\n        if: success() || failure()\n        with:\n          name: maestro-root-dir-android\n          path: ~/.maestro\n          retention-days: 7\n          include-hidden-files: true\n\n      - name: Upload screen recording of AVD\n        uses: actions/upload-artifact@v6\n        if: success() || failure()\n        with:\n          name: maestro-screenrecord-android.mp4\n          path: ~/screenrecord.mp4\n          retention-days: 7\n\n  test-ios:\n    name: Test on iOS\n    runs-on: macos-26\n    needs: build\n    timeout-minutes: 120\n\n    env:\n      MAESTRO_DRIVER_STARTUP_TIMEOUT: 240000 # 240s\n      MAESTRO_CLI_LOG_PATTERN_CONSOLE: '%d{HH:mm:ss.SSS} [%5level] %logger.%method: %msg%n'\n\n    steps:\n      - name: Clone repository (only needed for the e2e directory)\n        uses: actions/checkout@v6\n\n      - name: Set up demo_app workspace\n        run: |\n          git clone --depth 1 https://github.com/mobile-dev-inc/demo_app /tmp/demo_app\n          mkdir -p ${{ github.workspace }}/e2e/workspaces/demo_app\n          cp -r /tmp/demo_app/.maestro/. ${{ github.workspace }}/e2e/workspaces/demo_app/\n\n      - name: Set up JDK\n        uses: actions/setup-java@v5\n        with:\n          distribution: zulu\n          java-version: 17\n\n      - name: Download artifacts\n        uses: actions/download-artifact@v7\n        with:\n          name: maestro-cli-jdk17-run_id${{ github.run_id }}\n\n      - name: Add Maestro CLI executable to PATH\n        run: |\n          unzip maestro.zip -d maestro_extracted\n          echo \"$PWD/maestro_extracted/maestro/bin\" >> $GITHUB_PATH\n\n      - name: Check if Maestro CLI executable starts up\n        run: |\n          maestro --help\n          maestro --version\n\n      - name: Boot Simulator\n        run: |\n          xcrun simctl list runtimes\n          export RUNTIME=\"iOS26.1\"\n          export DEVICE_TYPE=\"iPhone 17 Pro\"\n          ./.github/scripts/boot_simulator.sh\n\n      - name: Download apps\n        working-directory: ${{ github.workspace }}/e2e\n        run: ./download_apps ios\n\n      - name: Install apps\n        working-directory: ${{ github.workspace }}/e2e\n        run: ./install_apps ios\n\n      - name: Start screen recording\n        run: |\n          xcrun simctl io booted recordVideo --codec h264 ~/screenrecord.mp4 &\n          echo $! > ~/screenrecord.pid\n\n      - name: Run tests\n        working-directory: ${{ github.workspace }}/e2e\n        timeout-minutes: 120\n        run: ./run_tests ios\n\n      - name: Stop screen recording\n        if: success() || failure()\n        run: kill -SIGINT \"$(cat ~/screenrecord.pid)\"\n\n      - name: Upload ~/.maestro artifacts\n        uses: actions/upload-artifact@v6\n        if: success() || failure()\n        with:\n          name: maestro-root-dir-ios\n          path: ~/.maestro\n          retention-days: 7\n          include-hidden-files: true\n\n      - name: Upload xctest runner logs\n        uses: actions/upload-artifact@v6\n        if: success() || failure()\n        with:\n          name: xctest_runner_logs\n          path: ~/Library/Logs/maestro/xctest_runner_logs\n          retention-days: 7\n          include-hidden-files: true\n\n      - name: Upload screen recording of Simulator\n        uses: actions/upload-artifact@v6\n        if: success() || failure()\n        with:\n          name: maestro-screenrecord-ios.mp4\n          path: ~/screenrecord.mp4\n          retention-days: 7\n\n  test-ios-xctest-runner:\n    name: Test on iOS (XCTest Runner only)\n    if: false  # Disabled: This needs be fixed, not working yet.\n    runs-on: macos-latest\n    needs: build\n    timeout-minutes: 30\n\n    steps:\n      - name: Clone repository (only needed for the e2e directory)\n        uses: actions/checkout@v6\n\n      - name: Set up JDK\n        uses: actions/setup-java@v5\n        with:\n          distribution: zulu\n          java-version: 17\n\n      - name: Download Maestro artifact\n        uses: actions/download-artifact@v7\n        with:\n          name: maestro-cli-jdk17-run_id${{ github.run_id }}\n\n      - name: Download build/Products artifact\n        uses: actions/download-artifact@v7\n        with:\n          name: build__Products-jdk17\n          path: build/Products\n\n      - name: Add Maestro CLI executable to PATH\n        run: |\n          unzip maestro.zip -d maestro_extracted\n          echo \"$PWD/maestro_extracted/maestro/bin\" >> $GITHUB_PATH\n\n      - name: Check if Maestro CLI executable starts up\n        run: |\n          maestro --help\n          maestro --version\n\n      - name: Boot Simulator\n        run: ./.github/scripts/boot_simulator.sh\n\n      - name: Run tests\n        timeout-minutes: 15\n        run: ./maestro-ios-xctest-runner/test-maestro-ios-runner.sh\n\n      - name: Upload xc test runner logs\n        uses: actions/upload-artifact@v6\n        if: success() || failure()\n        with:\n          name: test-ios-xctest-runner__xctest_runner_logs\n          path: ~/Library/Logs/maestro/xctest_runner_logs\n          retention-days: 7\n          include-hidden-files: true\n"
  },
  {
    "path": ".github/workflows/test.yaml",
    "content": "name: Test\n\non:\n  workflow_dispatch:\n  pull_request:\n\njobs:\n  unit-test:\n    name: Unit Test on Java ${{ matrix.java-version }}\n    runs-on: ubuntu-latest\n\n    strategy:\n      fail-fast: false\n      matrix:\n        java-version: [17]\n\n    steps:\n      - name: Clone repository\n        uses: actions/checkout@v4\n\n      - name: Set up Java\n        uses: actions/setup-java@v4\n        with:\n          distribution: zulu\n          java-version: ${{ matrix.java-version }}\n          cache: gradle\n\n      - name: Test\n        id: unit-test\n        run: ./gradlew test\n\n      - name: Upload unit test report\n        uses: actions/upload-artifact@v4\n        if: failure()\n        with:\n          name: maestro-unit-test-report\n          path: ./**/build/reports/tests/test\n          retention-days: 1\n          include-hidden-files: true\n\n  ios-driver-lib-test:\n    name: MaestroDriverLib Unit Tests\n    runs-on: macos-latest\n    timeout-minutes: 10\n\n    steps:\n      - name: Clone repository\n        uses: actions/checkout@v4\n\n      - name: Run MaestroDriverLib Tests\n        working-directory: ${{ github.workspace }}/maestro-ios-xctest-runner/MaestroDriverLib\n        run: swift test\n\n  ios-xctest-runner-test:\n    name: iOS XCTest Runner Unit Tests\n    runs-on: macos-latest\n    timeout-minutes: 15\n\n    steps:\n      - name: Clone repository\n        uses: actions/checkout@v4\n\n      - name: Select Xcode\n        run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer\n\n      - name: Run iOS Unit Tests\n        working-directory: ${{ github.workspace }}/maestro-ios-xctest-runner\n        run: |\n          xcodebuild test \\\n            -project maestro-driver-ios.xcodeproj \\\n            -scheme maestro-driver-iosTests \\\n            -destination 'platform=iOS Simulator,name=iPhone 16' \\\n            -only-testing:maestro-driver-iosTests \\\n            | xcpretty --color || exit ${PIPESTATUS[0]}\n\n  validate-gradle-wrapper:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Clone repository\n        uses: actions/checkout@v4\n\n      - name: Validate Gradle Wrapper\n        uses: gradle/actions/wrapper-validation@v4\n"
  },
  {
    "path": ".github/workflows/update-samples.yaml",
    "content": "name: Update samples\n\non:\n  workflow_dispatch:\n  push:\n    branches: [main]\n\njobs:\n  main:\n    runs-on: ubuntu-latest\n    if: github.repository == 'mobile-dev-inc/maestro'\n\n    steps:\n      - name: Clone repository\n        uses: actions/checkout@v4\n\n      - name: Authenticate to Google Cloud\n        uses: google-github-actions/auth@v2\n        with:\n          # These credentials should only have write access to the bucket\n          credentials_json: ${{ secrets.GCP_MOBILEDEV_BUCKET_CREDENTIALS }}\n\n      - name: Set up Google Cloud CLI\n        uses: google-github-actions/setup-gcloud@v2\n        with:\n          version: '>= 484.0.0'\n          project_id: perf-dev-289002\n    \n      - name: Upload samples to public Google Cloud Storage bucket\n        run: |\n          cd e2e/\n          ./update_samples\n"
  },
  {
    "path": ".gitignore",
    "content": ".DS_Store\n\n# Ignore Gradle project-specific cache directory\n.gradle\n\n# Ignore Gradle build output directory\nbuild\n\n# Ignore Gradle local properties\nlocal.properties\n\nbin\n\n# media assets\nmaestro-orchestra/src/test/resources/media/assets/*\n\n# Local files\nlocal/"
  },
  {
    "path": ".idea/.gitignore",
    "content": "# Default ignored files\n/shelf/\n/workspace.xml\n# Editor-based HTTP Client requests\n/httpRequests/\n# Datasource local storage ignored files\n/dataSources/\n/dataSources.local.xml\n\n# Above is default, IntelliJ-generated config. Below is our custom config.\n# Inspired by:\n# - https://github.com/Vadorequest/JetBrains-Intellij-IDEA-.gitignore-best-practices\n# - https://github.com/salarmehr/idea-gitignore\n# - https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore\n\ndataSources/\ndataSources.local.xml\nmisc.xml\nworkspace.xml\n\ngoogle-java-format.xml\ninspectionProfiles/\n\ncompiler.xml\ndeploymentTargetSelector.xml\ngradle.xml\nkotlinc.xml\nvcs.xml\n\ncopilot.data.migration.*"
  },
  {
    "path": ".idea/.name",
    "content": "maestro"
  },
  {
    "path": ".idea/dictionaries/project.xml",
    "content": "<component name=\"ProjectDictionaryState\">\n  <dictionary name=\"project\">\n    <words>\n      <w>addmedia</w>\n      <w>amanjeet</w>\n      <w>applesimutils</w>\n      <w>avdmanager</w>\n      <w>bartekpacia</w>\n      <w>bartkepacia</w>\n      <w>berik</w>\n      <w>caseley</w>\n      <w>cirrusci</w>\n      <w>clearstate</w>\n      <w>dadb</w>\n      <w>devicectl</w>\n      <w>dpad</w>\n      <w>evals</w>\n      <w>faceid</w>\n      <w>feeditem</w>\n      <w>graal</w>\n      <w>graaljs</w>\n      <w>inputmethod</w>\n      <w>iphoneos</w>\n      <w>iphonesimulator</w>\n      <w>jreleaser</w>\n      <w>keyevent</w>\n      <w>macosx</w>\n      <w>mdev</w>\n      <w>medialibrary</w>\n      <w>mobiledev</w>\n      <w>mobilesafari</w>\n      <w>modelcontextprotocol</w>\n      <w>niklasson</w>\n      <w>nowinandroid</w>\n      <w>openqa</w>\n      <w>posthog</w>\n      <w>printenv</w>\n      <w>reinstalls</w>\n      <w>rhinojs</w>\n      <w>runscript</w>\n      <w>saveliev</w>\n      <w>screenrecord</w>\n      <w>screenrecording</w>\n      <w>sdkmanager</w>\n      <w>simctl</w>\n      <w>systemui</w>\n      <w>takamine</w>\n      <w>testsuites</w>\n      <w>tokou</w>\n      <w>udid</w>\n      <w>visschers</w>\n      <w>xcbeautify</w>\n      <w>xcrun</w>\n      <w>xctestrun</w>\n      <w>xctestrunner</w>\n      <w>xctrunner</w>\n      <w>xcuitest</w>\n      <w>yamls</w>\n      <w>zaytsev</w>\n    </words>\n  </dictionary>\n</component>"
  },
  {
    "path": ".run/cli-version.run.xml",
    "content": "<component name=\"ProjectRunConfigurationManager\">\n  <configuration default=\"false\" name=\"CLI | version\" type=\"JetRunConfigurationType\">\n    <option name=\"MAIN_CLASS_NAME\" value=\"maestro.cli.AppKt\" />\n    <module name=\"maestro.maestro-cli.main\" />\n    <option name=\"PROGRAM_PARAMETERS\" value=\"-version\" />\n    <shortenClasspath name=\"NONE\" />\n    <method v=\"2\">\n      <option name=\"Make\" enabled=\"true\" />\n    </method>\n  </configuration>\n</component>\n"
  },
  {
    "path": ".run/cli.run.xml",
    "content": "<component name=\"ProjectRunConfigurationManager\">\n  <configuration default=\"false\" name=\"CLI\" type=\"JetRunConfigurationType\">\n    <option name=\"MAIN_CLASS_NAME\" value=\"maestro.cli.AppKt\" />\n    <module name=\"maestro.maestro-cli.main\" />\n    <option name=\"PROGRAM_PARAMETERS\" value=\"$Prompt$\" />\n    <option name=\"WORKING_DIRECTORY\" value=\"$FilePrompt$\" />\n    <shortenClasspath name=\"NONE\" />\n    <method v=\"2\">\n      <option name=\"Make\" enabled=\"true\" />\n    </method>\n  </configuration>\n</component>\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\n## Unreleased\n\n## 2.4.0\n\n- Add new device config flags for cloud and start-device\n    - Deprecated `--ios-version`, `--android-api-level`, and `--os-version` flags. These will be removed in a future release.\n    - `--device-os` and `--device-model` replace all platform-specific device options, providing a single consistent way to specify devices across iOS, Android, and Web\n- Add `maestro list-devices` command to see locally available devices on the machine\n- Add `maestro list-cloud-devices` command to see available cloud device models and OS versions\n- Support iframes for web tests\n- Faster feedback when using Maestro Cloud - more validation is happening locally\n- Improve feedback when startRecording fails on iOS\n- Add clearState support for web tests\n- Fix inputText crashing on iOS pincode screens\n- Fix incorrect websocket timeout (5000ms, not 5000s!)\n- Add support for variables as input to setOrientation\n- Add deprecation notice to `maestro studio`\n- Improved variable isolation and reduced memory usage in JavaScript evaluations\n- Fix step ordering in the html-detailed test report\n- Improve timeouts in all API calls (especially useful for Maestro Cloud uploads)\n\n## 2.3.0\n\n- Add web support for `clearState` command\n- Fix `assertScreenshot` not failing when screenshot dimensions are mismatched\n- Make `assertScreenshot` work more like `takeScreenshot` by not requiring file extension\n- Fix path resolution for `assertScreenshot` to allow relative paths from flows for reference images\n- Fix `inputRandomPersonName` to generate a predictable \"FirstName LastName\" format\n- Fix iPad landscape orientation support\n- Fix specifying `--device` when also specifying `--host`\n- Fix cloud uploads to always use requested device specifications on retries\n\nThanks to @SosenWiosen, @leggomuhgreggo and @jkronborg who contributed changes included in this release ❤️\n\n## 2.2.0\n\n- Add `--screen-size` option to test command, to specify the headless browser window size when testing web flows\n- Add `MAESTRO_DEVICE_UDID`, `MAESTRO_SHARD_ID`, and `MAESTRO_SHARD_INDEX` as default environment variables (useful for screenshot filenames when sharding)\n- Add step information to HTML test reports via a new `html-detailed` formatter\n- Add tags and custom property information to HTML and JUnit test reports\n- Add a new `assertScreenshot` command for visual regression testing\n- Add a `cropOn` property to the `takeScreenshot` command to crop screenshots to a specific element\n- Fix scrolling in Flutter Web\n- Fix output of subflows when using `--no-ansi` flag\n- Show `maestro hierarchy` and `maestro check-syntax` commands in `maestro --help`\n- Fix iOS driver app on Simulators running on Intel-based Macs\n- Fix a potential hang between Maestro and the on-device drivers when calls take too long\n- Some logging adjustments for less noise during web tests\n- Bump web support to Chrome v144\n- Bump DataFaker to v2.5.3, GraalJS engine to v24.2.0, log4j to v2.25.3\n\nThanks to @sazquatch17, @ImL1s, @sidferreira, @SosenWiosen, @TheKohan, @Fl0p, @ff-vivek and @eldare who all contributed changes included in this release ❤️\n\n## 2.1.0\n\n- Add `setPermissions` command, for setting app permissions outside of `launchApp`\n- Add `setClipboard` command, for setting Maestro's internal clipboard without copying from an element\n- Add `--platform` and `--device` to `maestro test` command\n- Add custom JUnit properties to reporting\n- Add support for --no-reinstall-driver option to `test` and `hierarchy` commands\n- Add creation of missing folders specified in the path when taking screenshots or recording videos\n- Bump web support to Chrome v142\n- Bump npm dependencies in legacy Maestro Studio\n- Hide incomplete `maestro driver-setup` command from `maestro --help`\n- Remove deprecated `deterministicOrder` feature from workspace config\n- Remove deprecated `maestro upload` command\n- Fix bug that reported that analytics was enabled when it wasn't\n- Fix building Maestro on Java >17\n- Fix link in `maestro bugreport`\n- Fix cancellation of flows whilst repeat loops are running\n- Fix enumeration of multi-select elements in Web\n- Fix use of hierarchy and screenshot strategies across Android and iOS\n- Fix web tests running into Chrome's password leak detection\n- Fix webview detection and interaction on iOS 26\n- Fix broken relative paths when uploading files via multipart form in `http.post`\n\nSpecial thanks to the Maestro community for contributing to this release! Shout out to @tokou, @kprakash2, @trongrg, @vibin, @ryuuhei0729, @Thomvis, @MarcellDr and @leovarmak ❤️\n\n## 2.0.10\n\n- Fix error messaging when running with shards fails\n- Improve gathering of dependencies when running single flows with `maestro cloud`\n\n## 2.0.7\n\n### Fix\n- Fixed bug affecting CI and pull request integrations where org prompts would fail in non-interactive environments.\n\n## 2.0.6\n\n### Features\n- Added support for negative index in element selector\n- Made specifying `--project_id` for cloud upload optional. **In case it is not specified and there are multiple projects, a prompt for selecting the project will be provided.**\n- In case the user belongs to multiple organizations and hasn't specified `--api-key` during cloud upload, a prompt for **selecting the organization** will be provided.\n\n### Fixes\n- Added descriptions to missing element selector aspects (enabled, disabled, selected, not selected, focused, not focused)\n\n## 2.0.5\n\n### Fixes\n- Removed debugging logs\n\n## 2.0.4\n\n### Features\n- Added support for tapping at specific coordinates relative to an element using the `relativePoint` parameter in `tapOn` and `doubleTapOn` commands [Github Issue](https://github.com/mobile-dev-inc/Maestro/issues/2059)\n- Labels in commands can now be dynamically evaluated using JavaScript expressions (thanks @jerriais!)\n\n### Fixes\n- Fixed issue where `maestro login` would fail if user was already logged in \n- Fixed iOS permission setting when using 'all' with specific permission overrides\n- Fixed issue where platform argument would be ignored\n- Fixed issue where blank platform argument would incorrectly filter out all tests\n- Fixed off-by-one error when specifying count with `eraseText` command on Android\n- Improved performance by evaluating script conditions eagerly, ahead of visibility conditions (thanks @tokou!)\n- Fixed crash when running Maestro with empty arguments\n- Updated iOS test runner to support Xcode 26\n- Improved logging on Android driver timeouts\n- Improved copy/paste experience in legacy Maestro Studio (thanks @tylerqr!)\n\n\n## 2.0.3\nFixes:\n- Fix filter logic that was causing incorrect element selection when using multiple selectors together for some applications\n- GraalJS will now isolate environment variables correctly between different runScript executions\n- Fix incorrect reporting of failures in `HtmlTestSuiteReporter`\n\n## 2.0.2\nFixes:\n- Added Rhino deprecation warning in CLI\n- Fix conditions for checking if web flows exist in workspace\n- Added back Run details to cloud upload logs (regression in Maestro 2.0.0)\n\n## 2.0.1\nFixes:\n- Fix issues with launching CLI on Windows systems\n\n## 2.0.0\nBreaking Change:\n- Updated java version to 17 better performance, security, and modern features. **If you’re still on an older version, update before using 2.0.0.**\n- We’ve switched from Rhino to **GraalJS** as the default JavaScript engine. Expect **faster execution** and **modern JS support** for your scripts. [GraalJS Behaviour Differences](https://docs.maestro.dev/advanced/javascript/graaljs-support#graaljs-behavior-differences)\n- URLs in the `appId` field are no longer supported. Flows must now use the `url` field in the YAML config for URLs.\n\nFeatures:\n- Added `setOrientation` command — adjust device orientation in tests (`PORTRAIT`, `UPSIDE_DOWN`, `LANDSCAPE_LEFT`, `LANDSCAPE_RIGHT`). ([Docs](https://docs.maestro.dev/api-reference/commands/setorientation))\n- Enhanced MCP Integration:\n  - More accurate flow path resolution\n  - View hierarchy output size reduced by **50%** (faster & lighter)\n  - `run_flow` / `run_flow_files` now support env variables & hooks\n- Added  `--test-output-dir`  to specify where test artifacts should be saved. ([Docs](https://docs.maestro.dev/cli/test-output-directory))\n- Added support for running entire workspace of **web flows** in a single `test` command.\n- Allowed Keep-Alive from Server to support for persistent connections.\n- Environment variables are now isolated between peer `runFlow` commands.\n- Added timestamp to JUnit and HTML test report\n- DataFaker is now available in JavaScript to generate random data for use in tests ([Docs](https://docs.maestro.dev/advanced/javascript/generating-random-with-faker.md))\n\nFixes:\n- Fix CLI Cloud upload output\n- Fix broken `maestro studio` command for web version of Studio.\n- Fix **memory leak** for ios test runs that could cause out of memory issues on testing environments.\n- Fix `maestro cloud` command when uploading files that have external dependencias (subflows, scripts and media)\n- Fix disconnect in local iOS test executions when flow contains a large element tree\n\n## 1.41.0\nFix:\n- Resolved an issue where view hierarchy was incorrectly returned on full-screen apps or larger devices (e.g., iPhone Pro models, iOS 18). This affected selector matching for taps and assertions.\n- Maestro now properly handles timeouts from the XCTest framework when the app UI is slow or too large. These are surfaced as actionable exceptions with helpful messages.\n- setLocation now mocks all major location providers (GPS, network, fused). Also ensures proper cleanup when the driver shuts down.\n- Errors when .maestro config file is misinterpreted as a test flow file.\n\nFeatures:\n- Platform configs are now supported via workspace configuration [(Docs)](https://docs.maestro.dev/api-reference/configuration/workspace-configuration#platform-configuration):\n  * `disableAnimations` for both android and iOS.\n  * `snapshotKeyHonorModalViews`: On iOS, includes elements behind modals that are still visible on modal to user but gets missing in hierarchy.\n- Added support for selecting `select` tags dropdown elements in web flows.\n- Debug messages are now attached to Maestro exceptions to help users understand failures faster.\n- Added support for selecting elements using CSS/DOM query\n- Added Maestro MCP server implementation to cli by [[Stevie Clifton](https://github.com/steviec)]\n\nBreaking Change:\n- `retryTapIfNoChange` is now disabled by default. It was causing side effects in some apps. If needed, it can still be manually enabled.\n\n\n## 1.40.3\nFix\n- MissingKotlinParameterException during using maestro commands.\n\n## 1.40.2\nFix\n- Sharding on iOS, throwing FileSystemAlreadyExistsException exception \n\n## 1.40.1\nFix\n- iOS apps going on background while using maestro commands\n\nFeature\n- Flag to skip interactive device selection by picking a --device-index\n\n## 1.40.0\n\nFix:\n- JavaScript fails when running maestro test in continuous mode. Affected Commands: `maestro test`, `maestro record` ([#2311](https://github.com/mobile-dev-inc/Maestro/pull/2311))\n- Ignore notifications in analyse command for CI ([#2306](https://github.com/mobile-dev-inc/Maestro/pull/2306))\n- `config.yaml` not resolving on Windows ([#2327](https://github.com/mobile-dev-inc/Maestro/pull/2327))\n- Fix swipe command failure on iOS after upgrading to Xcode 16.2 [issue #2422](https://github.com/mobile-dev-inc/maestro/issues/2422) ([#2332](https://github.com/mobile-dev-inc/maestro/pull/2332))\n- Fix `app-binary-id` option on maestro cloud upload ([#2361](https://github.com/mobile-dev-inc/Maestro/pull/2361))\n- Ensure commands with missing elements fail as expected in Studio ([#2140](https://github.com/mobile-dev-inc/Maestro/pull/2140))\n- Prevent flows from getting stuck on the cloud by properly setting driver closing state ([#2364](https://github.com/mobile-dev-inc/Maestro/pull/2364))\n- Fix `maestro cloud` & `maestro start-device` on windows ([#2371](https://github.com/mobile-dev-inc/Maestro/pull/2371))\n- Improved `maestro cloud` to only process valid flow `.yaml`/`.yml` files and skip unrelated files like `config.yaml`, preventing parsing errors when uploading folders with mixed content ([#2359](https://github.com/mobile-dev-inc/Maestro/pull/2359))\n- Improved `maestro cloud` to skip validating non-flow files (e.g., .js, README, config.yaml) in folders, preventing parsing errors and upload failures\n- Fix setting up iOS Driver when not on bash environment ([#2412](https://github.com/mobile-dev-inc/Maestro/pull/2412))\n- Speed up view hierarchy generation by reducing SpringBoard queries and avoiding redundant app list calls on iOS. ([#2419](https://github.com/mobile-dev-inc/Maestro/pull/2419))\n\nFeatures:\n- Added support for `androidWebViewHierarchy: devtools` option to build Android WebView hierarchy using Chrome DevTools ([#2350](https://github.com/mobile-dev-inc/Maestro/pull/2350))\n- Added Chrome to available devices for web automation ([#2360](https://github.com/mobile-dev-inc/Maestro/pull/2360))\n- Introduced pre-built mode for setting up iOS driver on simulators without relying on `xcodebuild` ([#2325](https://github.com/mobile-dev-inc/Maestro/pull/2325))\n- Added command-line chat mode to Maestro CLI accessible by `maestro chat --ask=` and `maestro chat` ([#2378](https://github.com/mobile-dev-inc/Maestro/pull/2378))\n- Introduced `maestro check-syntax` command for validating flow syntax ([#2387](https://github.com/mobile-dev-inc/Maestro/pull/2387))\n- Added `--reinstall-driver` flag that reinstalls xctestrunner driver before running the test. Set to false if the driver shouldn't be reinstalled ([#2413](https://github.com/mobile-dev-inc/Maestro/pull/2413))\n- Added `--compact` flag that remove empty values to make the output hierarchy json smaller ([#2413](https://github.com/mobile-dev-inc/Maestro/pull/2413))\n- Added `--device-os` and `--device-model` options to target specific iOS minor versions and devices ([Docs](https://docs.maestro.dev/cloud/reference/configuring-os-version#using-a-specific-ios-minor-version-and-device-recommended)) ([#2413](https://github.com/mobile-dev-inc/Maestro/pull/2413))\n- Added support for ios 18 on cloud and local\n- Bumped default iOS version to 16 for `maestro start-device`\n- Enabled AI command usage on `mobile.dev` ([#2425](https://github.com/mobile-dev-inc/Maestro/pull/2425))\n\nChore: \n- Update Flying Fox HTTP server on iOS driver ([#2415](https://github.com/mobile-dev-inc/Maestro/pull/2415))\n- Migrated app termination from `simctl` to `xctest` for improved stability` ([#2418](https://github.com/mobile-dev-inc/Maestro/pull/2418))\n\n## 1.39.13\n\n- Fix : Adding upload route back again\n- Feature: Removing Analyze logs from CI uploads\n\n## 1.39.12\n\n- Fix: Upload route on Robin was not working on maestro cloud command\n\n## 1.39.11\n\n- Feature: Starting trial from CLI\n- Feature: Better logs to improve visibility\n- Feature: Prebuilt iOS driver without xcodebuild\n- Feature: Analyze option to test command\n\n## 1.39.10\n\n- Update install script to tidy up old installation binaries\n\n## 1.39.9\n\n- Revert: Error in showing keyboard during input and erase commands on iOS\n- Fix: applesimutils affecting granting location permission\n- Fix: Setting host and port from the optional arguments\n- Feature: New `maestro login` command for logging in Robin.\n- Feature: Improved `maestro record` video to scroll and follow the currently executing commands\n- Fix: Enable running Maestro on Windows without WSL\n- Feature: Add console.log messages directly to the maestro log file.\n\n## 1.39.8\n\n- Fix: Debug message not showing up when we execute commands on maestro cli anymore\n\n## 1.39.7\n\n- Feature: Improved web support. \n  - Fix: Maestro can test web pages again (it was broken)\n  - Fix: WebDriver was reporting invalid screen size \n  - Web: support cases where a new tab is opened from the page \n  - Web: screen recording support (via JCodec for now, but we could add ffmpeg later)\n  - Web: fake geolocation support \n  - Studio: better layout for wide aspect-ratio screens (i.e. web pages or tablets)\n- Feature: Introduces extractTextWithAI command\n\n- Fix: Retry should throw exception when max retries reaches\n- Fix: Studio getting unresponsive due to exceptions in streaming device\n\n## 1.39.5\n\nReleased on 2024-12-16\n\n\nFixes:\n- Fix: Failure on how the assertConditionCommand was being handled on Robin([#2171](https://github.com/mobile-dev-inc/maestro/pull/2171))\n\n## 1.39.4\n\nFeatures:\n- Add `waitToSettleTimeoutMs` to other swipe related commands ([#2153](https://github.com/mobile-dev-inc/maestro/pull/2153))\n- Add retry command for flaky conditions ([#2168](https://github.com/mobile-dev-inc/maestro/pull/2168))\n- Add support for recording maestro flows locally instead of using remote servers ([#2173](https://github.com/mobile-dev-inc/maestro/pull/2173))\n\nFixes:\n- Fix: multiple xcodebuild process and leading to IOSDriverTimeoutException ([#2097](https://github.com/mobile-dev-inc/maestro/pull/2097))\n- Fix: NullPointerException during view hierarchy operations for android ([#2172](https://github.com/mobile-dev-inc/maestro/pull/2172))\n- Fix: Debug level logs in maestro.log file leading to large debug files ([#2170](https://github.com/mobile-dev-inc/maestro/pull/2170))\n- Fix: Environment variable not being set for test suite ([#2163](https://github.com/mobile-dev-inc/maestro/pull/2163))\n- Fix: Failures on clearKeychain operations on iOS due to missing directories ([#2178](https://github.com/mobile-dev-inc/maestro/pull/2178))\n\n## 1.39.2\n\nReleased on 2024-11-19\n\nFixes:\n- Fix: Insights object causing ConcurrentModificationException ([#2131](https://github.com/mobile-dev-inc/maestro/pull/2131))\n- Fix: Timeout unit in scrollUntilVisible command ([#2112](https://github.com/mobile-dev-inc/maestro/pull/2112))\n- Feat: Add new status for robin flows: PREPARING and INSTALLING. ([#2145](https://github.com/mobile-dev-inc/maestro/pull/2145))\n\n## 1.39.1\n\nReleased on 2024-11-04\n\nFixes:\n- Fix: clearState now automatically reinstall the App ([#2118](https://github.com/mobile-dev-inc/maestro/pull/2118))\n\n## 1.39.0\n\nReleased on 2024-10-15\n\nFeatures:\n- Feature: add `--shard-split` and `--shard-all` options to `maestro test` ([#1955](https://github.com/mobile-dev-inc/maestro/pull/1955) by [Tarek Belkahia](https://github.com/tokou))\n\n  The `--shard` is now deprecated and superseded by `--shard-split`.\n\n- Feature: allow for passing multiple flow files to `maestro test` ([#1995](https://github.com/mobile-dev-inc/maestro/pull/1995) by [Tarek Belkahia](https://github.com/tokou))\n- Feature: add the `optional` argument to all commands ([#1946](https://github.com/mobile-dev-inc/maestro/pull/1946) by [Tarek Belkahia](https://github.com/tokou))\n\n  This new command-level `optional` argument supersedes the (now removed) selector-level `optional` argument. No behavior changes are expected.\n\n  When command with `optional: true` fails, its status is now \"warned ⚠️\" instead of \"skipped ⚪️\"\n\n- Feature: add changelog to the update prompt when new Maestro version is available ([#1950](https://github.com/mobile-dev-inc/maestro/pull/1950) by [Tarek Belkahia](https://github.com/tokou))\n- Feature: add back the `--platform` option ([#1954](https://github.com/mobile-dev-inc/maestro/pull/1954) by [Tarek Belkahia](https://github.com/tokou))\n- Feature: expose current flow name as `MAESTRO_FILENAME` env var ([#1945](https://github.com/mobile-dev-inc/maestro/pull/1945) by [Tarek Belkahia](https://github.com/tokou))\n\nFixes:\n- Fix: Warnings generated by AI-powered commands aren't formatted nicely ([#2043](https://github.com/mobile-dev-inc/maestro/pull/2043)) ([#2044](https://github.com/mobile-dev-inc/maestro/pull/2044))\n- Fix: not working when iOS simulator is in landscape orientation ([caveats apply](https://github.com/mobile-dev-inc/maestro/pull/1974#issuecomment-2346074593)) ([#1974](https://github.com/mobile-dev-inc/maestro/pull/1974))\n- Fix: confusing error message \"BlockingCoroutine is cancelling\" ([#2036](https://github.com/mobile-dev-inc/maestro/pull/2036))\n- Fix: AI-powered commands crashing when Anthropic is used ([#2033](https://github.com/mobile-dev-inc/maestro/pull/2033))\n- Fix: display warnings generated by AI-powered commands in CLI output when `optional: true` ([#2026](https://github.com/mobile-dev-inc/maestro/pull/2026)) \n- Fix: visual bug with emojis having slightly different length in `maestro test`'s interactive CLI output ([#2016](https://github.com/mobile-dev-inc/maestro/pull/2016)) \n- Fix: no tests being run when flowsOrder specified all tests in the workspace ([#2003](https://github.com/mobile-dev-inc/maestro/pull/2003))\n- Fix: using integers from JavaScript outputs causing a deserialization error ([#1788](https://github.com/mobile-dev-inc/maestro/pull/1788) by [Muhammed Furkan Boran](https://github.com/boranfrkn)) \n- Fix: delete temporary APKs after using them ([#1947](https://github.com/mobile-dev-inc/maestro/pull/1947) by [Tarek Belkahia](https://github.com/tokou))\n- Fix: allow env vars in `setLocation` and `travel` commands ([#1988](https://github.com/mobile-dev-inc/maestro/pull/1988) by [Prasanta Biswas](https://github.com/prasanta-biswas))\n- Fix: error message when specifying `--format` together with `--continuous` #1948 ([#1948](https://github.com/mobile-dev-inc/maestro/pull/1948) by [Tarek Belkahia](https://github.com/tokou))\n\nChores:\n- Chore: clean up logging, make log format configurable with 2 new env vars ([#2041](https://github.com/mobile-dev-inc/maestro/pull/2041))\n- Chore: make Maestro build & compile on Java 17 ([#2008](https://github.com/mobile-dev-inc/maestro/pull/2008))\n- Chore: Migrate all Gradle buildscripts to Gradle Kotlin DSL ([#1994](https://github.com/mobile-dev-inc/maestro/pull/1994))\n\n## 1.38.1\n\nReleased on 2024-08-30\n\n- New experimental AI-powered commands for screenshot testing: [assertWithAI](https://maestro.mobile.dev/api-reference/commands/assertwithai) and [assertNoDefectsWithAI](https://maestro.mobile.dev/api-reference/commands/assertnodefectswithai) ([#1906](https://github.com/mobile-dev-inc/maestro/pull/1906))\n- Enable basic support for Maestro uploads while keeping Maestro Cloud functioning ([#1970](https://github.com/mobile-dev-inc/maestro/pull/1970))\n\n## 1.37.9\n\nReleased on 2024-08-15\n\n- Revert iOS landscape mode fix ([#1916](https://github.com/mobile-dev-inc/maestro/pull/1916))\n\n## 1.37.8\n\nReleased on 2024-08-14\n\n- Fix sharding on Android failing on all but one devices (quick hotfix) ([#1867](https://github.com/mobile-dev-inc/maestro/pull/1867))\n- Fix CLI crash when flow is canceled on Maestro Cloud ([#1912](https://github.com/mobile-dev-inc/maestro/pull/1912))\n- Fix iOS landscape mode ([caveats apply](https://github.com/mobile-dev-inc/maestro/pull/1809#issuecomment-2249917209)) ([#1809](https://github.com/mobile-dev-inc/maestro/pull/1809))\n- Skip search engine selection when running on the web ([#1869](https://github.com/mobile-dev-inc/maestro/pull/1869))\n\n## 1.37.7\n\nReleased on 2024-08-03\n\n- Fix cryptic \"Socket Exception\" when `CI` env var is set, once and for all ([#1882](https://github.com/mobile-dev-inc/maestro/pull/1882))\n\n## 1.37.6\n\nReleased on 2024-08-02\n\n- Print stack trace on 3rd retry ([#1877](https://github.com/mobile-dev-inc/maestro/pull/1877))\n\n## 1.37.5\n\nReleased on 2024-08-02\n\n- Fix cryptic \"SocketException\" when API token is invalid ([#1871](https://github.com/mobile-dev-inc/maestro/pull/1871))\n\n## 1.37.4\n\nReleased on 2024-07-30\n\n- Don't ask for analytics permission on CI + add `MAESTRO_CLI_NO_ANALYTICS` env var ([#1848](https://github.com/mobile-dev-inc/maestro/pull/1848))\n\n## 1.37.3\n\nReleased on 2024-07-29\n\n### Bug fixes\n\n- Fix `FileNotFoundException: ~.maestro/sessions` ([#1843](https://github.com/mobile-dev-inc/maestro/pull/1843)) \n\n## 1.37.2 - 2024-07-29\n\n### Bug fixes\n\n- Fix `UnsupportedOperationException: Empty collection can't be reduced` ([#1840](https://github.com/mobile-dev-inc/maestro/pull/1840))\n\n## 1.37.1 - 2024-07-29\n\n### Bug fixes\n\n- Fix crash when `flutter` or `xcodebuild` is not installed ([#1839](https://github.com/mobile-dev-inc/maestro/pull/1839))\n\n## 1.37.0 - 2024-07-29\n\n### New features\n\n- **Sharding tests for parallel execution on many devices 🎉** ([#1732](https://github.com/mobile-dev-inc/maestro/pull/1732) by [Kaan](https://github.com/sdfgsdfgd))\n\n  You can now pass `--shards` argument to `maestro test` to split up your test suite into chunks that run in parallel. If you have feedback or suggestions about this huge new feature, please share them with us in [issue #1818](https://github.com/mobile-dev-inc/maestro/issues/1818).\n\n- **Reports in HTML** ([#1750](https://github.com/mobile-dev-inc/maestro/pull/1750) by [Depa Panjie Purnama](https://github.com/depapp))\n\n  To see it, run `maestro test --format HTML <your-flow.yaml>`\n\n- **Homebrew is back!**\n\n  If you prefer to switch your installation of Maestro to use Homebrew:\n    1. `rm -rf ~/.maestro`\n    2. `brew tap mobile-dev-inc/tap && brew install maestro` 🎉\n\n    Script install method is still supported.\n\n- **Current platform exposed in JavaScript** ([#1747](https://github.com/mobile-dev-inc/maestro/pull/1747) by [Dan Caseley](https://github.com/Fishbowler))\n\n  In JavaScript, you can now access `maestro.platform` to express logic that depends on whether the test runs on iOS or Android.\n- **Control airplane mode** ([#1672](https://github.com/mobile-dev-inc/maestro/pull/1672) by [NyCodeGHG](https://github.com/NyCodeGHG))\n\n  New commands: `setAirplaneMode` and `toggleAirplaneMode`. Android-only because of iOS simulator restrictions.\n- **New `killApp` command** ([#1727](https://github.com/mobile-dev-inc/maestro/pull/1727) by [Alexandre Favre](https://github.com/alexandrefavre4))\n\n  To trigger a System-Initiated Process Death on Android. On iOS, works the same as `stopApp`.\n\n### Bug fixes\n\n- Fix cleaning up retries in iOS driver ([#1669](https://github.com/mobile-dev-inc/maestro/pull/1669))\n- Fix some commands not respecting custom labels ([#1762](https://github.com/mobile-dev-inc/maestro/pull/1762) by [Dan Caseley](https://github.com/Fishbowler))\n- Fix “Protocol family unavailable” when rerunning iOS tests ([#1671](https://github.com/mobile-dev-inc/maestro/pull/1671) by [Stanisław Chmiela](https://github.com/sjchmiela))\n\n## 1.36.0 - 2024-02-15\n\n- Feature: Add support for extra keys to Android TV\n- Feature: Add support for pressing tab key on Android\n- Feature: Add status and time to report.xml\n- Fix: Extend retry to handle 404 in upload status call\n- Fix: Crashes caused by toasts on Android API < 30\n\n## 1.35.0 - 2024-01-08\n\n- Change: Adds view class to Android hierarchy output\n- Change: Improves description of maestro start-device command to include device locale as well\n- Change: Adds scrollable attribute to Android view hierarchy output\n- Feature: Adds childOf attribute to selector to select from children of a container\n- Feature: Adds label attribute to customize the CLI output of maestro commands\n- Fix: Fixing “Unsupported architecture UNKNOWN” on linux environment when calling maestro attempts to create devices\n- Fix: Allow maestro to work below API level 25 for Android\n- Fix: IllegalArgumentException on swipe operation for iOS if the coordinates beyond device width and height are selected\n\n## 1.34.5 - 2024-01-04\n\n- Feature: Adds a parameter to exclude all the keyboard elements from hierarchy\n\n## 1.34.4 - 2023-12-27\n\n- Fix: Failures due to swipe ranges going beyond screen dimensions\n- Change: Adding escape key in `pressKey` API\n- Tweak: Avoid returning `Result` in IOSDriver install and clearAppState\n\n## 1.34.3 - 2023-11-21\n\n- Tweak: Include scrollable attribute in view hierarchy from Android Driver\n- Feature: Custom labels for readability of maestro commands\n- Feature: Adding childOf selector\n- Tweak: Message of start-device command to show locale as well\n\n## 1.34.2 - 2023-11-13\n\n- Tweak: Include view class in view hierarchy attributes from the Android driver\n\n## 1.34.1 - 2023-11-9\n\n- Feature: add support `--device-locale` parameter for `maestro cloud` command\n- Feature: add support iOS17 for `maestro start-device` command\n- Feature: add support Android API level 34 for `maestro start-device` command\n\n## 1.34.0 - 2023-10-24\n\n- Feature: support `--device-locale` parameter for `maestro start-device`\n- Feature: add `centerElement` parameter for `scrollUntilVisible`. Center element will attempt to stop scrolling when the element is near the center of the screen.\n- Feature: add `power` button support for `pressKey` on Android\n- Change: add `tapOn` parameter `waitToSettleTimeoutMs` to control how long it waits to move on to the next command. Helpful for animation heavy apps.\n- Change: improve executionOrder planning\n- Change: improve retry mechanism to ensure openness of XCUITest Server\n- Fix: improve `TimeoutException` for driver startup\n\n## 1.33.1 - 2023-10-03\n\n- Feature: support for multipart form data file upload in Javascript, thanks @maciejkrolik\n- Fix: setPermissions produces error on Xcode 15\n- Fix: Maestro studio - include enter key in command editor on initial paste\n\n## 1.33.0 - 2023-09-21\n\n- Feature: Adds MAESTRO_DRIVER_STARTUP_TIMEOUT to iOS driver to configure timeout to start iOS driver, used in CI/CD environment with performance limitations. Thanks, Jesse Farsong for contributing.\n- Feature: Introducing the \"addMedia\" command that enables adding images and videos directly to the devices.\n- Change: Improved Studio's user interface:\n  - Updated fonts to align with company branding.\n  - Introduced a distinct loading animation for better clarity when AI is processing commands.\n- Fix: Crash resulting in Error: No matches found for first query match sequence: `Children matching type Other` due to resolving root element for a snapshot operation on iOS\n- Fix: Android driver getting stuck when the device was disconnected\n- Fix: XCTestUnreachable exceptions due to missing IPv6 config on /etc/hosts\n- Fix: Handling app crash errors from XCUITest drivers gracefully\n- Fix: Timeouts can be separated with `_`. For example 10_000 for 10000\n\n## 1.32.0 - 2023-09-06\n\nStudio\n\n- Feature: Support writing Flows using AI (more info to come 🚀)\n- Feature: Maestro Studio can now run in multiple tabs simultaneously\n- Feature: Added element id and copy option for it\n- Tweak: Hide action buttons till command is hovered\n- Tweak: Hide Unnecessary Scrollbars\n- Tweak: Repl view scroll improvements\n- Tweak: Improve Maestro Studio performance\n- Fix: Selected element size\n- Fix: Performance issues with maestro studio device refresh\n- Fix: Fixed dark mode for element id\n\nCLI\n\n- Feature: New command to start or create a Maestro recommended device (docs)\n- Feature: Support id selection for testID with react-native-web (community contribution)\n- Feature: Control if browser automatically opens when running Maestro Studio via --no-window (community contribution)\n- Tweak: Show cancellation reason when available (Maestro Cloud)\n- Tweak: Update selenium-java and remove webdrivermanager to support Chrome 116+\n- Tweak: Show device type when running on Maestro Cloud\n- Tweak: Added better messaging and recovery options for Maestro Cloud uploads (useful for CI)\n- Tweak: Added better error messages for missing workspace and yaml validation errors\n- Tweak: Added file name and line number in yaml parsing error messages\n- Fix: Input text and erase text stability improvements for iOS\n- Fix: Leaking response body on iOS & better error handling for iOS Driver\n- Fix: Fixed Maestro Cloud wrong exit code when flow failed\n- Fix: Debug commands parsing would crash maestro\n- Fix: Cleaning up debug logs\n\n## 1.31.0 - 2023-08-10\n\n- Fix: Warning shown from OkHttp for leaking response bodies on CLI\n  - Closing response bodies for retries done on the XCUITest driver\n  - Closing response bodies for permissions\n  - Removing different thread execution done on hideKeyboard\n- Fix: Scroll for React native apps on screens with large view hierarchies on iOS\n- Fix: Showing more descriptive errors on flow file not found during maestro cloud command.\n- Fix: Input text characters being skipped or being appended later in the test on iOS\n- Fix: Crash in debug output generation when maestro flow contains \"/\"’\n- Fix: Resolved issue where tapping on the device in maestro studio produced inaccurate click locations due to incorrect coordinates. Now fixed for accurate device interaction\n- Fix: In Maestro Studio, the issue of window resizing causing devices to overflow off the screen has been resolved.\n- Feature: Add headers to HTTP response for API calls done with Maestro. Thanks, Jesse Willoughby! for this contribution.\n- Feature: Now it is possible to configure the path with the –debug-output option for debugging information that maestro dumps in the user directory by default.\n- Feature: Enhanced Maestro Studio with keyboard accessibility, streamlining navigation and facilitating the copy, run, and edit commands using the keyboard.\n- Change: Fail the test if any of the onFlowStart or onFlowComplete hooks fail\n- Change: Removed IDB on iOS. This may impact the performance of maestro commands like tapOn and assertVisible on iOS screens with large view hierarchies.\n  - Studio and CLI will now provide insights and warnings in case the hierarchy of these screens becomes extensive.\n- Change: In Maestro Studio, we've integrated screenshots of selected elements alongside their corresponding commands.\n- Change: In Maestro Studio, double-clicking will now execute the command.\n\n## 1.30.4 - 2023-07-19\n\n- Fix: correctly resolve external parameters for onStart/Complete hooks\n- Fix: reuse JSEngine for all executeCommands (hooks, main commands, subflows) actions\n\n## 1.30.3 - 2023-07-17\n\n- Update: Maestro Studio revamp improvements\n  - wrapped element names in sidebar\n  - sidebar text always visible\n  - add \"hintText\" and \"accessibilityText\" in sidebar\n  - improve sidebar search\n  - fixed highlight issues in search\n  - various other small improvements\n\n## 1.30.2 - 2023-07-14\n\n- Revert connection improvements (from 1.30.1)\n\n## 1.30.1 - 2023-07-14\n\n- Fix: Allow running `maestro studio` and `maestro test` simultaneously\n- Fix: Connection improvements\n\n## 1.30.0 - 2023-07-13\n\n- Feature: onFlowStart / onFlowComplete hooks\n- Feature: Maestro Studio revamp\n  - improved design\n  - search components panel\n  - improved drag-and-drop\n- Feature: Introduce `--app-binary-id` parameter for Maestro Cloud upload action to be able to re-use a previously uploaded app for different flows\n- Feature: Implement Experimental GraalJsEngine (ECMAScript 2022 compliant)\n- Fix: Save xctest xcodebuild logs output to system temp dir\n- Fix: Close existing screen recording if it was left open.\n  - Thanks, @carlosmuvi, for the contribution!\n- Fix: Execute sequential Flows even if no other Flows are present\n- Fix: Various XCTestClient connection improvements\n- Deprecate: `assertOutgoingRequestsCommand`\n- Deprecate: Network Mocking feature\n- Deprecate: Maestro Mock Server feature\n\n## 1.29.0 - 2023-06-19\n\n- Feature: Add test duration measurement and display\n- Feature: New screen recording commands\n  - Thanks, @tokou, for the contribution!\n- Feature: Add support for sequential execution\n- Feature: Add support for double taps + multiple taps in tapOn\n- Feature: Add support for custom Android driver startup timeout\n  - Thanks, @arildojr7, for the contribution!\n- Fix: Validate workspace prior to upload to Maestro Cloud\n- Fix: Resolve Android scrollUntilVisible flakiness\n- Fix: Resolve inputText flakiness\n- Fix: iOS url arguments\n  - Thanks, @tokou, for the contribution!\n\n## 1.28.0 - 2023-05-18\n\n- Feature: runScript command now support conditional execution\n- Feature: Improved debug output:\n  - Shows failure reason when command fails\n  - Generates screenshot when command fails\n  - Unified most logs under ~/.maestro/tests/<date>/maestro.log\n- Change: Launch arguments support for long values\n- Tweak: JUnit report naming changes. Local and Cloud should now have the same naming convention.\n- Tweak: Added deprecation notice for experimental features\n- Fix: maestro record command was not working on iOS\n- Fix: WebDriver, only scroll to elements outside of the window before tapping\n- Fix: close request leaking body\n- Fix: maestro cloud now will fail on timeout if configured as such\n\n## 1.27.0 - 2023-05-02\n\n- Feature: Adds assertOutgoingRequests to assert the network requests from the app\n- Feature: Add platform condition in runFlow command to do platform-specific orchestration. Thanks, Larry Ng for your contribution!\n- Feature: Adds a new selector containsDescendants. Thanks, Larry Ng for your contribution!\n- Feature: iOS and Android launch arguments\n- Change: Include the update command instead of update instructions in the update message. Thanks @bobpozun for your contribution!\n- Fix: Fixes swipe flakiness caused due to waiting for animations to complete on XCTest\n- Fix: Correctly resolving `maestro.copiedText`\n- Fix: Using deviceId instead of booted, potentially resolving XCTestUnreachable exceptions.\n- Fix: Improving waitForAppToSettle for Android by accounting window updates. Resolves maestro command interaction in Android 13.\n- Fix: Notification permissions not getting granted\n- Fix: Use correct documentation URLs in Studio\n\n## 1.26.1 - 2023-04-13\n\n- Fix: hideKeyboard crashing on react native apps because swipe fails on some screens\n\n## 1.26.0 - 2023-04-13\n\n- Feature: Adds Travel command to mock motion for app\n- Feature: Adds a capability to match the toast messages\n- Feature: Add support for console.log in javascript\n- Feature: Allow writing inline flows with runFlow command\n- Change: Adds sms permission to permission names which can be used to allow/deny: android.permission.READ_SMS, android.permission.RECIEVE_SMS, android.permission.SEND_SMS. Thanks, @depapp for the contribution.\n- Change: Maestro can now also match hint text and values of text field.\n- Change: Maestro can now also match elements with their accessibility text.\n- Commands moved away from IDB:\n  - Long press is now done with XCTest instead of idb\n  - Installation of app is now done with simctl commands\n  - Hide keyboard with help of XCTest. We now scroll up and down from the middle of the screen to close the keyboard.\n  - Press key now is done with XCTest.\n  - Note that with this change pressKey: Enter now only wraps on new line - earlier it also closed the keyboard\n  - Erase text is now done with XCTest.\n  - Use simctl to record screen\n- Fix: Web driver no longer crashes when using latest Chrome\n- Fix: Fixes hideKeyboard on android by appropriately dispatching proper event. Thanks, @nhaarman for contribution\n- Fix: Properly shutting down studio by listening to SIGTSP signal\n- Fix: Update granting of notifications and health permissions causing simulator restarts and XCTestUnreachableExceptions.\n\n## 1.25.0 - 2023-03-13\n\n- Fix: Shell environment variables can no longer crash the javascript runtime\n- Fix: XCTestRunner and IDB are restarted on connection error\n- Feature: Add support for setLocation\n\n## 1.24.0 - 2023-03-07\n\n- Change: LaunchApp command sets all app permissions to allow ([documentation](https://maestro.mobile.dev/reference/app-lifecycle))\n- Feature: LaunchApp supports specifying app permission state\n- Feature: On Android it is now possible to force links to be opened in the browser\n- Fix: Autocorrect is no longer applied to inputText on iOS\n- Fix: iOS apps with big view hierarchies (common with ReactNative and Flutter) caused an error in XCTest.framework\n- Fix: Studio UI fixes for Firefox and Safari\n- Fix: Element selection behavior in Maestro Studio\n\n## 1.23.0 - 2023-02-15\n\n- Feature: Maestro Studio - Action Modal\n- Feature: Maestro Studio - Dark Mode\n- Feature: assertion on `enabled`, `selected`, `checked`, `focused` properties ([documentation](https://maestro.mobile.dev/reference/assertions#assertvisible))\n- Feature: running tests in a deterministic order ([documentation](https://maestro.mobile.dev/cli/test-suites-and-reports#deterministic-ordering))\n- Feature: default global tags can now be set in `config.yaml` ([documentation](https://maestro.mobile.dev/cli/tags#global-tags))\n- Feature: allow to configure what flows should be included into a run at `config.yaml` level ([documentation](https://maestro.mobile.dev/cli/test-suites-and-reports#controlling-what-tests-to-include))\n- Tweak: considerable speed-up of iOS tests due to removal of unnecessary hierarchy polling\n- Tweak: wait for app to settle before proceeding with iOS test\n- Tweak: UX improvements in \"delete command\" confirmation dialog\n- Tweak: using `xcrun` for uninstall command on iOS\n- Tweak: using `xcrun` for clearKeychain command on iOS\n- Tweak: using `.maestro` directory by default for mockserver deploy command\n- Fix: errors were clipped in Maestro Studio\n- Fix: use element title as id in Web driver\n- Fix: Repeat-while-true did not work properly with JavaScript conditions\n- Fix: Repeat-times did not work properly with JavaScript input\n- Fix: added artificial delay after key presses (i.e. \"back\" key) on Android\n\n## 1.22.1 - 2023-02-09\n\n- Early Access Feature: Maestro Mock Server and Maestro SDK (Android preview)\n- Tweak: added visibility threshold and scroll speed to `scrollUntilVisible` command\n- Tweak: speed up `tapOn` command on iOS\n- Fix: removing view hierarchy elements that are out of screen bounds\n- Fix: `inputText` command skipping characters on iOS\n- Fix: Reworked `clearAppState` behaviour on iOS, solving issue that caused crashes after clearing the state\n- Fix: crash when running multiple Maestro sessions in parallel while using iOS device\n- Fix: a rare crash in React Native apps when trying to input a long string on iOS\n- Fix: properly handling linebreaks in Maestro Studio\n\n## 1.21.3 - 2023-01-30\n\n- Fix: `scrollUntilVisible` was not always working on iOS\n- Tweak: speed up tests by skipping an unnecessary hierarchy poll\n- Tweak: iOS screenshot no longer depends on IDB and is faster\n\n## 1.21.2 - 2023-01-26\n\n- Hotfix: Move iOS tap() implementation back to IDB to resolve problems with React Native apps\n- Fix: running multiple Maestro instances would sometimes result in Connection exception\n- Fix: support JS injection in `scrollUntilVisible` command\n\n## 1.21.1 - 2023-01-25\n\n- Fix: Increase typing speed for iOS text input\n\n## 1.21.0 - 2023-01-25\n\n- Feature: Next evolution of Maestro Studio\n- Fix: More robust implementation of inputText on iOS\n- Fix: More robust implementation of tap on iOS\n- Experimental: Added web driver\n\n## 1.20.0 - 2023-01-24\n\n- Feature: Maestro Studio - use percentage-based swiping\n- Feature: Scroll until view element is visible\n- Feature: Relatively swipe with percentage based start and end coordinates\n- Fix: Android tap was not always working\n- Fix: Bottom of Android hierarchy was cut off\n- Fix: idb_companion fails to start due to gRPC timeout exception\n- Tweak: Improve Android Screenshot Internal Logic\n- Tweak: Change the end coordinates for swipe element\n- Tweak: Update sample flows\n\n## 1.19.5 - 2023-01-19\n\n- Fix: inputText was not working on iOS React Native apps\n- Fix: Maestro fails to launch on iOS if --device parameter is present\n- Fix: Evaluate JS scripts with element selector in swipe command\n- Tweak: added tags to sample flows\n- Tweak: indicating whether build is running on CI in analytics\n\n## 1.19.2 - 2023-01-17\n\n- Hotfix: Maestro Studio was not working\n\n## 1.19.1 - 2023-01-17\n\n- Feature: generating test report from `maestro cloud` output\n- Fix: in rare cases, maestro cloud was computing progress bar as negative value\n- Fix: local test suite included non-flow files\n- Fix: some special characters were not allowed in env variables (i.e. `&`)\n- Fix: vertical scrolling was sometimes not working on iOS\n- Fix: if a text string is an invalid regex, treat it as a regular string instead\n- Fix: scroll and swipe commands on iOS were throwing an error when running in parallel with Maestro Studio\n- Tweak: print out valid inputs for `--format` parameter in `maestro test` and `maestro upload`\n- Tweak: removed Maestro Studio warning related to parallel execution\n- Refactor: making XCTestDriver configurable\n\n## 1.19.0 - 2023-01-13\n\n- Feature: iOS unicode input support + non-English keyboards\n- Feature: `swipe` command now supports `from` argument to swipe from a given view\n- Feature: `repeat` command now supports `while` condition\n- Feature: Allowing `extendedWaitUntil` command to use env values in `timeout` property\n- Tweak: assert commands now respect `optional` flag\n- Tweak: error analytics\n- Fix: scroll not working reliably on iOS\n- Fix: `openLink` was opening Google Maps on Android\n- Fix: sub-flows are now included regardless of their tags\n- Fix: Maestro Studio was not always computing `index` field correctly\n- Fix: `maestro upload` was ignoring JS files\n- Fix: `openLink` command now supports query parameters\n\n## 1.18.5 - 2023-01-10\n\n- Feature: tags\n- Tweak: allow running other maestro commands alongside Maestro Studio\n- Tweak: improved matching for strings with linebreaks\n- Fix: creating maestro logs directory was not always working properly\n- Fix: maestro studio was not working properly on Kubuntu\n\n## 1.18.3 - 2022-12-27\n\n- XCUITest driver improvements and fixes:\n  - Close the response when validating server up\n  - Add logs to uninstall of runner\n  - Remove redundant import and library from maestro-ios\n  - Kills the process before we uninstall it\n  - Redirect runner logs in xctest_runner_logs directory\n\n## 1.18.2 - 2022-12-27\n\n- Fix: Wait for XCUITest server to start before proceeding\n\n## 1.18.1 - 2022-12-27\n\n- Fix: Create XCUITest driver HTTP server on loopback address\n- Fix: Create parity with idb for `text` attribute with following priority:\n  - Title\n  - Label\n  - Value\n\n## 1.18.0 - 2022-12-26\n\n- Feature: Adds new XcUITest driver to capture view hierarchy on iOS.\n  - Fixes stability issues on iOS 16\n  - Fixes not identified bottom navigation tabs\n  - Gets view hierarchy natively from XCUITest\n- Fix: Missing letter j and y in inputRandomText command\n- Tweak: Un-deprecate the hierarchy command, inform about Studio\n- Tweak: Match negative bounds as well in maestro studio\n- Feature: Adds replay functionality in maestro studio\n- Feature: Adding device interaction to interact page in Maestro Studio\n\n## 1.17.4 - 2022-12-15\n\n- Fix: Maestro commands were failing if Android SDK wasn't installed\n\n## 1.17.3 - 2022-12-15\n\n- Feature: no-ansi version for terminals that do not ANSI\n- Feature: Android Maven artifact for setting up network mocking\n- Fix: Android emulator was not discovered properly if it wasn't on PATH\n- Fix: missing favicon\n\n## 1.17.2 - 2022-12-13\n\n- Tweak: Deprecate hierarchy and query CLI commands\n\n## 1.17.1 - 2022-12-12\n\n- Tweak: Remove Maestro Studio icon from Mac dock\n- Tweak: Prefer port 9999 for Maestro Studio app\n- Fix: Fix Maestro Studio conditional code snippet\n\n## 1.17.0 - 2022-12-12\n\n- Feature: Maestro Studio\n- Feature: Print a message when an update is available\n- Feature: Support percentages for tapOn\n- Fix: Maestro commands execute faster now\n- Fix: Fix environment variable substitution in certain cases\n- Fix: Use actual android device screen size (including nav bar)\n\n## 1.16.4 - 2022-12-02\n\n- Fix: Add error message for when an Android screen recording fails\n\n## 1.16.3 - 2022-12-02\n\n- Fix: Fix iOS `clearState` not working in certain cases\n- Fix: Fix `maestro record` not capturing full launch screen recording\n\n## 1.16.2 - 2022-12-02\n\n- Fix: older version of Maestro Driver on Android was not always updated\n\n## 1.16.1 - 2022-11-30\n\n- Feature: `maestro record` command\n- Fix: `z` character was not inputted correctly on Android\n\n## 1.16.0 - 2022-11-29\n\n- Feature: Javascript injection support\n  - `runScript` and `evalScript` commands to run scripts\n  - `assertTrue` command to assert based on Javascript\n  - `runFlow` can be launched based on Javascript condition\n  - `copyTextFrom` now also stores result in `maestro.copiedText` variable\n  - Env parameters are now treated as Javascript variables\n- Feature: HTTP(s) requests\n  - `http.request()` Javascript API that allows to make HTTP requests as part of Maestro flows\n- Feature: Maestro Cloud `--android-api-level` parameter to select API version to be used\n- Feature: `waitForAnimationToEnd` command to wait until animations/videos are finished\n- Tweak: test reports can now be generated for single test runs (and not just folders)\n- Tweak: `inputText` on Android was reworked to increase speed and input stability\n- Tweak: `eraseText` is now much faster\n- Tweak: `maestro cloud` will automatically retry upload up to 3 times\n- Fix: running on Samsung devices was sometimes failing because of wrong user being used\n\n## 1.15.0 - 2022-11-17\n\n- Feature: run all tests in a folder as a suite\n- Feature: XML test report in JUnit-compatible format\n- Feature: `copyTextFrom` command for copying text from a view\n- Feature: `maestro bugreport` command for capturing Maestro logs\n- **Breaking change**: Removed `clipboardPaste` command in favour of new `pasteText` command\n- Fix: Java 8 compatibility issue for M1 users\n- Fix: `_` character was mapped incorrectly on iOS\n- Fix: first `tapOn` command was failing unless it was preceded by `launchApp` or `openLink`\n- Tweak: Maestro no longer kills running `idb_companion` processes\n- Tweak: updated gRPC version to 1.52.0\n\n## 1.14.0 - 2022-11-14\n\n- Fix: passing env parameters to subflows and other env params\n- Speeding up maestro flows\n- Checking in maestro sample flows and adds sample updating guide\n- Maestro is now compatible with java 8!\n- Launching app without stopping the app\n- Fixing launching app when resolving launcher activity throws `NullPointerException`\n\n## 1.13.2 - 2022-11-10\n\n- Fix: Fallback properly on monkey when start-activity command fails, when launching app.\n\n## 1.13.1 - 2022-11-09\n\n- Fix: Fix maestro hanging with message \"Waiting for idb service to start..\"\n- Fix: Fix clearState operation not working on iOS\n\n## 1.13.0 - 2022-11-08\n\n- Feature: Option to set direction and speed for swipe command\n- Fix: Fix duplicate and unavailable iOS simulators in list\n- Fix: Longer timeout for iOS simulator boot\n\n## 1.12.0 - 2022-11-06\n\n- Feature: `maestro cloud` command added\n\n## 1.11.4 - 2022-11-02\n\n- Fix: Use absolute path to prevent NullPointerException when .app folder is in the cwd\n- Fix: Create parent directory if not exists when generating adb key pair, updates dadb to 1.2.6\n- Fix: Opening of leak canary app\n- Tweak: send agent: ci when known CI environment variables are set\n\n## 1.11.3 - 2022-10-29\n\n- Fix: updating to dadb 1.2.4\n\n## 1.11.2 - 2022-10-29\n\n- Fix: updating to dadb 1.2.3 to fix an occasional device connection issue\n- Fix: injecting `env` parameters into conditions (i.e. in `runFlow`)\n\n## 1.11.1 - 2022-10-27\n\n- Fix: closing `idb_companion` after `maestro` completes\n\n## 1.11.0 - 2022-10-26\n\n- Feature: `maestro` will offer user to select a device if one is not running already\n- Feature: `env` variables can be inlined in flow file or in `runFlow` command\n- **Breaking change**: `--platform` option is deprecated. CLI now prompts user to pick a device.\n- Tweak: auto-starting `idb_companion`. No need to start it manually anymore.\n- Tweak: tripled Android Driver launch timeout\n- Tweak: customisable error resolution in Orchestra\n- Fix: `maestro upload` was not ignoring `-e` parameters\n\n## 1.10.1 - 2022-10-12\n\n- Fix: login command fails with java.lang.IllegalStateException: closed\n\n## 1.10.0 - 2022-10-12\n\n- Feature: `repeat` command that allows to create loops\n- Feature: conditional `runFlow` execution that allows to create if-conditions\n- Feature: `inputRandomText`, `inputRandomNumber`, `inputRandomEmail` and `inputRandomPersonName` commands (thanks @ttpho !)\n- Feature: `clipboardPaste` command (thanks @depapp !)\n- Feature: Added `enabled` property to element selector\n- Feature: Added `download-samples` command to allow quickstart without having to build your own app\n- Feature: Added `login` and `logout` commands for interacting with mobile.dev\n- **Breaking change:** `upload` now takes 1 less argument. `uploadName` parameter was replaced with `--name` optional argument\n- Tweak: `upload` command automatically zips iOS apps\n- Tweak: sending `agent: cli` value alongside `upload` and `login` commands\n- Fix: properly compare fields that contain regex special symbols\n- Fix: input text on Android was sometimes missing characters\n\n## 1.9.0 - 2022-09-30\n\n- Feature: USB support for Android devices\n\n## 1.8.3 - 2022-09-28\n\n- Fix: occasional crash when an iOS layout has a view group with a 0 width\n- Fix: properly mapping top-level syntax errors\n\n## 1.8.2 - 2022-09-27\n\n- Tweak: prioritise clickable elements over non-clickable ones\n- Fix: close TCP forwarder if it is already in use\n- Fix: hideKeyboard on Android did not always work\n\n## 1.8.1 - 2022-09-27\n\n- Fix: Timeout exception while opening port for tcp forwarding\n\n## 1.8.0 - 2022-09-22\n\n- Feature: `runFlow` command\n- Tweak: support of Tab Bar on iOS\n- Tweak: added `--mapping` option to `upload` CLI command\n- Fix: open the main launcher screen on Android instead of Leak Canary\n- Fix: input character-by-character on Android to counter adb issue where not the whole text gets transferred to the device\n\n## 1.7.2 - 2022-09-20\n\n- Fix: `tapOn` command was failing due to a failure during screenshot capture\n\n## 1.7.1 - 2022-09-19\n\n- Feature: `clearState` command\n- Feature: `clearKeychain` command\n- Feature: `stopApp` command\n- Tweak: Maestro now compares screenshots to decide whether screen has been updated\n- Tweak: `launchApp` command now supports env parameters\n\n## 1.7.0 - 2022-09-16\n\n- Feature: `maestro upload` command for uploading your builds to mobile.dev\n- Feature: `takeScreenshot` command\n- Feature: `extendedWaitUntil` command\n- Fix: waiting for Android gRPC server to properly start before interacting with it\n- Fix: brought back multi-window support on Android\n- Fix: `hideKeyboard` command did not always work\n- Fix: make project buildable on Java 14\n- Refactoring: make `MaestroCommand` serializable without custom adapters\n- Refactoring: migrated to JUnit 5\n\n## 1.6.0 - 2022-09-13\n\n- Feature: hideKeyboard command\n- Feature: add Android TV Remote navigation\n- Tweak: allowing to skip package name when searching by `id`\n- Fix: Android WebView contents were sometimes not reported as part of the view hierarchy\n- Fix: iOS inputText race condition\n- Fix: populate iOS accessibility value\n- Refactoring: simplified `MaestroCommand` serialization\n\n## 1.5.0 - 2022-09-08\n\n- Temporary fix: showing an error when unicode characters are passed to `inputText`\n- Feature: `eraseText` command\n\n## 1.4.2 - 2022-09-06\n\n- Fix: Android devices were not discoverable in some cases\n\n## 1.4.1 - 2022-09-05\n\n- Fix: relative position selectors (i.e. `below`) were sometimes picking a wrong view\n- Fix: await channel termination when closing a gRPC ManagedChannel\n- Fix: Android `inputText` did not work properly when a string had whitespaces in it\n- Fix: race condition in iOS `inputText`\n\n## 1.4.0 - 2022-08-29\n\n- Added `traits` selector.\n- Relative selectors (such as `above`, `below`, etc.) are now picking the closest element.\n- Fix: continuous mode did not work for paths without a parent directory\n- Fix: workaround for UiAutomator content descriptor crash\n- Fix: `tapOn: {int}` did not work\n\n## 1.3.6 - 2022-08-25\n\n- Added `longPressOn` command\n- Decreased wait time in apps that have a screen overlay\n- Fixed CLI issue where status updates would not propagate correctly\n\n## 1.3.3 - 2022-08-23\n\n- Fix: iOS accessibility was not propagated to Maestro\n\n## 1.3.2 - 2022-08-22\n\n- Fix: env parameters did not work with init flows when using `Maestro` programmatically\n\n## 1.3.1 - 2022-08-19\n\n- Added support for externally supplied parameters\n- Added `openLink` command\n\n## 1.2.6 - 2022-08-18\n\n- Fail launching an iOS app if the app is already running\n\n## 1.2.4 - 2022-08-17\n\n- Add support for cli to specify what platform, host and port to connect to\n\n## 1.2.3 - 2022-08-15\n\n- Added support of iOS state restoration\n- Exposing `appId` field as part of `MaestroConfig`\n\n## 1.2.2 - 2022-08-08\n\n- Update `Orchestra` to support state restoration\n\n## 1.2.1 - 2022-08-04\n\n- Update `YamlCommandReader` to accept Paths instead of Files to support zip Filesystems\n\n## 1.2.0 - 2022-08-04\n\n- Config is now defined via a document separator\n- launchApp no longer requires and appId\n- initFlow config implemented\n\n## 1.1.0 - 2022-07-28\n\n- `launchApp` command now can optionally clear app state\n- `config` command to allow Orchestra consumers a higher degree of customization\n- Fixed a bug where `ElementNotFound` hierarchy field was not declared as public\n\n## 1.0.0 - 2022-07-20\n\n- Initial Maestro release (formerly known as Conductor)\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Maestro\n\nThank you for considering contributing to the project!\n\nWe welcome contributions from everyone and generally try to be as accommodating as possible. However, to make sure that your time is well spent, we separate the types of \ncontributions in the following types:\n\n- Type A: Simple fixes (bugs, typos) and cleanups\n  - You can open a pull request directly, chances are high (though never guaranteed) that it will be merged.\n- Type B: Features and major changes (i.e. refactoring)\n  - Unless you feel adventurous and wouldn't mind discarding your work in the worst-case scenario, we advise to open an issue or a PR with a suggestion first where you will \n    describe the problem you are trying to solve and the solution you have in mind. This will allow us to discuss the problem and the solution you have in mind.\n\n### Side-note on refactoring\n\nOur opinion on refactorings is generally that of - don't fix it if it isn't broken. Though we acknowledge that there are multiple areas where code could've been structured in a \ncleaner way, we believe there are no massive tech debt issues in the codebase. As each change has a probability of introducing a problem (despite all the test coverage), be \nmindful of that when working on a refactoring and have a strong justification prepared. \n\n## Lead times\n\nWe strive towards having all public PRs reviewed within a week, typically even faster than that. If you believe that your PR requires more urgency, please contact us on a \npublic Maestro Slack channel.\n\nOnce your PR is merged, it usually takes about a week until it becomes publicly available and included into the next release.\n\n## Developing\n\n### Requirements\n\nMaestro's minimal deployment target is Java 17, and for development, you need to use Java 17 or newer.\n\nIf you made changes to the CLI, rebuilt it with `./gradlew :maestro-cli:installDist`. This will generate a startup shell\nscript in `./maestro-cli/build/install/maestro/bin/maestro`. Use it instead of globally installed `maestro`.\n\nIf you made changes to the iOS XCTest runner app, make sure they are compatible with the version of Xcode used by the GitHub Actions build step. It is currently built using the default version of Xcode listed in the macos runner image [readme][macos_builder_readme].\nIf you introduce changes that work locally but fail to build when you make a PR, check if you used a feature used in a newer version of Swift or some other new Xcode setting.\n\n### Debugging\n\nMaestro stores logs for every test run in the following locations:\n\n- CLI Logs: `~/.maestro/tests/*/maestro.log`\n- iOS test runner logs: `~/Library/Logs/maestro/xctest_runner_logs`\n\n### Android artifacts\n\nMaestro requires 2 artifacts to run on Android:\n\n- `maestro-app.apk` - the host app. Does nothing.\n- `maestro-server.apk` - the test runner app. Starts an HTTP server inside an infinite JUnit/UIAutomator test.\n\nThese artifacts are built by `./gradlew :maestro-android:assemble` and `./gradlew :maestro-android:assembleAndroidTest`, respectively.\nThey are placed in `maestro-android/build/outputs/apk`, and are copied over to `maestro-client/src/main/resources`.\n\n### iOS artifacts\n\nMaestro requires 3 artifacts to run on iOS:\n\n- `maestro-driver-ios` - the host app for the test runner. Does nothing and is not installed.\n- `maestro-driver-iosUITests-Runner.app` - the test runner app. Starts an HTTP server inside an infinite XCTest. \n- `maestro-driver-ios-config.xctestrun` - the configuration file required to run the test runner app.\n\nThese artifacts are built by the `build-maestro-ios-runner.sh` script. It places them in `maestro-ios-driver/src/main/resources`.\n\n### Running standalone iOS XCTest runner app\n\nThe iOS XCTest runner can be run without Maestro CLI. To do so, make sure you built the artifacts, and then run:\n\n```console\n./maestro-ios-xctest-runner/run-maestro-ios-runner.sh\n```\n\nThis will use `xcodebuild test-without-building` to run the test runner on the connected iOS device. Now, you can reach\nthe HTTP server that runs inside the XCTest runner app (by default on port 22087):\n\n```console\ncurl -fsSL -X GET localhost:22087/deviceInfo | jq\n```\n\n<details>\n<summary>See example output</summary>\n\n```json\n{\n  \"heightPoints\": 852,\n  \"heightPixels\": 2556,\n  \"widthPixels\": 1179,\n  \"widthPoints\": 393\n}\n```\n\n</details>\n\n```console\ncurl -fsSL -X POST localhost:22087/touch -d '\n{\n  \"x\": 150,\n  \"y\": 150,\n  \"duration\": 0.2\n}'\n```\n\n```console\ncurl -sSL -X GET localhost:22087/swipe -d '\n{\n  \"startX\": 150,\n  \"startY\": 426,\n  \"endX\": 426,\n  \"endY\": 350,\n  \"duration\": 1\n}'\n```\n\n\n### Artifacts and the CLI\n\n`maestro-cli` depends on both `maestro-ios-driver` and `maestro-client`. This is how the CLI gets these artifacts.\n\n## Linting\n\n```bash\n./gradlew detekt              # Run detekt code quality checks\n./gradlew detektMain          # Run detekt with type resolution\n./gradlew detektBaseline      # Generate baseline\n```\n\n## Testing\n\nThere are 3 ways to test your changes:\n\n- Integration tests\n  - Run them via `./gradlew :maestro-test:test` (or from IDE)\n  - Tests are using real implementation of most components except for `Driver`. We use `FakeDriver` which pretends to be a real device.\n- Manual testing\n  - Run `./maestro` instead of `maestro` to use your local code.\n- Unit tests\n  - All the other tests in the projects. Run them via `./gradlew test` (or from IDE)\n\nIf you made changes to the iOS XCUITest driver, rebuild it by running `./maestro-ios-xctest-runner/build-maestro-ios-runner.sh`.\n\n## Module structure\n\n| Module | Purpose |\n|--------|---------|\n| `maestro-cli` | CLI entry point and user-facing commands |\n| `maestro-client` | `Maestro` class, `Driver` interface, core API |\n| `maestro-orchestra` | Flow execution, YAML parsing, scripting |\n| `maestro-orchestra-models` | Command data classes (serializable) |\n| `maestro-android` | Android driver implementation |\n| `maestro-ios` | iOS driver implementation |\n| `maestro-ios-xctest-runner` | Swift/Xcode XCTest runner app |\n| `maestro-web` | Web/CDP driver implementation |\n| `maestro-studio` | Studio IDE server |\n| `maestro-ai` | AI-powered test capabilities |\n| `maestro-test` | `FakeDriver` and testing utilities |\n| `maestro-utils` | Shared utilities |\n| `maestro-proto` | Protocol buffer definitions |\n| `e2e` | End-to-end test suites |\n\n### Processing flow\n\n```\nYAML Flow File → YamlCommandReader → List<MaestroCommand> → Orchestra.executeFlow() → Maestro API → Driver → Device\n```\n\n## Architectural considerations\n\nKeep the following things in mind when working on a PR:\n\n- `Maestro` class is serving as a target-agnostic API between you and the device.\n  - `Maestro` itself should not know or care about the concept of commands.\n- `Orchestra` class is a layer that translates Maestro commands (represented by `MaestroCommand`) to actual calls to `Maestro` API.\n- `Maestro` and `Orchestra` classes should remain completely target (Android/iOS/Web) agnostic.\n  - Use `Driver` interface to provide target-specific functionality.\n  - Maestro commands should be as platform-agnostic as possible, though we do allow for exceptions where they are justified.\n- Maestro CLI is supposed to be cross-platform (Mac OS, Linux, Windows).\n- Maestro is designed to run locally as well as on Maestro Cloud. That means that code should assume that it is running in a sandbox environment and shouldn't call out or spawn \n  arbitrary processes based on user's input\n  - For that reason we are not allowing execution of bash scripts from Maestro commands.\n  - For that reason, `MaestroCommand` class should be JSON-serializable (and is a reason we haven't moved to `sealed class`)\n- Prefer fakes over mocks (e.g. `FakeDriver`). Mocks (MockK) are used in some modules but fakes are the preferred approach for driver-level testing.\n\nThis graph (generated with [`./gradlew :generateDependencyGraph`][graph_plugin] in [PR #1834][pr_1834]) may be helpful\nto visualize relations between subprojects:\n\n![Project dependency graph](assets/project-dependency-graph.svg)\n\n## How to\n\n### Add new command\n\nFollow these steps:\n\n- Define a new command in `Commands.kt` file, implementing `Command` interface.\n- Add a new field to `MaestroCommand` class, following the example set by other commands.\n- Add a new field to `YamlFluentCommand` to map between yaml representation and `MaestroCommand` representation.\n- Handle command in `Orchestra` class.\n  - If this is a new functionality, you might need to add new methods to `Maestro` and `Driver` APIs.\n- Add a new test to `IntegrationTest`.\n\n[macos_builder_readme]: https://github.com/actions/runner-images/blob/main/images/macos/macos-14-Readme.md\n[graph_plugin]: https://github.com/vanniktech/gradle-dependency-graph-generator-plugin\n[pr_1834]: https://github.com/mobile-dev-inc/maestro/pull/1834\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "> [!TIP]\n> Great things happen when testers connect — [Join the Maestro Community](https://maestrodev.typeform.com/to/FelIEe8A)\n\n\n<p align=\"center\">\n  <a href=\"https://www.maestro.dev\">\n    <img width=\"1200\" alt=\"Maestro logo\" src=\"https://github.com/mobile-dev-inc/Maestro/blob/main/assets/banne_logo.png\" />\n  </a>\n</p>\n\n\n<p align=\"center\">\n  <strong>Maestro</strong> is an open-source framework that makes UI and end-to-end testing for Android, iOS, and web apps simple and fast.<br/>\n  Write your first test in under five minutes using YAML flows and run them on any emulator, simulator, or browser.\n</p>\n\n<img src=\"https://user-images.githubusercontent.com/847683/187275009-ddbdf963-ce1d-4e07-ac08-b10f145e8894.gif\" />\n\n---\n\n## Table of Contents\n\n- [Why Maestro?](#why-maestro)\n- [Getting Started](#getting-started)\n- [Resources & Community](#resources--community)\n- [Contributing](#contributing)\n- [Maestro Studio – Test IDE](#maestro-studio--test-ide)\n- [Maestro Cloud – Parallel Execution & Scalability](#maestro-cloud--parallel-execution--scalability)\n\n\n---\n\n## Why Maestro?\n\nMaestro is built on learnings from its predecessors (Appium, Espresso, UIAutomator, XCTest, Selenium, Playwright) and allows you to easily define and test your Flows.\n\nBy combining a human-readable YAML syntax with an interpreted execution engine, it lets you write, run, and scale cross-platform end-to-end tests for mobile and web with ease.\n\n- **Cross-platform coverage** – test Android, iOS, and web apps (React Native, Flutter, hybrid) on emulators, simulators, or real devices.  \n- **Human-readable YAML flows** – express interactions as commands like `launchApp`, `tapOn`, and `assertVisible`.  \n- **Resilience & smart waiting** – built-in flakiness tolerance and automatic waiting handle dynamic UIs without manual `sleep()` calls.  \n- **Fast iteration & simple install** – flows are interpreted (no compilation) and installation is a single script.\n\n**Simple Example:**\n```\n# flow_contacts_android.yaml\n\nappId: com.android.contacts\n---\n- launchApp\n- tapOn: \"Create new contact\"\n- tapOn: \"First Name\"\n- inputText: \"John\"\n- tapOn: \"Last Name\"\n- inputText: \"Snow\"\n- tapOn: \"Save\"\n```\n\n---\n## Getting Started\n\nMaestro requires Java 17 or higher to be installed on your system. You can verify your Java version by running:\n\n```\njava -version\n```\n\nInstalling the CLI:\n\nRun the following command to install Maestro on macOS, Linux or Windows (WSL):\n\n```\ncurl -fsSL \"https://get.maestro.mobile.dev\" | bash\n```\n\nThe links below will guide you through the next steps.\n\n- [Installing Maestro](https://docs.maestro.dev/getting-started/installing-maestro) (includes regular Windows installation)\n- [Build and install your app](https://docs.maestro.dev/getting-started/build-and-install-your-app)\n- [Run a sample flow](https://docs.maestro.dev/getting-started/run-a-sample-flow)\n- [Writing your first flow](https://docs.maestro.dev/getting-started/writing-your-first-flow)\n\n\n---\n\n## Resources & Community\n\n- 💬 [Join the Slack Community](https://maestrodev.typeform.com/to/FelIEe8A)\n- 📘 [Documentation](https://docs.maestro.dev)  \n- 📰 [Blog](https://maestro.dev/blog?utm_source=github-readme) \n- 🐦 [Follow us on X](https://twitter.com/maestro__dev)\n\n---\n\n## Contributing\n\nMaestro is open-source under the Apache 2.0 license — contributions are welcome!\n\n- Check [good first issues](https://github.com/mobile-dev-inc/maestro/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22)\n- Read the [Contribution Guide](https://github.com/mobile-dev-inc/Maestro/blob/main/CONTRIBUTING.md) \n- Fork, create a branch, and open a Pull Request.\n\nIf you find Maestro useful, ⭐ star the repository to support the project.\n\n---\n\n## Maestro Studio – Test IDE\n\n**Maestro Studio Desktop** is a lightweight IDE that lets you design and execute tests visually — no terminal needed. \nIt is also free, even though Studio is not an open-source project. So you won't find the Maestro Studio code here.\n\n- **Simple setup** – just download the native app for macOS, Windows, or Linux.  \n- **Visual flow builder & inspector** – record interactions, inspect elements, and build flows visually.  \n- **AI assistance** – use MaestroGPT to generate commands and answer questions while authoring tests.\n\n[Download Maestro Studio](https://maestro.dev/?utm_source=github-readme#maestro-studio)\n\n---\n\n## Maestro Cloud – Parallel Execution & Scalability\n\nWhen your test suite grows, run hundreds of tests in parallel on dedicated infrastructure, cutting execution times by up to 90%. Includes built-in notifications, deterministic environments, and complete debugging tools.\n\nPricing for Maestro Cloud is completely transparent and can be found on the [pricing page](https://maestro.dev/pricing?utm_source=github-readme).\n\n👉 [Start your free 7-day trial](https://maestro.dev/cloud?utm_source=github-readme)\n\n\n\n```\n  Built with ❤️ by Maestro.dev\n```\n\n\n"
  },
  {
    "path": "RELEASING.md",
    "content": "# Production Releases\n\n## Prepare\n\n1. Define the next semantic version\n\n   Semantic versioning: a.b.c\n\n   - a: major breaking changes\n   - b: new functionality, new features\n   - c: any other small changes\n\n2. Checkout the main branch and make sure it is up-to-date: `git checkout main && git pull`\n3. Create a new branch\n4. Update the CHANGELOG.md file with changes of this release, you should add a new section with your version number and the relevant updates, like the ones that exist on the previous versions\n5. Change the version in `gradle.properties`\n6. Change the version in `maestro-cli/gradle.properties`\n7. `git commit -am \"Prepare for release X.Y.Z.\"` (where X.Y.Z is the new version)\n8. Submit a PR with the changes against the main branch\n9. Merge the PR\n\n## Tag\n\n1. `git tag -a vX.Y.Z -m \"Version X.Y.Z\"` (where X.Y.Z is the new version)\n2. `git push --tags`\n3. Wait until all Publish actions have completed https://github.com/mobile-dev-inc/maestro/actions\n\n## Publish Maven Central\n\n1. Trigger the [Publish Release action](https://github.com/mobile-dev-inc/maestro/actions/workflows/publish-release.yml)\n   - ATTENTION: Wait for it to finish\n3. Go to [OSS Sonatype](https://s01.oss.sonatype.org/) and login with user/password\n4. Go to Staging Repositories, select the repository uploaded from the trigger above.\n5. Click \"Close\" and then \"Release\". Each of these operations take a couple minutes to complete\n\n____________________________________________________________________________________________________________________________________________________\n**CAUTION:** You should go back to the [notion document](https://www.notion.so/Maestro-Release-Run-Book-78159c6f80de4492a6e9e05bb490cf60?pvs=4) to see how to update the **Robin** and **Maestro Cloud** versions before updating the **CLI**\n____________________________________________________________________________________________________________________________________________________\n\n## Publish CLI\n\n1. Trigger the [Publish CLI Github action](https://github.com/mobile-dev-inc/Maestro/actions/workflows/publish-cli.yaml)\n2. Test installing the cli by running `curl -Ls \"https://get.maestro.mobile.dev\" | bash`\n3. Check the version number `maestro --version`\n\n"
  },
  {
    "path": "build.gradle.kts",
    "content": "import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask\n\n@Suppress(\"DSL_SCOPE_VIOLATION\")\nplugins {\n    alias(libs.plugins.kotlin.jvm)\n    alias(libs.plugins.kotlin.android) apply false\n    alias(libs.plugins.android.application) apply false\n    alias(libs.plugins.protobuf) apply false\n    alias(libs.plugins.mavenPublish)\n    alias(libs.plugins.detekt)\n}\n\njava {\n    sourceCompatibility = JavaVersion.VERSION_17\n    targetCompatibility = JavaVersion.VERSION_17\n}\n\ntasks.named(\"compileKotlin\", KotlinCompilationTask::class.java) {\n    compilerOptions {\n        freeCompilerArgs.addAll(\"-Xjdk-release=17\")\n    }\n}\n\nkotlin {\n    jvmToolchain {\n        languageVersion.set(JavaLanguageVersion.of(17))\n    }\n}\n\ndetekt {\n    buildUponDefaultConfig = true\n    allRules = false\n    autoCorrect = true\n    config = files(\"${rootDir}/detekt.yml\")\n}\n"
  },
  {
    "path": "detekt.yml",
    "content": "build:\n  maxIssues: 0\n  excludeCorrectable: false\n  weights:\n  # complexity: 2\n  # LongParameterList: 1\n  # style: 1\n  # comments: 1\n\nconfig:\n  validation: true\n  warningsAsErrors: false\n  # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]'\n  excludes: ''\n\nprocessors:\n  active: true\n  exclude:\n    - 'DetektProgressListener'\n  # - 'KtFileCountProcessor'\n  # - 'PackageCountProcessor'\n  # - 'ClassCountProcessor'\n  # - 'FunctionCountProcessor'\n  # - 'PropertyCountProcessor'\n  # - 'ProjectComplexityProcessor'\n  # - 'ProjectCognitiveComplexityProcessor'\n  # - 'ProjectLLOCProcessor'\n  # - 'ProjectCLOCProcessor'\n  # - 'ProjectLOCProcessor'\n  # - 'ProjectSLOCProcessor'\n  # - 'LicenseHeaderLoaderExtension'\n\nconsole-reports:\n  active: true\n  exclude:\n    - 'ProjectStatisticsReport'\n    - 'ComplexityReport'\n    - 'NotificationReport'\n    #  - 'FindingsReport'\n    - 'FileBasedFindingsReport'\n    - 'LiteFindingsReport'\n\noutput-reports:\n  active: true\n  exclude:\n  # - 'TxtOutputReport'\n  # - 'XmlOutputReport'\n  # - 'HtmlOutputReport'\n\ncomments:\n  active: true\n  AbsentOrWrongFileLicense:\n    active: false\n    licenseTemplateFile: 'license.template'\n    licenseTemplateIsRegex: false\n  CommentOverPrivateFunction:\n    active: false\n  CommentOverPrivateProperty:\n    active: false\n  DeprecatedBlockTag:\n    active: false\n  EndOfSentenceFormat:\n    active: false\n    endOfSentenceFormat: '([.?!][ \\t\\n\\r\\f<])|([.?!:]$)'\n  OutdatedDocumentation:\n    active: false\n    matchTypeParameters: true\n    matchDeclarationsOrder: true\n  UndocumentedPublicClass:\n    active: false\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    searchInNestedClass: true\n    searchInInnerClass: true\n    searchInInnerObject: true\n    searchInInnerInterface: true\n  UndocumentedPublicFunction:\n    active: false\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n  UndocumentedPublicProperty:\n    active: false\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n\ncomplexity:\n  active: true\n  ComplexCondition:\n    active: true\n    threshold: 4\n  ComplexInterface:\n    active: false\n    threshold: 10\n    includeStaticDeclarations: false\n    includePrivateDeclarations: false\n  ComplexMethod:\n    active: true\n    threshold: 15\n    ignoreSingleWhenExpression: false\n    ignoreSimpleWhenEntries: false\n    ignoreNestingFunctions: false\n    nestingFunctions:\n      - 'also'\n      - 'apply'\n      - 'forEach'\n      - 'isNotNull'\n      - 'ifNull'\n      - 'let'\n      - 'run'\n      - 'use'\n      - 'with'\n  LabeledExpression:\n    active: false\n    ignoredLabels: [ ]\n  LargeClass:\n    active: true\n    threshold: 600\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n  LongMethod:\n    active: true\n    threshold: 120\n  LongParameterList:\n    active: false\n    functionThreshold: 6\n    constructorThreshold: 7\n    ignoreDefaultParameters: false\n    ignoreDataClasses: true\n    ignoreAnnotatedParameter: [ ]\n  MethodOverloading:\n    active: false\n    threshold: 6\n  NamedArguments:\n    active: false\n    threshold: 3\n  NestedBlockDepth:\n    active: true\n    threshold: 4\n  ReplaceSafeCallChainWithRun:\n    active: false\n  StringLiteralDuplication:\n    active: false\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    threshold: 3\n    ignoreAnnotation: true\n    excludeStringsWithLessThan5Characters: true\n    ignoreStringsRegex: '$^'\n  TooManyFunctions:\n    active: false\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    thresholdInFiles: 11\n    thresholdInClasses: 11\n    thresholdInInterfaces: 11\n    thresholdInObjects: 11\n    thresholdInEnums: 11\n    ignoreDeprecated: false\n    ignorePrivate: false\n    ignoreOverridden: false\n\ncoroutines:\n  active: true\n  GlobalCoroutineUsage:\n    active: false\n  InjectDispatcher:\n    active: false\n    dispatcherNames:\n      - 'IO'\n      - 'Default'\n      - 'Unconfined'\n  RedundantSuspendModifier:\n    active: false\n  SleepInsteadOfDelay:\n    active: false\n  SuspendFunWithFlowReturnType:\n    active: false\n\nempty-blocks:\n  active: true\n  EmptyCatchBlock:\n    active: true\n    allowedExceptionNameRegex: '_|(ignore|expected).*'\n  EmptyClassBlock:\n    active: true\n  EmptyDefaultConstructor:\n    active: true\n  EmptyDoWhileBlock:\n    active: true\n  EmptyElseBlock:\n    active: true\n  EmptyFinallyBlock:\n    active: true\n  EmptyForBlock:\n    active: true\n  EmptyFunctionBlock:\n    active: true\n    ignoreOverridden: true\n  EmptyIfBlock:\n    active: true\n  EmptyInitBlock:\n    active: true\n  EmptyKtFile:\n    active: true\n  EmptySecondaryConstructor:\n    active: true\n  EmptyTryBlock:\n    active: true\n  EmptyWhenBlock:\n    active: true\n  EmptyWhileBlock:\n    active: true\n\nexceptions:\n  active: true\n  ExceptionRaisedInUnexpectedLocation:\n    active: true\n    methodNames:\n      - 'equals'\n      - 'finalize'\n      - 'hashCode'\n      - 'toString'\n  InstanceOfCheckForException:\n    active: false\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n  NotImplementedDeclaration:\n    active: false\n  ObjectExtendsThrowable:\n    active: false\n  PrintStackTrace:\n    active: true\n  RethrowCaughtException:\n    active: true\n  ReturnFromFinally:\n    active: true\n    ignoreLabeled: false\n  SwallowedException:\n    active: true\n    ignoredExceptionTypes:\n      - 'InterruptedException'\n      - 'MalformedURLException'\n      - 'NumberFormatException'\n      - 'ParseException'\n    allowedExceptionNameRegex: '_|(ignore|expected).*'\n  ThrowingExceptionFromFinally:\n    active: true\n  ThrowingExceptionInMain:\n    active: false\n  ThrowingExceptionsWithoutMessageOrCause:\n    active: false\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    exceptions:\n      - 'ArrayIndexOutOfBoundsException'\n      - 'Exception'\n      - 'IllegalArgumentException'\n      - 'IllegalMonitorStateException'\n      - 'IllegalStateException'\n      - 'IndexOutOfBoundsException'\n      - 'NullPointerException'\n      - 'RuntimeException'\n      - 'Throwable'\n  ThrowingNewInstanceOfSameException:\n    active: true\n  TooGenericExceptionCaught:\n    active: true\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    exceptionNames:\n      - 'ArrayIndexOutOfBoundsException'\n      - 'Error'\n      - 'Exception'\n      - 'IllegalMonitorStateException'\n      - 'IndexOutOfBoundsException'\n      - 'NullPointerException'\n      - 'RuntimeException'\n      - 'Throwable'\n    allowedExceptionNameRegex: '_|(ignore|expected).*'\n  TooGenericExceptionThrown:\n    active: true\n    exceptionNames:\n      - 'Error'\n      - 'Exception'\n      - 'RuntimeException'\n      - 'Throwable'\n\nformatting:\n  active: true\n  android: false\n  autoCorrect: true\n  AnnotationOnSeparateLine:\n    active: false\n    autoCorrect: true\n  AnnotationSpacing:\n    active: false\n    autoCorrect: true\n  ArgumentListWrapping:\n    active: false\n    autoCorrect: true\n    indentSize: 4\n    maxLineLength: 160\n  ChainWrapping:\n    active: true\n    autoCorrect: true\n  CommentSpacing:\n    active: true\n    autoCorrect: true\n  EnumEntryNameCase:\n    active: false\n    autoCorrect: true\n  Filename:\n    active: true\n  FinalNewline:\n    active: true\n    autoCorrect: true\n    insertFinalNewLine: true\n  ImportOrdering:\n    active: true\n    autoCorrect: true\n    layout: '*,java.**,javax.**,kotlin.**,^'\n  Indentation:\n    active: true\n    autoCorrect: true\n    indentSize: 4\n    continuationIndentSize: 4\n  MaximumLineLength:\n    active: true\n    maxLineLength: 160\n    ignoreBackTickedIdentifier: false\n  ModifierOrdering:\n    active: true\n    autoCorrect: true\n  MultiLineIfElse:\n    active: false\n    autoCorrect: true\n  NoBlankLineBeforeRbrace:\n    active: true\n    autoCorrect: true\n  NoConsecutiveBlankLines:\n    active: true\n    autoCorrect: true\n  NoEmptyClassBody:\n    active: true\n    autoCorrect: true\n  NoEmptyFirstLineInMethodBlock:\n    active: false\n    autoCorrect: true\n  NoLineBreakAfterElse:\n    active: true\n    autoCorrect: true\n  NoLineBreakBeforeAssignment:\n    active: true\n    autoCorrect: true\n  NoMultipleSpaces:\n    active: true\n    autoCorrect: true\n  NoSemicolons:\n    active: true\n    autoCorrect: true\n  NoTrailingSpaces:\n    active: true\n    autoCorrect: true\n  NoUnitReturn:\n    active: true\n    autoCorrect: true\n  NoUnusedImports:\n    active: true\n    autoCorrect: true\n  NoWildcardImports:\n    active: true\n  PackageName:\n    active: false\n    autoCorrect: true\n  ParameterListWrapping:\n    active: true\n    autoCorrect: true\n    indentSize: 4\n    maxLineLength: 160\n  SpacingAroundAngleBrackets:\n    active: false\n    autoCorrect: true\n  SpacingAroundColon:\n    active: true\n    autoCorrect: true\n  SpacingAroundComma:\n    active: true\n    autoCorrect: true\n  SpacingAroundCurly:\n    active: true\n    autoCorrect: true\n  SpacingAroundDot:\n    active: true\n    autoCorrect: true\n  SpacingAroundDoubleColon:\n    active: false\n    autoCorrect: true\n  SpacingAroundKeyword:\n    active: true\n    autoCorrect: true\n  SpacingAroundOperators:\n    active: true\n    autoCorrect: true\n  SpacingAroundParens:\n    active: true\n    autoCorrect: true\n  SpacingAroundRangeOperator:\n    active: true\n    autoCorrect: true\n  SpacingAroundUnaryOperator:\n    active: false\n    autoCorrect: true\n  SpacingBetweenDeclarationsWithAnnotations:\n    active: false\n    autoCorrect: true\n  SpacingBetweenDeclarationsWithComments:\n    active: false\n    autoCorrect: true\n  StringTemplate:\n    active: true\n    autoCorrect: true\n\nnaming:\n  active: true\n  BooleanPropertyNaming:\n    active: false\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    allowedPattern: '^(is|has|are)'\n  ClassNaming:\n    active: true\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    classPattern: '[A-Z][a-zA-Z0-9]*'\n  ConstructorParameterNaming:\n    active: true\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    parameterPattern: '[a-z][A-Za-z0-9]*'\n    privateParameterPattern: '[a-z][A-Za-z0-9]*'\n    excludeClassPattern: '$^'\n    ignoreOverridden: true\n  EnumNaming:\n    active: true\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    enumEntryPattern: '[A-Z][_a-zA-Z0-9]*'\n  ForbiddenClassName:\n    active: false\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    forbiddenName: [ ]\n  FunctionMaxLength:\n    active: false\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    maximumFunctionNameLength: 30\n  FunctionMinLength:\n    active: false\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    minimumFunctionNameLength: 3\n  FunctionNaming:\n    active: true\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    functionPattern: '([a-z][a-zA-Z0-9]*)|(`.*`)'\n    excludeClassPattern: '$^'\n    ignoreOverridden: true\n  FunctionParameterNaming:\n    active: true\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    parameterPattern: '[a-z][A-Za-z0-9]*'\n    excludeClassPattern: '$^'\n    ignoreOverridden: true\n  InvalidPackageDeclaration:\n    active: false\n    rootPackage: ''\n  LambdaParameterNaming:\n    active: false\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    parameterPattern: '[a-z][A-Za-z0-9]*|_'\n  MatchingDeclarationName:\n    active: true\n    mustBeFirst: true\n  MemberNameEqualsClassName:\n    active: true\n    ignoreOverridden: true\n  NoNameShadowing:\n    active: false\n  NonBooleanPropertyPrefixedWithIs:\n    active: false\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n  ObjectPropertyNaming:\n    active: true\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    constantPattern: '[A-Za-z][_A-Za-z0-9]*'\n    propertyPattern: '[A-Za-z][_A-Za-z0-9]*'\n    privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*'\n  PackageNaming:\n    active: true\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    packagePattern: '[a-z]+(\\.[a-z][A-Za-z0-9]*)*'\n  TopLevelPropertyNaming:\n    active: true\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    constantPattern: '[A-Z][_A-Z0-9]*'\n    propertyPattern: '[A-Za-z][_A-Za-z0-9]*'\n    privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*'\n  VariableMaxLength:\n    active: false\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    maximumVariableNameLength: 64\n  VariableMinLength:\n    active: false\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    minimumVariableNameLength: 1\n  VariableNaming:\n    active: true\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    variablePattern: '[a-z][A-Za-z0-9]*'\n    privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*'\n    excludeClassPattern: '$^'\n    ignoreOverridden: true\n\nperformance:\n  active: true\n  ArrayPrimitive:\n    active: true\n  ForEachOnRange:\n    active: true\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n  SpreadOperator:\n    active: true\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n  UnnecessaryTemporaryInstantiation:\n    active: true\n\npotential-bugs:\n  active: true\n  AvoidReferentialEquality:\n    active: false\n    forbiddenTypePatterns:\n      - 'kotlin.String'\n  CastToNullableType:\n    active: false\n  Deprecation:\n    active: false\n  DontDowncastCollectionTypes:\n    active: false\n  DoubleMutabilityForCollection:\n    active: false\n  DuplicateCaseInWhenExpression:\n    active: true\n  EqualsAlwaysReturnsTrueOrFalse:\n    active: true\n  EqualsWithHashCodeExist:\n    active: true\n  ExitOutsideMain:\n    active: false\n  ExplicitGarbageCollectionCall:\n    active: true\n  HasPlatformType:\n    active: false\n  IgnoredReturnValue:\n    active: false\n    restrictToAnnotatedMethods: true\n    returnValueAnnotations:\n      - '*.CheckResult'\n      - '*.CheckReturnValue'\n    ignoreReturnValueAnnotations:\n      - '*.CanIgnoreReturnValue'\n  ImplicitDefaultLocale:\n    active: true\n  ImplicitUnitReturnType:\n    active: false\n    allowExplicitReturnType: true\n  InvalidRange:\n    active: true\n  IteratorHasNextCallsNextMethod:\n    active: true\n  IteratorNotThrowingNoSuchElementException:\n    active: true\n  LateinitUsage:\n    active: false\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    ignoreOnClassesPattern: ''\n  MapGetWithNotNullAssertionOperator:\n    active: false\n  MissingPackageDeclaration:\n    active: false\n    excludes: [ '**/*.kts' ]\n  MissingWhenCase:\n    active: true\n    allowElseExpression: true\n  NullableToStringCall:\n    active: false\n  RedundantElseInWhen:\n    active: true\n  UnconditionalJumpStatementInLoop:\n    active: false\n  UnnecessaryNotNullOperator:\n    active: true\n  UnnecessarySafeCall:\n    active: true\n  UnreachableCatchBlock:\n    active: false\n  UnreachableCode:\n    active: true\n  UnsafeCallOnNullableType:\n    active: true\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n  UnsafeCast:\n    active: true\n  UnusedUnaryOperator:\n    active: false\n  UselessPostfixExpression:\n    active: false\n  WrongEqualsTypeParameter:\n    active: true\n\nstyle:\n  active: true\n  ClassOrdering:\n    active: false\n  CollapsibleIfStatements:\n    active: false\n  DataClassContainsFunctions:\n    active: false\n    conversionFunctionPrefix: 'to'\n  DataClassShouldBeImmutable:\n    active: false\n  DestructuringDeclarationWithTooManyEntries:\n    active: false\n    maxDestructuringEntries: 3\n  EqualsNullCall:\n    active: true\n  EqualsOnSignatureLine:\n    active: false\n  ExplicitCollectionElementAccessMethod:\n    active: false\n  ExplicitItLambdaParameter:\n    active: false\n  ExpressionBodySyntax:\n    active: false\n    includeLineWrapping: false\n  ForbiddenComment:\n    active: true\n    values:\n      - 'FIXME:'\n      - 'STOPSHIP:'\n      - 'TODO:'\n    allowedPatterns: ''\n    customMessage: ''\n  ForbiddenImport:\n    active: false\n    imports: [ ]\n    forbiddenPatterns: ''\n  ForbiddenMethodCall:\n    active: false\n    methods:\n      - 'kotlin.io.print'\n      - 'kotlin.io.println'\n  ForbiddenPublicDataClass:\n    active: true\n    excludes: [ '**' ]\n    ignorePackages:\n      - '*.internal'\n      - '*.internal.*'\n  ForbiddenVoid:\n    active: false\n    ignoreOverridden: false\n    ignoreUsageInGenerics: false\n  FunctionOnlyReturningConstant:\n    active: true\n    ignoreOverridableFunction: true\n    ignoreActualFunction: true\n    excludedFunctions: ''\n  LibraryCodeMustSpecifyReturnType:\n    active: true\n    excludes: [ '**' ]\n  LibraryEntitiesShouldNotBePublic:\n    active: true\n    excludes: [ '**' ]\n  LoopWithTooManyJumpStatements:\n    active: true\n    maxJumpCount: 1\n  MagicNumber:\n    active: false\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    ignoreNumbers:\n      - '-1'\n      - '0'\n      - '1'\n      - '2'\n    ignoreHashCodeFunction: true\n    ignorePropertyDeclaration: false\n    ignoreLocalVariableDeclaration: false\n    ignoreConstantDeclaration: true\n    ignoreCompanionObjectPropertyDeclaration: true\n    ignoreAnnotation: false\n    ignoreNamedArgument: true\n    ignoreEnums: false\n    ignoreRanges: false\n    ignoreExtensionFunctions: true\n  MandatoryBracesIfStatements:\n    active: false\n  MandatoryBracesLoops:\n    active: false\n  MaxLineLength:\n    active: true\n    maxLineLength: 160\n    excludePackageStatements: true\n    excludeImportStatements: true\n    excludeCommentStatements: false\n  MayBeConst:\n    active: true\n  ModifierOrder:\n    active: true\n  MultilineLambdaItParameter:\n    active: false\n  NestedClassesVisibility:\n    active: true\n  NewLineAtEndOfFile:\n    active: true\n  NoTabs:\n    active: false\n  ObjectLiteralToLambda:\n    active: false\n  OptionalAbstractKeyword:\n    active: true\n  OptionalUnit:\n    active: false\n  OptionalWhenBraces:\n    active: false\n  PreferToOverPairSyntax:\n    active: false\n  ProtectedMemberInFinalClass:\n    active: true\n  RedundantExplicitType:\n    active: false\n  RedundantHigherOrderMapUsage:\n    active: false\n  RedundantVisibilityModifierRule:\n    active: false\n  ReturnCount:\n    active: false\n    max: 2\n    excludedFunctions: 'equals'\n    excludeLabeled: false\n    excludeReturnFromLambda: true\n    excludeGuardClauses: false\n  SafeCast:\n    active: true\n  SerialVersionUIDInSerializableClass:\n    active: true\n  SpacingBetweenPackageAndImports:\n    active: false\n  ThrowsCount:\n    active: false\n    max: 2\n    excludeGuardClauses: false\n  TrailingWhitespace:\n    active: false\n  UnderscoresInNumericLiterals:\n    active: false\n    acceptableLength: 4\n  UnnecessaryAbstractClass:\n    active: false\n  UnnecessaryAnnotationUseSiteTarget:\n    active: false\n  UnnecessaryApply:\n    active: true\n  UnnecessaryFilter:\n    active: false\n  UnnecessaryInheritance:\n    active: true\n  UnnecessaryLet:\n    active: false\n  UnnecessaryParentheses:\n    active: false\n  UntilInsteadOfRangeTo:\n    active: false\n  UnusedImports:\n    active: false\n  UnusedPrivateClass:\n    active: true\n  UnusedPrivateMember:\n    active: true\n    allowedNames: '(_|ignored|expected|serialVersionUID)'\n  UseAnyOrNoneInsteadOfFind:\n    active: false\n  UseArrayLiteralsInAnnotations:\n    active: false\n  UseCheckNotNull:\n    active: false\n  UseCheckOrError:\n    active: false\n  UseDataClass:\n    active: false\n    allowVars: false\n  UseEmptyCounterpart:\n    active: false\n  UseIfEmptyOrIfBlank:\n    active: false\n  UseIfInsteadOfWhen:\n    active: false\n  UseIsNullOrEmpty:\n    active: false\n  UseOrEmpty:\n    active: false\n  UseRequire:\n    active: false\n  UseRequireNotNull:\n    active: false\n  UselessCallOnNotNull:\n    active: true\n  UtilityClassWithPublicConstructor:\n    active: true\n  VarCouldBeVal:\n    active: true\n  WildcardImport:\n    active: false\n    excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ]\n    excludeImports:\n      - 'java.util.*'\n"
  },
  {
    "path": "e2e/.gitignore",
    "content": "apps/\n\nsamples/\nsamples.zip\n"
  },
  {
    "path": "e2e/README.md",
    "content": "# e2e\n\nThis directory contains glue code for testing Maestro itself.\n\n## Testing\n\nTypical workflow is:\n\n1. Start Android emulator and iOS simulator\n2. `download_apps`\n3. `install_apps`\n4. `run_tests`\n\nWe try to keep shell code in separate files, so we don't get too tightly coupled\nto GitHub Actions.\n\n### Expected failures\n\nLet's say a critical bug is introduced that causes Maestro to always mark all\ntests as passed. If our e2e test suite only was only checking if all tests pass\n(i.e. `maestro test` exit code is 0), then wouldn't catch such a bug.\n\nTo prevent this, all flows in this directory MUST have a `passing` or `failing`\nlabel, so the correct outcome can be asserted.\n\n## Samples\n\nThis directory also contains samples that are downloaded by the `maestro download-samples` command,\nand some glue code to facilitate updating those samples.\n\n`maestro download-samples` provides a set of flows and apps so that users can\nquickly try out Maestro, without having to write any flows for their own app.\n\n`download-samples` downloads these files and apps from our publicly-available\nGoogle Cloud Storage bucket (hosted on `storage.googleapis.com`).\n\n### Intro\n\nThe samples are automatically updated by the GitHub Action on every new commit\nto the `main` branch.\n\nThere zip archive that is downloaded by `download-samples` consists of 2 things:\n- the Maestro workspace with flows (located in the `workspaces/wikipedia` directory)\n- the app binary files that are used in the flows (located in the `apps` directory)\n\nApp binary files are heavy, so we don't store them in the repository. Instead, they are hosted\non publicly available directory in Google Cloud Storage:\n\n### Update the samples\n\nRun the script:\n\n```console\n./update_samples\n```\n"
  },
  {
    "path": "e2e/download_apps",
    "content": "#!/usr/bin/env sh\nset -eu\n\n# Download apps from URLs listed in manifest.txt.\n#\n# We assume that if the downloaded file is a zip file, it's an iOS app and must\n# be unzipped.\n\n[ \"$(basename \"$PWD\")\" = \"e2e\" ] || { echo \"must be run from e2e directory\" && exit 1; }\n\ncommand -v curl >/dev/null 2>&1 || { echo \"curl is required\" && exit 1; }\n\nplatform=\"${1:-}\" # android or ios or an empty string (no filter)\nplatform=\"$(echo \"$platform\" | tr '[:upper:]' '[:lower:]')\" # Normalize to lowercase\n\nmkdir -p ./apps\nwhile read -r url; do\n  case \"$platform\" in\n    android)\n      echo \"$url\" | grep -qi '\\.apk$' || continue # Skip if not an APK\n      ;;\n    ios)\n      echo \"$url\" | grep -qi '\\.zip$' || continue # Skip if not a ZIP file\n      ;;\n    *)\n      # No filter\n      ;;\n  esac\n  echo \"download $url\"\n  app_file=\"$(curl -fsSL --output-dir ./apps --write-out \"%{filename_effective}\" -OJ \"$url\")\"\n  extension=\"${app_file##*.}\"\n  if [ \"$extension\" = \"zip\" ]; then\n    unzip -qq -o -d ./apps \"$app_file\" -x \"__MACOSX/*\"\n  fi\ndone <manifest.txt"
  },
  {
    "path": "e2e/install_apps",
    "content": "#!/usr/bin/env sh\nset -eu\n\n# Install all apps from apps/ directory (that was previously created with\n# download_apps).\n#\n# Matches .apk files to install on Android devices and .app files to install on\n# iOS simulators.\n\n[ \"$(basename \"$PWD\")\" = \"e2e\" ] || { echo \"must be run from e2e directory\" && exit 1; }\n\nplatform=\"${1:-}\"\nif [ \"$platform\" != \"android\" ] && [ \"$platform\" != \"ios\" ]; then\n\techo \"usage: $0 <android|ios>\"\n\texit 1\nfi\n\ncommand -v adb >/dev/null 2>&1 || { echo \"adb is required\" && exit 1; }\n\nfor file in ./apps/*; do\n\tfilename=\"$(basename \"$file\")\"\n\n\textension=\"${file##*.}\"\n\tif [ \"$platform\" = android ] && [ \"$extension\" = \"apk\" ]; then\n\t\techo \"install $filename\"\n\t\tadb install -r \"$file\" >/dev/null || echo \"adb: could not install $filename\"\n\telif [ \"$platform\" = ios ] && [ \"$extension\" = \"app\" ] && [ \"$(uname)\" = \"Darwin\" ]; then\n\t\techo \"install $filename\"\n\t\txcrun simctl install booted \"$file\" || echo \"xcrun: could not install $filename\"\n\tfi\ndone\n"
  },
  {
    "path": "e2e/manifest.txt",
    "content": "https://storage.googleapis.com/mobile.dev/cli_e2e/wikipedia.apk\nhttps://storage.googleapis.com/mobile.dev/cli_e2e/wikipedia.zip\nhttps://storage.googleapis.com/mobile.dev/cli_e2e/demo_app.apk\nhttps://storage.googleapis.com/mobile.dev/cli_e2e/demo_app.zip\nhttps://storage.googleapis.com/mobile.dev/cli_e2e/setOrientation.apk\nhttps://storage.googleapis.com/mobile.dev/cli_e2e/SimpleWebViewApp.zip\n"
  },
  {
    "path": "e2e/run_tests",
    "content": "#!/usr/bin/env sh\nset -eu\n\n# Runs all tests in the workspaces directory.\n\ncommand -v maestro >/dev/null 2>&1 || { echo \"maestro is required\" && exit 1; }\n\n[ \"$(basename \"$PWD\")\" = \"e2e\" ] || { echo \"must be run from e2e directory\" && exit 1; }\n\nALL_PASS=true\n\n_h1() { printf \"=>\\n=> %s\\n=>\\n\" \"$1\"; }\n_h2() { printf \"==> [%s] %s\\n\" \"$1\" \"$2\"; }\n_h3() { printf \"==> [%s] [%s] => %s\\n\" \"$1\" \"$2\" \"$3\"; }\n\ncloud=\"android_device_configuration,ios_device_configuration\" # Maestro Cloud specific tests\nplatform=\"${1:-}\"\ncase \"$platform\" in\n\tandroid) exclude_tags=\"ios,web,$cloud\" ;;\n\tios)     exclude_tags=\"android,$cloud,web\" ;;\n\tweb)     exclude_tags=\"android,ios,$cloud\" ;;\n\t*)       echo \"usage: $0 <android|ios|web>\"; exit 1 ;;\nesac\n\nmkfifo pipe\ntrap 'rm -f pipe' EXIT\n\nexport MAESTRO_EXAMPLE=\"test-value\"  # Relied upon in a test\n\nif [ \"$platform\" = \"web\" ]; then\n\t###\n\t### Web: run web-tagged tests for demo_app only\n\t###\n\tworkspace_dir=\"./workspaces/demo_app\"\n\tapp_name=\"demo_app\"\n\n\t_h1 \"run tests for app \\\"$app_name\\\" on platform \\\"$platform\\\"\"\n\t_h2 \"$app_name\" \"run web tests\"\n\n\twhile IFS= read -r line; do\n\t\t_h3 \"$app_name\" \"web\" \"$line\"\n\tdone < pipe &\n\n\tmaestro --verbose --platform \"$platform\" test --headless --include-tags web --exclude-tags \"$exclude_tags\" \"$workspace_dir\" 1>pipe 2>&1 \\\n\t\t|| { _h2 \"$app_name\" \"FAIL! Expected all pass, but at least some failed instead\"; ALL_PASS=false; }\nelse\n\tfor workspace_dir in ./workspaces/*; do\n\t\tapp_name=\"$(basename \"$workspace_dir\")\"\n\n\t\tcase $app_name in\n\t\t\t# demo_app has OOM issues on GHA\n\t\t\tdemo_app|setOrientation) [ \"$platform\" = \"ios\" ]     && continue ;;\n\t\t\tsimple_web_view)         [ \"$platform\" = \"android\" ] && continue ;;\n\t\tesac\n\n\t\t_h1 \"run tests for app \\\"$app_name\\\" on platform \\\"$platform\\\"\"\n\n\t\t###\n\t\t### Run passing tests\n\t\t###\n\t\t_h2 \"$app_name\" \"run passing tests\"\n\n\t\twhile IFS= read -r line; do\n\t\t\t_h3 \"$app_name\" \"passing\" \"$line\"\n\t\tdone < pipe &\n\n\t\tmaestro --verbose --platform \"$platform\" test --include-tags passing --exclude-tags \"$exclude_tags\" \"$workspace_dir\" 1>pipe 2>&1 \\\n\t\t\t|| { _h2 \"$app_name\" \"FAIL! Expected all pass, but at least some failed instead\"; ALL_PASS=false; }\n\n\t\t###\n\t\t### Run failing tests (skip workspaces with no failing flows)\n\t\t###\n\t\tcase $app_name in\n\t\t\twikipedia|setOrientation|simple_web_view) continue ;;\n\t\tesac\n\n\t\t_h2 \"$app_name\" \"run failing tests\"\n\n\t\twhile IFS= read -r line; do\n\t\t\t_h3 \"$app_name\" \"failing\" \"$line\"\n\t\tdone < pipe &\n\n\t\tmaestro --verbose --platform \"$platform\" test --include-tags failing --exclude-tags \"$exclude_tags\" \"$workspace_dir\" 1>pipe 2>&1 \\\n\t\t\t&& { _h2 \"$app_name\" \"FAIL! Expected all to fail, but at least some passed instead\"; ALL_PASS=false; }\n\tdone\nfi\n\nif [ \"$ALL_PASS\" = \"false\" ]; then\n\t_h1 \"FAILURE: some tests failed!\"\n\texit 1\nelse\n\t_h1 \"SUCCESS: all tests passed!\"\nfi"
  },
  {
    "path": "e2e/update_samples",
    "content": "#!/usr/bin/env sh\nset -eu\n\n# Updates the samples that are hosted in mobile.dev's GCS bucket ($SAMPLES_URL).\n# The samples are for use with `maestro download-samples` command.\n\n[ \"$(basename \"$PWD\")\" = \"e2e\" ] || { echo \"must be run from e2e directory\" && exit 1; }\n\ncommand -v curl >/dev/null 2>&1 || { echo \"curl is required\" && exit 1; }\ncommand -v gsutil >/dev/null 2>&1 || { echo \"gsutil is required\" && exit 1; }\n\nSAMPLES_URL=\"gs://mobile.dev/samples/samples.zip\"\n\nif [ ! -d apps/ ]; then\n\t./download_apps\nfi\n\nrm -rf samples/ samples.zip\nmkdir -p samples/\ncp -r apps/wikipedia.apk apps/wikipedia.zip samples/\ncp -r workspaces/wikipedia/* samples/\ncp samples/wikipedia.apk samples/sample.apk # The name is being depended upon.\ncp samples/wikipedia.zip samples/sample.zip # The name is being depended upon.\ncd samples/\n\nzip -r -q ../samples.zip . -x \"/**/.*\" -x \"__MACOSX\"\n\ncd ..\n\ngsutil cp samples.zip \"$SAMPLES_URL\"\ngsutil acl ch -r -u AllUsers:R \"$SAMPLES_URL\"\n"
  },
  {
    "path": "e2e/workspaces/setOrientation/test-set-orientation-flow.yaml",
    "content": "appId: com.example.maestro.orientation\nenv:\n  orientationLandscapeLeft: LANDSCAPE_LEFT\n  orientationLandscapeRight: LANDSCAPE_RIGHT\n  orientationUpsideDown: UPSIDE_DOWN\n  orientationPortrait: PORTRAIT\ntags:\n  - android\n  - passing\n---\n- launchApp\n- setOrientation: LANDSCAPE_LEFT\n- assertVisible: \"LANDSCAPE_LEFT\"\n- setOrientation: LANDSCAPE_RIGHT\n- assertVisible: \"LANDSCAPE_RIGHT\"\n- setOrientation: UPSIDE_DOWN\n- assertVisible: \"UPSIDE_DOWN\"\n- setOrientation: PORTRAIT\n- assertVisible: \"PORTRAIT\"\n- setOrientation: ${orientationLandscapeLeft}\n- assertVisible: \"LANDSCAPE_LEFT\"\n- setOrientation: ${orientationLandscapeRight}\n- assertVisible: \"LANDSCAPE_RIGHT\"\n- setOrientation: ${orientationUpsideDown}\n- assertVisible: \"UPSIDE_DOWN\"\n- setOrientation: ${orientationPortrait}\n- assertVisible: \"PORTRAIT\""
  },
  {
    "path": "e2e/workspaces/simple_web_view/webview.yaml",
    "content": "appId: com.example.SimpleWebViewApp\ntags:\n  - passing\n  - ios\n---\n- launchApp:\n    clearState: true\n\n- tapOn: Open Login Page\n\n- extendedWaitUntil:\n    visible: Login\n    timeout: 30000\n    label: Wait for Login page to load\n- assertVisible: Sign In\n- assertVisible: Forgot your password?\n"
  },
  {
    "path": "e2e/workspaces/wikipedia/android-advanced-flow.yaml",
    "content": "appId: org.wikipedia\ntags:\n  - android\n  - passing\n  - advanced\n---\n- runFlow: subflows/onboarding-android.yaml\n- tapOn:\n    id: \"org.wikipedia:id/search_container\"\n- tapOn:\n    text: \"Non existent view\"\n    optional: true\n- runScript: scripts/getSearchQuery.js\n- inputText: ${output.result}\n- assertVisible: ${output.result}\n- runFlow: subflows/launch-clearstate-android.yaml\n"
  },
  {
    "path": "e2e/workspaces/wikipedia/android-flow.yaml",
    "content": "appId: org.wikipedia\ntags:\n  - android\n  - passing\n---\n- launchApp\n"
  },
  {
    "path": "e2e/workspaces/wikipedia/ios-advanced-flow.yaml",
    "content": "appId: org.wikimedia.wikipedia\ntags:\n  - ios\n  - passing\n  - advanced\n---\n- runFlow: subflows/onboarding-ios.yaml\n\n- runFlow:\n    when:\n      visible:\n        text: Explore your Wikipedia Year in Review\n    commands:\n      - tapOn: Done\n    label: Dismiss Year In Review popup, if visible\n\n- runFlow:\n    when:\n      visible: \"You have been logged out\"\n    commands:\n      - tapOn:\n          text: \"Continue without logging in\"\n    label: Dismiss the auth modal if visible\n\n- tapOn:\n    text: \"Non existent view\"\n    optional: true\n- tapOn: Search Wikipedia\n- runScript: scripts/getSearchQuery.js\n- inputText: ${output.result}\n- eraseText\n- inputText: qwerty\n- assertVisible: ${output.result}\n- runFlow: subflows/launch-clearstate-ios.yaml\n"
  },
  {
    "path": "e2e/workspaces/wikipedia/ios-flow.yaml",
    "content": "appId: org.wikimedia.wikipedia\ntags:\n  - ios\n  - passing\n---\n- launchApp\n"
  },
  {
    "path": "e2e/workspaces/wikipedia/scripts/getSearchQuery.js",
    "content": "output.result = 'qwerty';\n"
  },
  {
    "path": "e2e/workspaces/wikipedia/subflows/launch-clearstate-android.yaml",
    "content": "appId: org.wikipedia\n---\n- launchApp:\n    clearState: true\n- assertVisible: \"Continue\"\n- assertVisible: \"Skip\""
  },
  {
    "path": "e2e/workspaces/wikipedia/subflows/launch-clearstate-ios.yaml",
    "content": "appId: org.wikimedia.wikipedia\n---\n- launchApp:\n    clearState: true\n- assertVisible: \"Next\"\n- assertVisible: \"Skip\""
  },
  {
    "path": "e2e/workspaces/wikipedia/subflows/onboarding-android.yaml",
    "content": "appId: org.wikipedia\n---\n- launchApp:\n    clearState: true\n- tapOn:\n      text: \"Non existent view\"\n      optional: true\n- tapOn:\n    id: \"org.wikipedia:id/fragment_onboarding_forward_button\"\n- tapOn:\n    id: \"org.wikipedia:id/fragment_onboarding_forward_button\"\n- tapOn:\n    id: \"org.wikipedia:id/fragment_onboarding_forward_button\"\n- tapOn:\n    id: \"org.wikipedia:id/fragment_onboarding_done_button\"\n"
  },
  {
    "path": "e2e/workspaces/wikipedia/subflows/onboarding-ios.yaml",
    "content": "appId: org.wikimedia.wikipedia\n---\n- launchApp:\n    clearState: true\n- repeat:\n    times: 3\n    commands:\n      - swipe:\n          direction: LEFT\n          duration: 400\n      - waitForAnimationToEnd\n- tapOn: Get started\n- tapOn:\n    text: \"Non existent view\"\n    optional: true\n"
  },
  {
    "path": "e2e/workspaces/wikipedia/wikipedia-android-advanced/auth/login.yml",
    "content": "appId: org.wikipedia\n---\n- tapOn: \"More\"\n- tapOn: \"LOG IN.*\"\n- tapOn:\n    id: \".*create_account_login_button\"\n- runScript: \"../scripts/fetchTestUser.js\"\n- tapOn: \"Username\"\n- inputText: \"${output.test_user.username}\"\n- tapOn: \"Password\"\n- inputText: \"No provided\"\n- tapOn: \"LOG IN\"\n- back\n- back\n"
  },
  {
    "path": "e2e/workspaces/wikipedia/wikipedia-android-advanced/auth/signup.yml",
    "content": "appId: org.wikipedia\n---\n- tapOn: \"More\"\n- tapOn: \"LOG IN.*\"\n- runScript: \"../scripts/generateCredentials.js\"\n- tapOn: \"Username\"\n- inputText: \"${output.credentials.username}\"\n- tapOn: \"Password\"\n- inputText: \"${output.credentials.password}\"\n- tapOn: \"Repeat password\"\n- inputText: \"${output.credentials.password}\"\n- tapOn: \"Email.*\"\n- inputText: \"${output.credentials.email}\"\n\n# We won't actually create the account\n- back\n- back\n"
  },
  {
    "path": "e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/copy-paste.yml",
    "content": "appId: org.wikipedia\n---\n- tapOn: \"Explore\"\n- scrollUntilVisible:\n    element: \"Top read\"\n- copyTextFrom:\n    id: \".*view_list_card_item_title\"\n    index: 0\n- tapOn: \"Explore\"\n- tapOn: \"Search Wikipedia\"\n- inputText: \"${maestro.copiedText}\"\n- back\n- back\n"
  },
  {
    "path": "e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/feed.yml",
    "content": "appId: org.wikipedia\n---\n- tapOn: \"Explore\"\n- scrollUntilVisible:\n    element: \"Today on Wikipedia.*\"\n- tapOn: \"Today on Wikipedia.*\"\n- back\n"
  },
  {
    "path": "e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/main.yml",
    "content": "appId: org.wikipedia\n---\n- runFlow: \"search.yml\"\n- runFlow: \"saved.yml\"\n- runFlow: \"feed.yml\"\n- runFlow: \"copy-paste.yml\"\n"
  },
  {
    "path": "e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/saved.yml",
    "content": "appId: org.wikipedia\n---\n- tapOn: \"Saved\"\n- tapOn: \"Default list for your saved articles\"\n- assertVisible: \"Sun\"\n- assertVisible: \"Star at the center of the Solar System\"\n- back\n"
  },
  {
    "path": "e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/search.yml",
    "content": "appId: org.wikipedia\n---\n- tapOn: \"Search Wikipedia\"\n- inputText: \"Sun\"\n- assertVisible: \"Star at the center of the Solar System\"\n- tapOn:\n    id: \".*page_list_item_title\"\n- tapOn:\n    id: \".*page_save\"\n- back\n- back\n- back\n"
  },
  {
    "path": "e2e/workspaces/wikipedia/wikipedia-android-advanced/onboarding/add-language.yml",
    "content": "appId: org.wikipedia\n---\n- tapOn: \"ADD OR EDIT.*\"\n- tapOn: \"ADD LANGUAGE\"\n- tapOn:\n    id: \".*menu_search_language\"\n- inputText: \"Greek\"\n- assertVisible: \"Ελληνικά\"\n- tapOn: \"Ελληνικά\"\n- tapOn: \"Navigate up\"\n"
  },
  {
    "path": "e2e/workspaces/wikipedia/wikipedia-android-advanced/onboarding/main.yml",
    "content": "appId: org.wikipedia\n---\n- runFlow: \"add-language.yml\"\n- runFlow: \"remove-language.yml\"\n- tapOn: \"Continue\"\n- assertVisible: \"New ways to explore\"\n- tapOn: \"Continue\"\n- assertVisible: \"Reading lists with sync\"\n- tapOn: \"Continue\"\n- assertVisible: \"Send anonymous data\"\n- tapOn: \"Get started\"\n"
  },
  {
    "path": "e2e/workspaces/wikipedia/wikipedia-android-advanced/onboarding/remove-language.yml",
    "content": "appId: org.wikipedia\n---\n- tapOn: \"ADD OR EDIT.*\"\n- tapOn: \"More options\"\n- tapOn: \"Remove language\"\n- tapOn:\n    id: \".*wiki_language_checkbox\"\n    index: 1\n- tapOn:\n    id: \".*menu_delete_selected\"\n- tapOn: \"OK\"\n- assertNotVisible: \"Ελληνικά\"\n- tapOn: \"Navigate up\"\n"
  },
  {
    "path": "e2e/workspaces/wikipedia/wikipedia-android-advanced/run-test.yml",
    "content": "appId: org.wikipedia\ntags:\n  - android\n  - passing\n---\n- launchApp:\n    clearState: true\n- runFlow: \"onboarding/main.yml\"\n- runFlow: \"dashboard/main.yml\"\n- runFlow: \"auth/signup.yml\"\n- runFlow: \"auth/login.yml\"\n"
  },
  {
    "path": "e2e/workspaces/wikipedia/wikipedia-android-advanced/scripts/fetchTestUser.js",
    "content": "// Fetches test user from API\nfunction getTestUserFromApi() {\n  const url = `https://jsonplaceholder.typicode.com/users/1`;\n  var response = http.get(url);\n  var data = json(response.body);\n\n  return {\n    username: data.username,\n    email: data.email,\n  };\n}\n\noutput.test_user = getTestUserFromApi();\n"
  },
  {
    "path": "e2e/workspaces/wikipedia/wikipedia-android-advanced/scripts/generateCredentials.js",
    "content": "function username() {\n  var date = new Date().getTime().toString();\n  var username = `test_user_placeholder`.replace(\"placeholder\", date);\n  return username;\n}\n\nfunction email() {\n  var date = new Date().getTime().toString();\n  var email = `test-user-placeholder@test.com`.replace(\"placeholder\", date);\n  return email;\n}\n\nfunction password() {\n  var date = new Date().getTime().toString();\n  var password = `test-user-password-placeholder`.replace(\"placeholder\", date);\n  return password;\n}\n\noutput.credentials = {\n  email: email(),\n  password: password(),\n  username: username(),\n};\n"
  },
  {
    "path": "gradle/libs.versions.toml",
    "content": "# File should be sorted by alphabet for each section\n\n# How to sort with AS:\n# \"Select all in block\" -> \"Edit\" -> \"Sort lines\"\n\n# File should follow the naming convention:\n# https://blog.gradle.org/best-practices-naming-version-catalog-entries\n\n[versions]\nandroidPlugin = \"8.13.2\"\nandroidxEspresso = \"3.6.1\"\nandroidxTestJunit = \"1.2.1\"\nandroidxUiautomator = \"2.3.0\"\napkParser = \"2.6.10\"\nappdirs = \"1.2.1\"\naxml = \"2.1.2\"\ncommons-codec = \"1.17.0\"\ncommons-lang3 = \"3.13.0\" # 3.14.0 causes weird crashes during dexing\ncommons-io = \"2.16.1\"\ndadb = \"1.2.10\"\ndatafaker = \"2.5.3\"\nddPlist = \"1.23\"\ndetekt = \"1.23.8\"\ngoogleFindbugs = \"3.0.2\"\ngoogleGson = \"2.11.0\"\ngoogleProtobuf = \"3.21.9\"\ngoogleProtobufPlugin = \"0.9.4\"\ngoogleTruth = \"1.4.2\"\ngraaljs = \"24.2.0\"\ngrpc = \"1.50.2\"\ngrpcKotlinStub = \"1.4.1\"\nimageComparison = \"4.4.0\"\nhiddenapibypass = \"4.3\"\njackson = \"2.17.1\"\njansi = \"2.4.1\"\njarchivelib = \"1.2.0\"\njcodec = \"0.2.5\"\njunit = \"5.10.2\"\nkotlin = \"2.2.0\"\nkotlinRetry = \"2.0.1\"\nkotlinResult = \"2.0.1\"\nkotlinx-serialization-json = \"1.5.0\"\nktor = \"2.3.6\"\nmicrometerObservation = \"1.13.4\"\nmicrometerCore = \"1.13.4\"\nmockk = \"1.12.0\"\nmordant = \"3.0.2\"\nmozillaRhino = \"1.7.14\"\npicocli = \"4.6.3\"\nposthog = \"1.0.3\"\nselenium = \"4.40.0\"\nselenium-devtools = \"4.40.0\"\nskiko = \"0.8.18\"\nsquareOkhttp = \"4.12.0\"\nsquareOkio = \"3.16.2\"\nsquareMockWebServer = \"4.11.0\"\nwiremock = \"2.35.0\"\nlog4j = \"2.25.3\"\n\ncoroutines = \"1.8.0\"\nkotlinx-html = \"0.8.0\"\nclikt = \"4.2.2\"\ngmsLocation = \"21.3.0\"\nmcpKotlinSdk = \"steviec/kotlin-1.8\"\n\n[libraries]\nkotlinx-coroutines-core = { module = \"org.jetbrains.kotlinx:kotlinx-coroutines-core\", version.ref = \"coroutines\" }\nkotlinx-html = { module = \"org.jetbrains.kotlinx:kotlinx-html\", version.ref = \"kotlinx-html\" }\nkotlinx-serialization-json = { module = \"org.jetbrains.kotlinx:kotlinx-serialization-json\", version.ref = \"kotlinx-serialization-json\" }\nandroidx-espresso-core = { module = \"androidx.test.espresso:espresso-core\", version.ref = \"androidxEspresso\" }\nandroidx-test-junit = { module = \"androidx.test.ext:junit\", version.ref = \"androidxTestJunit\" }\nandroidx-uiautomator = { module = \"androidx.test.uiautomator:uiautomator\", version.ref = \"androidxUiautomator\" }\napk-parser = { module = \"net.dongliu:apk-parser\", version.ref = \"apkParser\" }\nappdirs = { module = \"net.harawata:appdirs\", version.ref = \"appdirs\" }\naxml = { module = \"de.upb.cs.swt:axml\", version.ref = \"axml\" }\ncommons-codec = { module = \"commons-codec:commons-codec\", version.ref = \"commons-codec\" }\ncommons-lang3 = { module = \"org.apache.commons:commons-lang3\", version.ref = \"commons-lang3\" }\ncommons-io = { module = \"commons-io:commons-io\", version.ref = \"commons-io\" }\ndadb = { module = \"dev.mobile:dadb\", version.ref = \"dadb\" }\ndatafaker = { module = \"net.datafaker:datafaker\", version.ref = \"datafaker\" }\ndd-plist = { module = \"com.googlecode.plist:dd-plist\", version.ref = \"ddPlist\" }\ngoogle-findbugs = { module = \"com.google.code.findbugs:jsr305\", version.ref = \"googleFindbugs\" }\ngoogle-gson = { module = \"com.google.code.gson:gson\", version.ref = \"googleGson\" }\ngoogle-protobuf-kotlin = { module = \"com.google.protobuf:protobuf-kotlin\", version.ref = \"googleProtobuf\" }\ngoogle-protobuf-kotlin-lite = { module = \"com.google.protobuf:protobuf-kotlin-lite\", version.ref = \"googleProtobuf\" }\ngoogle-truth = { module = \"com.google.truth:truth\", version.ref = \"googleTruth\" }\ngraaljs = { module = \"org.graalvm.js:js\", version.ref = \"graaljs\" }\ngraaljsEngine = { module = \"org.graalvm.js:js-scriptengine\", version.ref = \"graaljs\" }\ngraaljsLanguage = { module = \"org.graalvm.js:js-language\", version.ref = \"graaljs\" }\ngrpc-kotlin-stub = { module = \"io.grpc:grpc-kotlin-stub\", version.ref = \"grpcKotlinStub\" }\ngrpc-netty = { module = \"io.grpc:grpc-netty\", version.ref = \"grpc\" }\ngrpc-netty-shaded = { module = \"io.grpc:grpc-netty-shaded\", version.ref = \"grpc\" }\ngrpc-okhttp = { module = \"io.grpc:grpc-okhttp\", version.ref = \"grpc\" }\ngrpc-protobuf = { module = \"io.grpc:grpc-protobuf\", version.ref = \"grpc\" }\ngrpc-protobuf-lite = { module = \"io.grpc:grpc-protobuf-lite\", version.ref = \"grpc\" }\ngrpc-stub = { module = \"io.grpc:grpc-stub\", version.ref = \"grpc\" }\nhiddenapibypass = { module = \"org.lsposed.hiddenapibypass:hiddenapibypass\", version.ref = \"hiddenapibypass\" }\nimage-comparison = { module = \"com.github.romankh3:image-comparison\", version.ref = \"imageComparison\" }\njackson-core-databind = { module = \"com.fasterxml.jackson.core:jackson-databind\", version.ref = \"jackson\" }\njackson-dataformat-xml = { module = \"com.fasterxml.jackson.dataformat:jackson-dataformat-xml\", version.ref = \"jackson\" }\njackson-dataformat-yaml = { module = \"com.fasterxml.jackson.dataformat:jackson-dataformat-yaml\", version.ref = \"jackson\" }\njackson-module-kotlin = { module = \"com.fasterxml.jackson.module:jackson-module-kotlin\", version.ref = \"jackson\" }\njackson-datatype-jsr310 = { module = \"com.fasterxml.jackson.datatype:jackson-datatype-jsr310\", version.ref = \"jackson\" }\njansi = { module = \"org.fusesource.jansi:jansi\", version.ref = \"jansi\" }\njarchivelib = { module = \"org.rauschig:jarchivelib\", version.ref = \"jarchivelib\" }\njcodec = { module = \"org.jcodec:jcodec\", version.ref = \"jcodec\" }\njcodec-awt = { module = \"org.jcodec:jcodec-javase\", version.ref = \"jcodec\" }\njunit-jupiter-api = { module = \"org.junit.jupiter:junit-jupiter-api\", version.ref = \"junit\" }\njunit-jupiter-engine = { module = \"org.junit.jupiter:junit-jupiter-engine\", version.ref = \"junit\" }\njunit-jupiter-params = { module = \"org.junit.jupiter:junit-jupiter-params\", version.ref = \"junit\" }\nkotlin-result = { module = \"com.michael-bull.kotlin-result:kotlin-result\", version.ref = \"kotlinResult\" }\nkotlin-retry = { module = \"com.michael-bull.kotlin-retry:kotlin-retry\", version.ref = \"kotlinRetry\" }\nclikt = { module = \"com.github.ajalt.clikt:clikt\", version.ref = \"clikt\" }\nktor-client-cio = { module = \"io.ktor:ktor-client-cio\", version.ref = \"ktor\" }\nktor-client-core = { module = \"io.ktor:ktor-client-core\", version.ref = \"ktor\" }\nktor-serial-gson = { module = \"io.ktor:ktor-serialization-gson\", version.ref = \"ktor\" }\nktor-serial-json = { module = \"io.ktor:ktor-serialization-kotlinx-json\", version.ref = \"ktor\" }\nktor-server-cio = { module = \"io.ktor:ktor-server-cio\", version.ref = \"ktor\" }\nktor-server-content-negotiation = { module = \"io.ktor:ktor-server-content-negotiation\", version.ref = \"ktor\" }\nktor-client-content-negotiation = { module = \"io.ktor:ktor-client-content-negotiation\", version.ref = \"ktor\" }\nktor-server-core = { module = \"io.ktor:ktor-server-core\", version.ref = \"ktor\" }\nktor-server-cors = { module = \"io.ktor:ktor-server-cors\", version.ref = \"ktor\" }\nktor-server-netty = { module = \"io.ktor:ktor-server-netty\", version.ref = \"ktor\" }\nktor-server-status-pages = { module = \"io.ktor:ktor-server-status-pages\", version.ref = \"ktor\" }\nmicrometer-core = { module = \"io.micrometer:micrometer-core\", version.ref = \"micrometerCore\" }\nmicrometer-observation = { module = \"io.micrometer:micrometer-observation\", version.ref = \"micrometerObservation\" }\nmockk = { module = \"io.mockk:mockk\", version.ref = \"mockk\" }\nmordant = { module = \"com.github.ajalt.mordant:mordant\", version.ref = \"mordant\" }\nmozilla-rhino = { module = \"org.mozilla:rhino\", version.ref = \"mozillaRhino\" }\npicocli = { module = \"info.picocli:picocli\", version.ref = \"picocli\" }\npicocli-codegen = { module = \"info.picocli:picocli-codegen\", version.ref = \"picocli\" }\nposthog = { module = \"com.posthog:posthog-server\", version.ref = \"posthog\" }\nselenium = { module = \"org.seleniumhq.selenium:selenium-java\", version.ref = \"selenium\" }\nselenium-devtools = { module = \"org.seleniumhq.selenium:selenium-devtools-v142\", version.ref = \"selenium-devtools\" }\nskiko-macos-arm64 = { module = \"org.jetbrains.skiko:skiko-awt-runtime-macos-arm64\", version.ref = \"skiko\" }\nskiko-macos-x64 = { module = \"org.jetbrains.skiko:skiko-awt-runtime-macos-x64\", version.ref = \"skiko\" }\nskiko-linux-arm64 = { module = \"org.jetbrains.skiko:skiko-awt-runtime-linux-arm64\", version.ref = \"skiko\" }\nskiko-linux-x64 = { module = \"org.jetbrains.skiko:skiko-awt-runtime-linux-x64\", version.ref = \"skiko\" }\nskiko-windows-arm64 = { module = \"org.jetbrains.skiko:skiko-awt-runtime-windows-arm64\", version.ref = \"skiko\" }\nskiko-windows-x64 = { module = \"org.jetbrains.skiko:skiko-awt-runtime-windows-x64\", version.ref = \"skiko\" }\nsquare-okhttp = { module = \"com.squareup.okhttp3:okhttp\", version.ref = \"squareOkhttp\" }\nsquare-okhttp-logs = { module = \"com.squareup.okhttp3:logging-interceptor\", version.ref = \"squareOkhttp\" }\nsquare-okio = { module = \"com.squareup.okio:okio\", version.ref = \"squareOkio\" }\nsquare-okio-jvm = { module = \"com.squareup.okio:okio-jvm\", version.ref = \"squareOkio\" }\nsquare-mock-server = { module = \"com.squareup.okhttp3:mockwebserver\", version.ref = \"squareMockWebServer\" }\nwiremock-jre8 = { module = \"com.github.tomakehurst:wiremock-jre8\", version.ref = \"wiremock\" }\nlogging-sl4j = { module = \"org.apache.logging.log4j:log4j-slf4j2-impl\", version.ref = \"log4j\" }\nlogging-api = { module = \"org.apache.logging.log4j:log4j-api\", version.ref = \"log4j\" }\nlogging-layout-template = { module = \"org.apache.logging.log4j:log4j-layout-template-json\", version.ref = \"log4j\" }\nlog4j-core = { module = \"org.apache.logging.log4j:log4j-core\", version.ref = \"log4j\" }\ngmsLocation = { module = \"com.google.android.gms:play-services-location\", version.ref = \"gmsLocation\" }\nmcp-kotlin-sdk = { module = \"io.modelcontextprotocol:kotlin-sdk\" }\n\n[bundles]\n\n[plugins]\nandroid-application = { id = \"com.android.application\", version.ref = \"androidPlugin\" }\ndetekt = { id = \"io.gitlab.arturbosch.detekt\", version.ref = \"detekt\" }\nprotobuf = { id = \"com.google.protobuf\", version.ref = \"googleProtobufPlugin\" }\nkotlin-jvm = { id = \"org.jetbrains.kotlin.jvm\", version.ref = \"kotlin\" }\nkotlin-android = { id = \"org.jetbrains.kotlin.android\", version.ref = \"kotlin\" }\nkotlin-serialization = { id = \"org.jetbrains.kotlin.plugin.serialization\", version.ref = \"kotlin\" }\nmavenPublish = { id = \"com.vanniktech.maven.publish\", version = \"0.33.0\" }\njreleaser = { id = \"org.jreleaser\", version = \"1.13.1\" }\nshadow = { id = \"com.github.johnrengelman.shadow\", version = \"7.1.2\" }\n"
  },
  {
    "path": "gradle/wrapper/gradle-wrapper.properties",
    "content": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-8.13-bin.zip\nnetworkTimeout=10000\nvalidateDistributionUrl=true\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\n"
  },
  {
    "path": "gradle.properties",
    "content": "android.useAndroidX=true\nandroid.enableJetifier=true\nkotlin.code.style=official\nGROUP=dev.mobile\nVERSION_NAME=2.4.0\nPOM_DESCRIPTION=Maestro is a server-driven platform-agnostic library that allows to drive tests for both iOS and Android using the same implementation through an intuitive API.\nPOM_URL=https://github.com/mobile-dev-inc/maestro\nPOM_SCM_URL=https://github.com/mobile-dev-inc/maestro\nPOM_SCM_CONNECTION=scm:git:git://github.com/mobile-dev-inc/maestro.git\nPOM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/mobile-dev-inc/maestro.git\nPOM_LICENCE_NAME=The Apache Software License, Version 2.0\nPOM_LICENCE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt\nPOM_LICENCE_DIST=repo\nPOM_DEVELOPER_ID=mobile-dev-inc\nPOM_DEVELOPER_NAME=mobile.dev inc.\nSONATYPE_STAGING_PROFILE=dev.mobile\norg.gradle.jvmargs=-Xmx2000m\n"
  },
  {
    "path": "gradlew",
    "content": "#!/bin/sh\n\n#\n# Copyright © 2015-2021 the original authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n# SPDX-License-Identifier: Apache-2.0\n#\n\n##############################################################################\n#\n#   Gradle start up script for POSIX generated by Gradle.\n#\n#   Important for running:\n#\n#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is\n#       noncompliant, but you have some other compliant shell such as ksh or\n#       bash, then to run this script, type that shell name before the whole\n#       command line, like:\n#\n#           ksh Gradle\n#\n#       Busybox and similar reduced shells will NOT work, because this script\n#       requires all of these POSIX shell features:\n#         * functions;\n#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,\n#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;\n#         * compound commands having a testable exit status, especially «case»;\n#         * various built-in commands including «command», «set», and «ulimit».\n#\n#   Important for patching:\n#\n#   (2) This script targets any POSIX shell, so it avoids extensions provided\n#       by Bash, Ksh, etc; in particular arrays are avoided.\n#\n#       The \"traditional\" practice of packing multiple parameters into a\n#       space-separated string is a well documented source of bugs and security\n#       problems, so this is (mostly) avoided, by progressively accumulating\n#       options in \"$@\", and eventually passing that to Java.\n#\n#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,\n#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;\n#       see the in-line comments for details.\n#\n#       There are tweaks for specific operating systems such as AIX, CygWin,\n#       Darwin, MinGW, and NonStop.\n#\n#   (3) This script is generated from the Groovy template\n#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt\n#       within the Gradle project.\n#\n#       You can find Gradle at https://github.com/gradle/gradle/.\n#\n##############################################################################\n\n# Attempt to set APP_HOME\n\n# Resolve links: $0 may be a link\napp_path=$0\n\n# Need this for daisy-chained symlinks.\nwhile\n    APP_HOME=${app_path%\"${app_path##*/}\"}  # leaves a trailing /; empty if no leading path\n    [ -h \"$app_path\" ]\ndo\n    ls=$( ls -ld \"$app_path\" )\n    link=${ls#*' -> '}\n    case $link in             #(\n      /*)   app_path=$link ;; #(\n      *)    app_path=$APP_HOME$link ;;\n    esac\ndone\n\n# This is normally unused\n# shellcheck disable=SC2034\nAPP_BASE_NAME=${0##*/}\n# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)\nAPP_HOME=$( cd -P \"${APP_HOME:-./}\" > /dev/null && printf '%s\\n' \"$PWD\" ) || exit\n\n# Use the maximum available, or set MAX_FD != -1 to use that value.\nMAX_FD=maximum\n\nwarn () {\n    echo \"$*\"\n} >&2\n\ndie () {\n    echo\n    echo \"$*\"\n    echo\n    exit 1\n} >&2\n\n# OS specific support (must be 'true' or 'false').\ncygwin=false\nmsys=false\ndarwin=false\nnonstop=false\ncase \"$( uname )\" in                #(\n  CYGWIN* )         cygwin=true  ;; #(\n  Darwin* )         darwin=true  ;; #(\n  MSYS* | MINGW* )  msys=true    ;; #(\n  NONSTOP* )        nonstop=true ;;\nesac\n\nCLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar\n\n\n# Determine the Java command to use to start the JVM.\nif [ -n \"$JAVA_HOME\" ] ; then\n    if [ -x \"$JAVA_HOME/jre/sh/java\" ] ; then\n        # IBM's JDK on AIX uses strange locations for the executables\n        JAVACMD=$JAVA_HOME/jre/sh/java\n    else\n        JAVACMD=$JAVA_HOME/bin/java\n    fi\n    if [ ! -x \"$JAVACMD\" ] ; then\n        die \"ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nelse\n    JAVACMD=java\n    if ! command -v java >/dev/null 2>&1\n    then\n        die \"ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nfi\n\n# Increase the maximum file descriptors if we can.\nif ! \"$cygwin\" && ! \"$darwin\" && ! \"$nonstop\" ; then\n    case $MAX_FD in #(\n      max*)\n        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.\n        # shellcheck disable=SC2039,SC3045\n        MAX_FD=$( ulimit -H -n ) ||\n            warn \"Could not query maximum file descriptor limit\"\n    esac\n    case $MAX_FD in  #(\n      '' | soft) :;; #(\n      *)\n        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.\n        # shellcheck disable=SC2039,SC3045\n        ulimit -n \"$MAX_FD\" ||\n            warn \"Could not set maximum file descriptor limit to $MAX_FD\"\n    esac\nfi\n\n# Collect all arguments for the java command, stacking in reverse order:\n#   * args from the command line\n#   * the main class name\n#   * -classpath\n#   * -D...appname settings\n#   * --module-path (only if needed)\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.\n\n# For Cygwin or MSYS, switch paths to Windows format before running java\nif \"$cygwin\" || \"$msys\" ; then\n    APP_HOME=$( cygpath --path --mixed \"$APP_HOME\" )\n    CLASSPATH=$( cygpath --path --mixed \"$CLASSPATH\" )\n\n    JAVACMD=$( cygpath --unix \"$JAVACMD\" )\n\n    # Now convert the arguments - kludge to limit ourselves to /bin/sh\n    for arg do\n        if\n            case $arg in                                #(\n              -*)   false ;;                            # don't mess with options #(\n              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath\n                    [ -e \"$t\" ] ;;                      #(\n              *)    false ;;\n            esac\n        then\n            arg=$( cygpath --path --ignore --mixed \"$arg\" )\n        fi\n        # Roll the args list around exactly as many times as the number of\n        # args, so each arg winds up back in the position where it started, but\n        # possibly modified.\n        #\n        # NB: a `for` loop captures its iteration list before it begins, so\n        # changing the positional parameters here affects neither the number of\n        # iterations, nor the values presented in `arg`.\n        shift                   # remove old arg\n        set -- \"$@\" \"$arg\"      # push replacement arg\n    done\nfi\n\n\n# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\nDEFAULT_JVM_OPTS='\"-Xmx64m\" \"-Xms64m\"'\n\n# Collect all arguments for the java command:\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,\n#     and any embedded shellness will be escaped.\n#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be\n#     treated as '${Hostname}' itself on the command line.\n\nset -- \\\n        \"-Dorg.gradle.appname=$APP_BASE_NAME\" \\\n        -classpath \"$CLASSPATH\" \\\n        org.gradle.wrapper.GradleWrapperMain \\\n        \"$@\"\n\n# Stop when \"xargs\" is not available.\nif ! command -v xargs >/dev/null 2>&1\nthen\n    die \"xargs is not available\"\nfi\n\n# Use \"xargs\" to parse quoted args.\n#\n# With -n1 it outputs one arg per line, with the quotes and backslashes removed.\n#\n# In Bash we could simply go:\n#\n#   readarray ARGS < <( xargs -n1 <<<\"$var\" ) &&\n#   set -- \"${ARGS[@]}\" \"$@\"\n#\n# but POSIX shell has neither arrays nor command substitution, so instead we\n# post-process each arg (as a line of input to sed) to backslash-escape any\n# character that might be a shell metacharacter, then use eval to reverse\n# that process (while maintaining the separation between arguments), and wrap\n# the whole thing up as a single \"set\" statement.\n#\n# This will of course break if any of these variables contains a newline or\n# an unmatched quote.\n#\n\neval \"set -- $(\n        printf '%s\\n' \"$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS\" |\n        xargs -n1 |\n        sed ' s~[^-[:alnum:]+,./:=@_]~\\\\&~g; ' |\n        tr '\\n' ' '\n    )\" '\"$@\"'\n\nexec \"$JAVACMD\" \"$@\"\n"
  },
  {
    "path": "gradlew.bat",
    "content": "@rem\r\n@rem Copyright 2015 the original author or authors.\r\n@rem\r\n@rem Licensed under the Apache License, Version 2.0 (the \"License\");\r\n@rem you may not use this file except in compliance with the License.\r\n@rem You may obtain a copy of the License at\r\n@rem\r\n@rem      https://www.apache.org/licenses/LICENSE-2.0\r\n@rem\r\n@rem Unless required by applicable law or agreed to in writing, software\r\n@rem distributed under the License is distributed on an \"AS IS\" BASIS,\r\n@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r\n@rem See the License for the specific language governing permissions and\r\n@rem limitations under the License.\r\n@rem\r\n@rem SPDX-License-Identifier: Apache-2.0\r\n@rem\r\n\r\n@if \"%DEBUG%\"==\"\" @echo off\r\n@rem ##########################################################################\r\n@rem\r\n@rem  Gradle startup script for Windows\r\n@rem\r\n@rem ##########################################################################\r\n\r\n@rem Set local scope for the variables with windows NT shell\r\nif \"%OS%\"==\"Windows_NT\" setlocal\r\n\r\nset DIRNAME=%~dp0\r\nif \"%DIRNAME%\"==\"\" set DIRNAME=.\r\n@rem This is normally unused\r\nset APP_BASE_NAME=%~n0\r\nset APP_HOME=%DIRNAME%\r\n\r\n@rem Resolve any \".\" and \"..\" in APP_HOME to make it shorter.\r\nfor %%i in (\"%APP_HOME%\") do set APP_HOME=%%~fi\r\n\r\n@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\r\nset DEFAULT_JVM_OPTS=\"-Xmx64m\" \"-Xms64m\"\r\n\r\n@rem Find java.exe\r\nif defined JAVA_HOME goto findJavaFromJavaHome\r\n\r\nset JAVA_EXE=java.exe\r\n%JAVA_EXE% -version >NUL 2>&1\r\nif %ERRORLEVEL% equ 0 goto execute\r\n\r\necho. 1>&2\r\necho ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2\r\necho. 1>&2\r\necho Please set the JAVA_HOME variable in your environment to match the 1>&2\r\necho location of your Java installation. 1>&2\r\n\r\ngoto fail\r\n\r\n:findJavaFromJavaHome\r\nset JAVA_HOME=%JAVA_HOME:\"=%\r\nset JAVA_EXE=%JAVA_HOME%/bin/java.exe\r\n\r\nif exist \"%JAVA_EXE%\" goto execute\r\n\r\necho. 1>&2\r\necho ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2\r\necho. 1>&2\r\necho Please set the JAVA_HOME variable in your environment to match the 1>&2\r\necho location of your Java installation. 1>&2\r\n\r\ngoto fail\r\n\r\n:execute\r\n@rem Setup the command line\r\n\r\nset CLASSPATH=%APP_HOME%\\gradle\\wrapper\\gradle-wrapper.jar\r\n\r\n\r\n@rem Execute Gradle\r\n\"%JAVA_EXE%\" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% \"-Dorg.gradle.appname=%APP_BASE_NAME%\" -classpath \"%CLASSPATH%\" org.gradle.wrapper.GradleWrapperMain %*\r\n\r\n:end\r\n@rem End local scope for the variables with windows NT shell\r\nif %ERRORLEVEL% equ 0 goto mainEnd\r\n\r\n:fail\r\nrem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of\r\nrem the _cmd.exe /c_ return code!\r\nset EXIT_CODE=%ERRORLEVEL%\r\nif %EXIT_CODE% equ 0 set EXIT_CODE=1\r\nif not \"\"==\"%GRADLE_EXIT_CONSOLE%\" exit %EXIT_CODE%\r\nexit /b %EXIT_CODE%\r\n\r\n:mainEnd\r\nif \"%OS%\"==\"Windows_NT\" endlocal\r\n\r\n:omega\r\n"
  },
  {
    "path": "installLocally.sh",
    "content": "#!/bin/sh\n\n./gradlew :maestro-cli:installDist\n\nrm -rf ~/.maestro/bin\nrm -rf ~/.maestro/lib\n\ncp -r ./maestro-cli/build/install/maestro/bin ~/.maestro/bin\ncp -r ./maestro-cli/build/install/maestro/lib ~/.maestro/lib"
  },
  {
    "path": "maestro",
    "content": "#!/usr/bin/env bash\n\nset -e\n\nif [ -t 0 ]; then\n  input=\"\"\nelse\n  input=$(cat -)\nfi\n\n./gradlew :maestro-cli:installDist -q && ./maestro-cli/build/install/maestro/bin/maestro \"$@\"\n"
  },
  {
    "path": "maestro-ai/README.md",
    "content": "# maestro-ai\n\nThis project implements AI support for use in Maestro.\n\nIt's both a library and an executable demo app.\n\n## Demo app\n\nAn API key is required. Set it with `MAESTRO_CLI_AI_KEY` env var. Examples:\n\n- OpenAI: `export MAESTRO_CLI_AI_KEY=sk-...`\n- Anthropic: `export MAESTRO_CLI_AI_KEY=sk-ant-api-...`\n\n### Build\n\n```console\n./gradlew :maestro-ai:installDist\n```\n\nThe startup script will be generated in `./maestro-ai/build/install/maestro-ai-demo/bin/maestro-ai-demo`.\n\n### How to use\n\nFirst of all, try out the `--help` flag.\n\nRun test for a single screenshot that contains defects (i.e. is bad):\n\n```console\nmaestro-ai-demo foo_1_bad.png\n```\n\nRun tests for all screenshots from the Uber that contain defects (i.e. are bad). Additionally, show prompts and raw\nLLM response:\n\n```console\nmaestro-ai-demo \\\n  --model gpt-4o-2024-08-06 \\\n  --show-prompts \\\n  --show-raw-response \\\n  test-ai-fixtures/uber_*_bad.png\n```\n"
  },
  {
    "path": "maestro-ai/build.gradle.kts",
    "content": "import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask\n\nplugins {\n    application\n    id(\"maven-publish\")\n    alias(libs.plugins.kotlin.jvm)\n    alias(libs.plugins.kotlin.serialization)\n    alias(libs.plugins.mavenPublish)\n}\n\napplication {\n    applicationName = \"maestro-ai-demo\"\n    mainClass.set(\"maestro.ai.DemoAppKt\")\n}\n\nmavenPublishing {\n    publishToMavenCentral(true)\n    signAllPublications()\n}\n\ntasks.named<Jar>(\"jar\") {\n    manifest {\n        attributes[\"Main-Class\"] = \"maestro.ai.DemoAppKt\"\n    }\n}\n\ndependencies {\n    api(libs.kotlin.result)\n    api(libs.square.okio)\n    api(libs.square.okio.jvm)\n    api(libs.square.okhttp)\n\n    api(libs.logging.sl4j)\n    api(libs.logging.api)\n    api(libs.logging.layout.template)\n    api(libs.log4j.core)\n\n\n    api(libs.ktor.client.core)\n    implementation(libs.ktor.client.cio)\n    implementation(libs.ktor.serial.json)\n    implementation(libs.ktor.client.content.negotiation)\n    implementation(libs.kotlinx.coroutines.core)\n    implementation(libs.clikt)\n\n    testImplementation(libs.junit.jupiter.api)\n    testRuntimeOnly(libs.junit.jupiter.engine)\n    testImplementation(libs.google.truth)\n    testImplementation(libs.square.mock.server)\n    testImplementation(libs.junit.jupiter.params)\n}\n\njava {\n    sourceCompatibility = JavaVersion.VERSION_17\n    targetCompatibility = JavaVersion.VERSION_17\n}\n\nkotlin {\n    jvmToolchain {\n        languageVersion.set(JavaLanguageVersion.of(17))\n    }\n}\n\ntasks.named(\"compileKotlin\", KotlinCompilationTask::class.java) {\n    compilerOptions {\n        freeCompilerArgs.addAll(\"-Xjdk-release=17\")\n    }\n}\n"
  },
  {
    "path": "maestro-ai/gradle.properties",
    "content": "POM_NAME=Maestro AI\nPOM_ARTIFACT_ID=maestro-ai\nPOM_PACKAGING=jar\n"
  },
  {
    "path": "maestro-ai/src/main/java/maestro/ai/AI.kt",
    "content": "package maestro.ai\n\nimport io.ktor.client.HttpClient\nimport io.ktor.client.plugins.HttpTimeout\nimport io.ktor.client.plugins.contentnegotiation.ContentNegotiation\nimport kotlinx.serialization.json.Json\nimport kotlinx.serialization.json.JsonObject\nimport java.io.Closeable\n\ndata class CompletionData(\n    val prompt: String,\n    val model: String,\n    val temperature: Float,\n    val maxTokens: Int,\n    val images: List<String>,\n    val response: String,\n)\n\nabstract class AI(\n    val defaultModel: String,\n    protected val httpClient: HttpClient,\n) : Closeable {\n\n    /**\n     * Chat completion with the AI model.\n     *\n     * Caveats:\n     *  - `jsonSchema` is only supported by OpenAI (\"Structured Outputs\" feature)\n     */\n    abstract suspend fun chatCompletion(\n        prompt: String,\n        images: List<ByteArray> = listOf(),\n        temperature: Float? = null,\n        model: String? = null,\n        maxTokens: Int? = null,\n        imageDetail: String? = null,\n        identifier: String? = null,\n        jsonSchema: JsonObject? = null,\n    ): CompletionData\n\n    companion object {\n        const val AI_KEY_ENV_VAR = \"MAESTRO_CLI_AI_KEY\"\n        const val AI_MODEL_ENV_VAR = \"MAESTRO_CLI_AI_MODEL\"\n\n        val defaultHttpClient = HttpClient {\n            install(ContentNegotiation) {\n                Json {\n                    ignoreUnknownKeys = true\n                }\n            }\n\n            install(HttpTimeout) {\n                connectTimeoutMillis = 10000\n                socketTimeoutMillis = 60000\n                requestTimeoutMillis = 60000\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-ai/src/main/java/maestro/ai/CloudPredictionAIEngine.kt",
    "content": "package maestro.ai\n\nimport maestro.ai.cloud.Defect\nimport maestro.ai.Prediction\n\nclass CloudAIPredictionEngine(private val apiKey: String) : AIPredictionEngine {\n    override suspend fun findDefects(screen: ByteArray): List<Defect> {\n        return Prediction.findDefects(apiKey, screen)\n    }\n\n    override suspend fun performAssertion(screen: ByteArray, assertion: String): Defect? {\n        return Prediction.performAssertion(apiKey, screen, assertion)\n    }\n\n    override suspend fun extractText(screen: ByteArray, query: String): String {\n        return Prediction.extractText(apiKey, query, screen)\n    }\n}\n"
  },
  {
    "path": "maestro-ai/src/main/java/maestro/ai/DemoApp.kt",
    "content": "package maestro.ai\n\nimport com.github.ajalt.clikt.core.CliktCommand\nimport com.github.ajalt.clikt.parameters.arguments.argument\nimport com.github.ajalt.clikt.parameters.arguments.multiple\nimport com.github.ajalt.clikt.parameters.options.default\nimport com.github.ajalt.clikt.parameters.options.flag\nimport com.github.ajalt.clikt.parameters.options.option\nimport com.github.ajalt.clikt.parameters.types.float\nimport com.github.ajalt.clikt.parameters.types.path\nimport kotlinx.coroutines.async\nimport kotlinx.coroutines.runBlocking\nimport maestro.ai.anthropic.Claude\nimport maestro.ai.cloud.Defect\nimport maestro.ai.openai.OpenAI\nimport java.io.File\nimport java.nio.file.Path\n\n\nfun main(args: Array<String>) = DemoApp().main(args)\n\n/**\n * This is a small helper program to help evaluate LLM results against a directory of screenshots and prompts.\n *\n * ### Input format\n *\n * Screenshot name format:\n * - {app_name}_{screenshot_number}_{good|bad}.png\n *\n * A screenshot can optionally have a prompt. In this case, the model will treat the prompt as the assertion command.\n * To associate a prompt with a screenshot, prompt text file name must have\n * the following format:\n * - {app_name_{screenshot_number}_{good|bad}.txt\n *\n * For example:\n * - foo_1_bad.png\n * - bar_2_good.png\n *\n * ### Output format\n *\n * The output for a single screenshot should indicate either PASS or FAIL, screenshot name, the result, and the defects\n * founds (if any).\n *\n * For example:\n *\n * ```text\n * PASS uber_2_bad.png: 1 defects found (as expected)\n * \t* layout: The prompt for entering a verification code is visible, indicating that the 2-factor authentication process is present. The screen instructs the user to enter a verification code generated for Uber, which is a typical 2-factor authentication step.\n * ```\n *\n * Some of the flags change output format.\n */\nclass DemoApp : CliktCommand() {\n    private val inputFiles: List<Path> by argument(help = \"screenshots to use\").path(mustExist = true).multiple()\n\n    private val model: String by option(help = \"LLM to use\").default(\"gpt-4o\")\n\n    private val showOnlyFails: Boolean by option(help = \"Show only failed tests\").flag()\n\n    private val showPrompts: Boolean by option(help = \"Show prompts\").flag()\n\n    private val showRawResponse: Boolean by option(help = \"Show raw LLM response\").flag()\n\n    private val temperature: Float by option(help = \"Temperature for LLM\").float().default(0.2f)\n\n    private val parallel: Boolean by option(help = \"Run in parallel. May get rate limited\").flag()\n\n    // IDEA: \"--json\" flag to allow for easy filtering with jq\n\n    override fun run() = runBlocking {\n        val apiKey = System.getenv(\"MAESTRO_CLI_AI_KEY\")\n        require(apiKey != null) { \"OpenAI API key is not provided\" }\n\n        val testCases = inputFiles.map { it.toFile() }.map { file ->\n            require(!file.isDirectory) { \"Provided file is a directory, not a file\" }\n            require(file.exists()) { \"Provided file does not exist\" }\n            require(file.extension == \"png\") { \"Provided file is not a PNG file\" }\n            file\n        }.map { file ->\n            val filename = file.nameWithoutExtension\n            val parts = filename.split(\"_\")\n            require(parts.size == 3) { \"Screenshot name is invalid: ${file.name}\" }\n\n            val appName = parts[0]\n            val index =\n                parts[1].toIntOrNull() ?: throw IllegalArgumentException(\"Invalid screenshot name: ${file.name}\")\n            val status = parts[2]\n\n            val promptFile = \"${file.parent}/${appName}_${index}_${status}.txt\"\n            val prompt = File(promptFile).run {\n                if (exists()) {\n                    println(\"Found prompt file: $promptFile\")\n                    readText()\n                } else null\n            }\n\n            TestCase(\n                screenshot = file,\n                appName = appName,\n                shouldPass = status == \"good\",\n                index = index,\n                prompt = prompt,\n            )\n        }.toList()\n\n        val aiClient: AI = when {\n            model.startsWith(\"gpt\") -> OpenAI(\n                apiKey = apiKey,\n                defaultModel = model,\n                defaultTemperature = temperature,\n            )\n\n            model.startsWith(\"claude\") -> Claude(\n                apiKey = apiKey,\n                defaultModel = model,\n                defaultTemperature = temperature,\n            )\n\n            else -> throw IllegalArgumentException(\"Unknown model: $model\")\n        }\n\n        val cloudApiKey = System.getenv(\"MAESTRO_CLOUD_API_KEY\")\n        if (cloudApiKey.isNullOrEmpty()) {\n            throw IllegalArgumentException(\"`MAESTRO_CLOUD_API_KEY` is not available. Did you export MAESTRO_CLOUD_API_KEY?\")\n        }\n\n        testCases.forEach { testCase ->\n            val bytes = testCase.screenshot.readBytes()\n\n            val job = async {\n                val defects = if (testCase.prompt == null) Prediction.findDefects(\n                    apiKey = cloudApiKey,\n                    screen = bytes,\n                ) else {\n                    val result = Prediction.performAssertion(\n                        apiKey = cloudApiKey,\n                        screen = bytes,\n                        assertion = testCase.prompt,\n                    )\n\n                    if (result == null) emptyList()\n                    else listOf(result)\n                }\n\n                verify(testCase, defects)\n            }\n\n            if (!parallel) job.await()\n        }\n    }\n\n    private fun verify(testCase: TestCase, defects: List<Defect>) {\n        if (!testCase.shouldPass) {\n            // Check if LLM found defects (i.e. didn't commit false negative)\n            if (defects.isNotEmpty()) {\n                if (showOnlyFails) return\n\n                println(\n                    \"\"\"\n                    PASS ${testCase.screenshot.name}: ${defects.size} defects found (as expected)\n                    ${defects.joinToString(\"\\n\") { \"\\t* ${it.category}: ${it.reasoning}\" }}\n                    \"\"\".trimIndent()\n                )\n            } else {\n                println(\"FAIL ${testCase.screenshot.name} false-negative: No defects found but some were expected\")\n            }\n\n        } else {\n            // Check that LLM didn't raise false positives\n            if (defects.isEmpty()) {\n                if (showOnlyFails) return\n\n                println(\n                    \"\"\"\n                    PASS ${testCase.screenshot.name}: No defects found (as expected)\n                    \"\"\".trimIndent()\n                )\n            } else {\n                println(\n                    \"\"\"\n                    FAIL ${testCase.screenshot.name} false-positive: ${defects.size} defects found but none were expected\n                    ${defects.joinToString(\"\\n\") { \"\\t* ${it.category}: ${it.reasoning}\" }}\n                    \"\"\".trimIndent()\n                )\n            }\n        }\n    }\n}\n\ndata class TestCase(\n    val screenshot: File,\n    val appName: String,\n    val prompt: String?,\n    val shouldPass: Boolean,\n    val index: Int,\n)\n"
  },
  {
    "path": "maestro-ai/src/main/java/maestro/ai/IAPredictionEngine.kt",
    "content": "package maestro.ai\n\nimport maestro.ai.cloud.Defect\n\ninterface AIPredictionEngine {\n    suspend fun findDefects(screen: ByteArray): List<Defect>\n    suspend fun performAssertion(screen: ByteArray, assertion: String): Defect?\n    suspend fun extractText(screen: ByteArray, query: String): String\n}\n"
  },
  {
    "path": "maestro-ai/src/main/java/maestro/ai/Prediction.kt",
    "content": "package maestro.ai\n\nimport maestro.ai.cloud.ApiClient\nimport maestro.ai.cloud.Defect\n\nobject Prediction {\n    private val apiClient = ApiClient()\n\n    suspend fun findDefects(\n        apiKey: String,\n        screen: ByteArray,\n    ): List<Defect> {\n        val response = apiClient.findDefects(apiKey, screen)\n\n        return response.defects\n    }\n\n    suspend fun performAssertion(\n        apiKey: String,\n        screen: ByteArray,\n        assertion: String,\n    ): Defect? {\n        val response = apiClient.findDefects(apiKey, screen, assertion)\n\n        return response.defects.firstOrNull()\n    }\n\n    suspend fun extractText(\n        apiKey: String,\n        query: String,\n        screen: ByteArray,\n    ): String {\n        val response = apiClient.extractTextWithAi(apiKey, query, screen)\n\n        return response.text\n    }\n}"
  },
  {
    "path": "maestro-ai/src/main/java/maestro/ai/anthropic/Client.kt",
    "content": "package maestro.ai.anthropic\n\nimport Response\nimport io.ktor.client.HttpClient\nimport io.ktor.client.plugins.HttpTimeout\nimport io.ktor.client.plugins.contentnegotiation.ContentNegotiation\nimport io.ktor.client.request.post\nimport io.ktor.client.request.setBody\nimport io.ktor.client.statement.bodyAsText\nimport io.ktor.http.ContentType\nimport io.ktor.http.HttpStatusCode\nimport io.ktor.http.contentType\nimport io.ktor.http.isSuccess\nimport io.ktor.util.encodeBase64\nimport kotlinx.serialization.SerializationException\nimport kotlinx.serialization.encodeToString\nimport kotlinx.serialization.json.Json\nimport kotlinx.serialization.json.JsonObject\nimport maestro.ai.AI\nimport maestro.ai.CompletionData\nimport org.slf4j.LoggerFactory\n\nprivate const val API_URL = \"https://api.anthropic.com/v1/messages\"\n\nprivate val logger = LoggerFactory.getLogger(Claude::class.java)\n\nclass Claude(\n    defaultModel: String = \"claude-3-5-sonnet-20240620\",\n    httpClient: HttpClient = defaultHttpClient,\n    private val apiKey: String,\n    private val defaultTemperature: Float = 0.2f,\n    private val defaultMaxTokens: Int = 1024,\n    private val defaultImageDetail: String = \"high\",\n) : AI(defaultModel = defaultModel, httpClient = httpClient) {\n\n    private val json = Json { ignoreUnknownKeys = true }\n\n    override suspend fun chatCompletion(\n        prompt: String,\n        images: List<ByteArray>,\n        temperature: Float?,\n        model: String?,\n        maxTokens: Int?,\n        imageDetail: String?,\n        identifier: String?,\n        jsonSchema: JsonObject?,\n    ): CompletionData {\n        val imagesBase64 = images.map { it.encodeBase64() }\n\n        // Fallback to Anthropic defaults\n        val actualTemperature = temperature ?: defaultTemperature\n        val actualModel = model ?: defaultModel\n        val actualMaxTokens = maxTokens ?: defaultMaxTokens\n        val actualImageDetail = imageDetail ?: defaultImageDetail\n\n        val imageContents = imagesBase64\n            .map { imageBase64 ->\n                Content(\n                    type = \"image\",\n                    source = ContentSource(\n                        type = \"base64\",\n                        mediaType = \"image/png\",\n                        data = imageBase64,\n                    ),\n                )\n            }\n\n        val textContent = Content(type = \"text\", text = prompt)\n\n        val chatCompletionRequest = Request(\n            model = actualModel,\n            maxTokens = actualMaxTokens,\n            messages = listOf(Message(\"user\", imageContents + textContent)),\n        )\n\n        val response = try {\n            val httpResponse = httpClient.post(API_URL) {\n                contentType(ContentType.Application.Json)\n                headers[\"x-api-key\"] = apiKey\n                headers[\"anthropic-version\"] = \"2023-06-01\"\n                setBody(json.encodeToString(chatCompletionRequest))\n            }\n\n            val body = httpResponse.bodyAsText()\n            if (!httpResponse.status.isSuccess()) {\n                logger.error(\"Failed to complete request to Anthropic: ${httpResponse.status}, $body\")\n                throw Exception(\"Failed to complete request to Anthropic: ${httpResponse.status}, $body\")\n            }\n\n            if (httpResponse.status != HttpStatusCode.OK) {\n                throw IllegalStateException(\"Call to Anthropic AI failed: $body\")\n            }\n\n            json.decodeFromString<Response>(httpResponse.bodyAsText())\n        } catch (e: SerializationException) {\n            logger.error(\"Failed to parse response from Anthropic\", e)\n            throw e\n        } catch (e: Exception) {\n            logger.error(\"Failed to complete request to Anthropic\", e)\n            throw e\n        }\n\n        return CompletionData(\n            prompt = prompt,\n            temperature = actualTemperature,\n            maxTokens = actualMaxTokens,\n            images = imagesBase64,\n            model = actualModel,\n            response = response.content.first().text!!,\n        )\n    }\n\n    override fun close() = httpClient.close()\n}\n"
  },
  {
    "path": "maestro-ai/src/main/java/maestro/ai/anthropic/Common.kt",
    "content": "package maestro.ai.anthropic\n\nimport kotlinx.serialization.SerialName\nimport kotlinx.serialization.Serializable\n\n@Serializable\ndata class Message(\n    val role: String,\n    val content: List<Content>,\n)\n\n@Serializable\ndata class Content(\n    val type: String,\n    val text: String? = null,\n    val source: ContentSource? = null,\n)\n\n@Serializable\ndata class ContentSource(\n    val type: String,\n    @SerialName(\"media_type\") val mediaType: String,\n    val data: String,\n)\n"
  },
  {
    "path": "maestro-ai/src/main/java/maestro/ai/anthropic/Request.kt",
    "content": "package maestro.ai.anthropic\n\nimport kotlinx.serialization.SerialName\nimport kotlinx.serialization.Serializable\n\n@Serializable\ndata class Request(\n    val model: String,\n    @SerialName(\"max_tokens\") val maxTokens: Int,\n    val messages: List<Message>,\n)\n"
  },
  {
    "path": "maestro-ai/src/main/java/maestro/ai/anthropic/Response.kt",
    "content": "import kotlinx.serialization.Serializable\nimport maestro.ai.anthropic.Content\n\n@Serializable\ndata class Response(\n    val content: List<Content>,\n)\n"
  },
  {
    "path": "maestro-ai/src/main/java/maestro/ai/cloud/ApiClient.kt",
    "content": "package maestro.ai.cloud\n\nimport io.ktor.client.*\nimport io.ktor.client.plugins.*\nimport io.ktor.client.plugins.contentnegotiation.*\nimport io.ktor.client.request.*\nimport io.ktor.client.statement.*\nimport io.ktor.http.*\nimport kotlinx.serialization.Serializable\nimport kotlinx.serialization.SerializationException\nimport kotlinx.serialization.encodeToString\nimport kotlinx.serialization.json.Json\nimport maestro.ai.openai.OpenAI\nimport org.slf4j.LoggerFactory\n\nprivate val logger = LoggerFactory.getLogger(OpenAI::class.java)\n\n@Serializable\ndata class Defect(\n    val category: String,\n    val reasoning: String,\n)\n\n@Serializable\ndata class FindDefectsRequest(\n    val assertion: String? = null,\n    val screen: ByteArray,\n)\n\n@Serializable\ndata class FindDefectsResponse(\n    val defects: List<Defect>,\n)\n\n@Serializable\ndata class ExtractTextWithAiRequest(\n    val query: String,\n    val screen: ByteArray,\n)\n\n@Serializable\ndata class ExtractTextWithAiResponse(\n    val text: String,\n)\n\nclass ApiClient {\n    private val baseUrl by lazy {\n        System.getenv(\"MAESTRO_CLOUD_API_URL\") ?: \"https://api.copilot.mobile.dev\"\n    }\n\n    private val json = Json { ignoreUnknownKeys = true }\n\n    val httpClient = HttpClient {\n        install(ContentNegotiation) {\n            Json {\n                ignoreUnknownKeys = true\n            }\n        }\n\n        install(HttpTimeout) {\n            connectTimeoutMillis = 10000\n            socketTimeoutMillis = 60000\n            requestTimeoutMillis = 60000\n        }\n    }\n\n    suspend fun extractTextWithAi(\n        apiKey: String,\n        query: String,\n        screen: ByteArray,\n    ): ExtractTextWithAiResponse {\n        val url = \"$baseUrl/v2/extract-text\"\n\n        val response = try {\n            val httpResponse = httpClient.post(url) {\n                headers {\n                    append(HttpHeaders.Authorization, \"Bearer $apiKey\")\n                    append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) // Explicitly set JSON content type\n                }\n                setBody(json.encodeToString(ExtractTextWithAiRequest(query, screen)))\n            }\n\n            val body = httpResponse.bodyAsText()\n            if (!httpResponse.status.isSuccess()) {\n                logger.error(\"Failed to complete request to Maestro Cloud: ${httpResponse.status}, $body\")\n                throw Exception(\"Failed to complete request to Maestro Cloud: ${httpResponse.status}, $body\")\n            }\n\n            json.decodeFromString<ExtractTextWithAiResponse>(body)\n        } catch (e: SerializationException) {\n            logger.error(\"Failed to parse response from Maestro Cloud\", e)\n            throw e\n        } catch (e: Exception) {\n            logger.error(\"Failed to complete request to Maestro Cloud\", e)\n            throw e\n        }\n\n        return response\n    }\n\n    suspend fun findDefects(\n        apiKey: String,\n        screen: ByteArray,\n        assertion: String? = null,\n    ): FindDefectsResponse {\n        val url = \"$baseUrl/v2/find-defects\"\n\n        val response = try {\n            val httpResponse = httpClient.post(url) {\n                headers {\n                    append(HttpHeaders.Authorization, \"Bearer $apiKey\")\n                    append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) // Explicitly set JSON content type\n                }\n                setBody(json.encodeToString(FindDefectsRequest(assertion = assertion, screen = screen)))\n            }\n\n            val body = httpResponse.bodyAsText()\n            if (!httpResponse.status.isSuccess()) {\n                logger.error(\"Failed to complete request to Maestro Cloud: ${httpResponse.status}, $body\")\n                throw Exception(\"Failed to complete request to Maestro Cloud: ${httpResponse.status}, $body\")\n            }\n\n            json.decodeFromString<FindDefectsResponse>(body)\n        } catch (e: SerializationException) {\n            logger.error(\"Failed to parse response from Maestro Cloud\", e)\n            throw e\n        } catch (e: Exception) {\n            logger.error(\"Failed to complete request to Maestro Cloud\", e)\n            throw e\n        }\n\n        return response\n    }\n\n}"
  },
  {
    "path": "maestro-ai/src/main/java/maestro/ai/common/Image.kt",
    "content": "package maestro.ai.common\n\nimport kotlinx.serialization.Serializable\n\n@Serializable\ndata class Base64Image(\n    val url: String,\n    val detail: String,\n)\n"
  },
  {
    "path": "maestro-ai/src/main/java/maestro/ai/openai/Client.kt",
    "content": "package maestro.ai.openai\n\nimport io.ktor.client.HttpClient\nimport io.ktor.client.request.post\nimport io.ktor.client.request.setBody\nimport io.ktor.client.statement.bodyAsText\nimport io.ktor.http.ContentType\nimport io.ktor.http.contentType\nimport io.ktor.http.isSuccess\nimport io.ktor.util.encodeBase64\nimport kotlinx.serialization.SerializationException\nimport kotlinx.serialization.encodeToString\nimport kotlinx.serialization.json.Json\nimport kotlinx.serialization.json.JsonObject\nimport maestro.ai.AI\nimport maestro.ai.CompletionData\nimport maestro.ai.common.Base64Image\nimport org.slf4j.LoggerFactory\n\nprivate const val API_URL = \"https://api.openai.com/v1/chat/completions\"\n\nprivate val logger = LoggerFactory.getLogger(OpenAI::class.java)\n\nclass OpenAI(\n    defaultModel: String = \"gpt-4o\",\n    httpClient: HttpClient = defaultHttpClient,\n    private val apiKey: String,\n    private val defaultTemperature: Float = 0.2f,\n    private val defaultMaxTokens: Int = 1024,\n    private val defaultImageDetail: String = \"high\",\n) : AI(defaultModel = defaultModel, httpClient = httpClient) {\n\n    private val json = Json { ignoreUnknownKeys = true }\n\n    override suspend fun chatCompletion(\n        prompt: String,\n        images: List<ByteArray>,\n        temperature: Float?,\n        model: String?,\n        maxTokens: Int?,\n        imageDetail: String?,\n        identifier: String?,\n        jsonSchema: JsonObject?,\n    ): CompletionData {\n        val imagesBase64 = images.map { it.encodeBase64() }\n\n        // Fallback to OpenAI defaults\n        val actualTemperature = temperature ?: defaultTemperature\n        val actualModel = model ?: defaultModel\n        val actualMaxTokens = maxTokens ?: defaultMaxTokens\n        val actualImageDetail = imageDetail ?: defaultImageDetail\n\n        val imagesContent = imagesBase64.map { image ->\n            ContentDetail(\n                type = \"image_url\",\n                imageUrl = Base64Image(url = \"data:image/png;base64,$image\", detail = actualImageDetail),\n            )\n        }\n        val textContent = ContentDetail(type = \"text\", text = prompt)\n\n        val messages = listOf(\n            MessageContent(\n                role = \"user\",\n                content = imagesContent + textContent,\n            )\n        )\n\n        val chatCompletionRequest = ChatCompletionRequest(\n            model = actualModel,\n            temperature = actualTemperature,\n            messages = messages,\n            maxTokens = actualMaxTokens,\n            seed = 1566,\n            responseFormat = if (jsonSchema == null) null else ResponseFormat(\n                type = \"json_schema\",\n                jsonSchema = jsonSchema,\n            ),\n        )\n\n        val chatCompletionResponse = try {\n            val httpResponse = httpClient.post(API_URL) {\n                contentType(ContentType.Application.Json)\n                headers[\"Authorization\"] = \"Bearer $apiKey\"\n                setBody(json.encodeToString(chatCompletionRequest))\n            }\n\n            val body = httpResponse.bodyAsText()\n            if (!httpResponse.status.isSuccess()) {\n                logger.error(\"Failed to complete request to OpenAI: ${httpResponse.status}, $body\")\n                throw Exception(\"Failed to complete request to OpenAI: ${httpResponse.status}, $body\")\n            }\n\n            json.decodeFromString<ChatCompletionResponse>(body)\n        } catch (e: SerializationException) {\n            logger.error(\"Failed to parse response from OpenAI\", e)\n            throw e\n        } catch (e: Exception) {\n            logger.error(\"Failed to complete request to OpenAI\", e)\n            throw e\n        }\n\n        return CompletionData(\n            prompt = prompt,\n            temperature = actualTemperature,\n            maxTokens = actualMaxTokens,\n            images = imagesBase64,\n            model = actualModel,\n            response = chatCompletionResponse.choices.first().message.content,\n        )\n    }\n\n    override fun close() = httpClient.close()\n}\n"
  },
  {
    "path": "maestro-ai/src/main/java/maestro/ai/openai/Request.kt",
    "content": "package maestro.ai.openai\n\nimport kotlinx.serialization.SerialName\nimport kotlinx.serialization.Serializable\nimport kotlinx.serialization.json.JsonObject\nimport maestro.ai.common.Base64Image\n\n@Serializable\ndata class ChatCompletionRequest(\n    val model: String,\n    val messages: List<MessageContent>,\n    val temperature: Float,\n    @SerialName(\"max_tokens\") val maxTokens: Int,\n    @SerialName(\"response_format\") val responseFormat: ResponseFormat?,\n    val seed: Int,\n)\n\n@Serializable\nclass ResponseFormat(\n    val type: String,\n    @SerialName(\"json_schema\") val jsonSchema: JsonObject,\n)\n\n@Serializable\ndata class MessageContent(\n    val role: String,\n    val content: List<ContentDetail>,\n)\n\n@Serializable\ndata class ContentDetail(\n    val type: String,\n    val text: String? = null,\n    @SerialName(\"image_url\") val imageUrl: Base64Image? = null,\n)\n"
  },
  {
    "path": "maestro-ai/src/main/java/maestro/ai/openai/Response.kt",
    "content": "package maestro.ai.openai\n\nimport kotlinx.serialization.SerialName\nimport kotlinx.serialization.Serializable\n\n@Serializable\ndata class ChatCompletionResponse(\n    val id: String,\n    val `object`: String,\n    val created: Long,\n    val model: String,\n    @SerialName(\"system_fingerprint\") val systemFingerprint: String? = null,\n    val usage: Usage? = null,\n    val choices: List<Choice>,\n)\n\n@Serializable\ndata class Usage(\n    @SerialName(\"prompt_tokens\") val promptTokens: Int,\n    @SerialName(\"completion_tokens\") val completionTokens: Int? = null,\n    @SerialName(\"total_tokens\") val totalTokens: Int,\n)\n\n@Serializable\ndata class Choice(\n    val message: Message,\n    @SerialName(\"finish_details\") val finishDetails: FinishDetails? = null,\n    val index: Int,\n    @SerialName(\"finish_reason\") val finishReason: String? = null,\n)\n\n@Serializable\ndata class Message(\n    val role: String,\n    val content: String,\n)\n\n@Serializable\ndata class FinishDetails(\n    val type: String,\n    val stop: String? = null,\n)\n"
  },
  {
    "path": "maestro-ai/src/main/resources/askForDefects_schema.json",
    "content": "{\n  \"name\": \"askForDefects\",\n  \"description\": \"Returns a list of possible defects found in the mobile app's UI\",\n  \"strict\": true,\n  \"schema\": {\n    \"type\": \"object\",\n    \"required\": [\"defects\"],\n    \"additionalProperties\": false,\n    \"properties\": {\n      \"defects\": {\n        \"type\": \"array\",\n        \"items\": {\n          \"type\": \"object\",\n          \"required\": [\"category\", \"reasoning\"],\n          \"additionalProperties\": false,\n          \"properties\": {\n            \"category\": {\n              \"type\": \"string\",\n              \"enum\": [\n                \"layout\",\n                \"localization\"\n              ]\n            },\n            \"reasoning\": {\n              \"type\": \"string\"\n            }\n          }\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "maestro-ai/src/main/resources/extractText_schema.json",
    "content": "{\n  \"name\": \"extractText\",\n  \"description\": \"Extracts text from an image based on a given query\",\n  \"strict\": true,\n  \"schema\": {\n    \"type\": \"object\",\n    \"required\": [\n      \"text\"\n    ],\n    \"additionalProperties\": false,\n    \"properties\": {\n      \"text\": {\n        \"type\": \"string\"\n      }\n    }\n  }\n}"
  },
  {
    "path": "maestro-android/build.gradle.kts",
    "content": "import org.jetbrains.kotlin.config.JvmTarget\n\nplugins {\n    alias(libs.plugins.android.application)\n    alias(libs.plugins.kotlin.android)\n    alias(libs.plugins.protobuf)\n}\n\nprotobuf {\n    protoc {\n        artifact = \"com.google.protobuf:protoc:${libs.versions.googleProtobuf.get()}\"\n    }\n\n    plugins {\n        create(\"grpc\") {\n            artifact = \"io.grpc:protoc-gen-grpc-java:${libs.versions.grpc.get()}\"\n        }\n    }\n\n    generateProtoTasks {\n        all().forEach { task ->\n            task.plugins {\n                create(\"grpc\") { option(\"lite\") }\n            }\n\n            task.builtins {\n                create(\"java\") { option(\"lite\") }\n                create(\"kotlin\") { option(\"lite\") }\n            }\n        }\n    }\n}\n\nkotlin.sourceSets.configureEach {\n    // Prevent build warnings for grpc's generated opt-in code\n    languageSettings.optIn(\"kotlin.RequiresOptIn\")\n}\n\nandroid {\n    namespace = \"dev.mobile.maestro\"\n    compileSdk = 34\n\n    defaultConfig {\n        applicationId = \"dev.mobile.maestro\"\n        minSdk = 24\n        targetSdk = 34\n        versionCode = 1\n        versionName = \"1.0\"\n        testInstrumentationRunner = \"androidx.test.runner.AndroidJUnitRunner\"\n    }\n\n    buildTypes {\n        named(\"release\") {\n            isMinifyEnabled = false\n\n            proguardFiles(\n                getDefaultProguardFile(\"proguard-android-optimize.txt\"),\n                \"proguard-rules.pro\",\n            )\n        }\n\n        named(\"debug\") {\n            signingConfig = signingConfigs.getByName(\"debug\")\n        }\n    }\n\n    signingConfigs {\n        named(\"debug\") {\n            storeFile = file(\"../debug.keystore\")\n        }\n    }\n\n    compileOptions {\n        sourceCompatibility = JavaVersion.VERSION_17\n        targetCompatibility = JavaVersion.VERSION_17\n    }\n\n    kotlinOptions {\n        jvmTarget = \"17\"\n    }\n\n    packaging {\n        resources {\n            excludes += listOf(\"META-INF/INDEX.LIST\", \"META-INF/io.netty.versions.properties\")\n        }\n    }\n}\n\ntasks.register<Copy>(\"copyMaestroAndroid\") {\n    val maestroAndroidApkPath = \"outputs/apk/debug/maestro-android-debug.apk\"\n    val maestroAndroidApkDest = \"../../maestro-client/src/main/resources\"\n    val maestroAndroidApkDestPath = \"../../maestro-client/src/main/resources/maestro-android-debug.apk\"\n\n    from(layout.buildDirectory.dir(maestroAndroidApkPath))\n    into(layout.buildDirectory.file(maestroAndroidApkDest))\n\n    doLast {\n        if (!layout.buildDirectory.file(maestroAndroidApkDestPath).get().asFile.exists()) {\n            throw GradleException(\"Error: Input source for copyMaestroAndroid doesn't exist\")\n        }\n\n        File(\"./maestro-client/src/main/resources/maestro-android-debug.apk\").renameTo(File(\"./maestro-client/src/main/resources/maestro-app.apk\"))\n    }\n}\n\ntasks.register<Copy>(\"copyMaestroServer\") {\n    val maestroServerApkPath = \"outputs/apk/androidTest/debug/maestro-android-debug-androidTest.apk\"\n    val maestroServerApkDest = \"../../maestro-client/src/main/resources\"\n    val maestroServerApkDestPath = \"../../maestro-client/src/main/resources/maestro-android-debug-androidTest.apk\"\n\n    from(layout.buildDirectory.dir(maestroServerApkPath))\n    into(layout.buildDirectory.file(maestroServerApkDest))\n\n    doLast {\n        if (!layout.buildDirectory.file(maestroServerApkDestPath).get().asFile.exists()) {\n            throw GradleException(\"Error: Input source for copyMaestroServer doesn't exist\")\n        }\n\n        File(\"./maestro-client/src/main/resources/maestro-android-debug-androidTest.apk\").renameTo(File(\"./maestro-client/src/main/resources/maestro-server.apk\"))\n    }\n}\n\ntasks.named(\"assemble\") {\n    // lint.enabled = false\n    // lintVitalRelease.enabled = false\n    finalizedBy(\"copyMaestroAndroid\")\n}\n\ntasks.named(\"assembleAndroidTest\") {\n    // lint.enabled = false\n    // lintVitalRelease.enabled = false\n    finalizedBy(\"copyMaestroServer\")\n}\n\nsourceSets {\n    create(\"generated\") {\n        java {\n            srcDirs(\n                \"build/generated/source/proto/main/grpc\",\n                \"build/generated/source/proto/main/java\",\n                \"build/generated/source/proto/main/kotlin\",\n            )\n        }\n    }\n}\n\ndependencies {\n    protobuf(project(\":maestro-proto\"))\n\n    implementation(libs.grpc.kotlin.stub)\n    implementation(libs.grpc.netty.shaded)\n    implementation(libs.grpc.stub)\n    implementation(libs.grpc.protobuf.lite)\n    implementation(libs.grpc.okhttp)\n    implementation(libs.google.protobuf.kotlin.lite)\n\n    implementation(libs.ktor.server.core)\n    implementation(libs.ktor.server.cio)\n    implementation(libs.ktor.server.content.negotiation)\n    implementation(libs.ktor.serial.gson)\n\n    implementation(libs.commons.lang3)\n    implementation(libs.hiddenapibypass)\n\n    androidTestImplementation(libs.gmsLocation)\n    implementation(libs.gmsLocation)\n\n    androidTestImplementation(libs.androidx.test.junit)\n    androidTestImplementation(libs.androidx.espresso.core)\n    androidTestImplementation(libs.androidx.uiautomator)\n    androidTestImplementation(libs.kotlin.retry)\n}\n"
  },
  {
    "path": "maestro-android/src/androidTest/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:tools=\"http://schemas.android.com/tools\"\n          xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n    <uses-permission android:name=\"android.permission.ACCESS_MOCK_LOCATION\"\n                     tools:ignore=\"MockLocation,ProtectedPermissions\"/>\n    <uses-permission android:name=\"android.permission.QUERY_ALL_PACKAGES\"\n                     tools:ignore=\"QueryAllPackagesPermission\"/>\n    <uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\" />\n    <uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\" />\n\n\n    <application>\n        <meta-data android:name=\"com.google.android.gms.version\" android:value=\"@integer/google_play_services_version\" />\n    </application>\n</manifest>"
  },
  {
    "path": "maestro-android/src/androidTest/java/androidx/test/uiautomator/UiDeviceExt.kt",
    "content": "package androidx.test.uiautomator\n\nobject UiDeviceExt {\n\n    /**\n     * Fix for a UiDevice.click() method that discards taps that happen outside of the screen bounds.\n     * The issue with the original method is that it was computing screen bounds incorrectly.\n     */\n    fun UiDevice.clickExt(x: Int, y: Int) {\n        interactionController.clickNoSync(\n            x, y\n        )\n    }\n\n}"
  },
  {
    "path": "maestro-android/src/androidTest/java/dev/mobile/maestro/AccessibilityNodeInfoExt.kt",
    "content": "package dev.mobile.maestro\n\nimport android.os.Build\nimport android.view.accessibility.AccessibilityNodeInfo\n\nobject AccessibilityNodeInfoExt {\n\n    /**\n     * Retrieves the hint text associated with this [android.view.accessibility.AccessibilityNodeInfo].\n     *\n     * If the device API level is below 26 (Oreo) or the hint text is null, this function provides a fallback\n     * by returning an empty CharSequence instead.\n     *\n     * @return [CharSequence] representing the hint text or its fallback.\n     */\n    fun AccessibilityNodeInfo.getHintOrFallback(): CharSequence {\n        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && this.hintText != null) {\n            this.hintText\n        } else {\n            \"\"\n        }\n    }\n\n}\n"
  },
  {
    "path": "maestro-android/src/androidTest/java/dev/mobile/maestro/MaestroDriverService.kt",
    "content": "package dev.mobile.maestro\n\nimport android.app.UiAutomation\nimport android.content.Context\nimport android.content.Context.LOCATION_SERVICE\nimport android.location.Criteria\nimport android.location.Location\nimport android.location.LocationManager\nimport android.os.Build\nimport android.os.SystemClock\nimport android.util.DisplayMetrics\nimport android.util.Log\nimport android.view.KeyEvent.KEYCODE_1\nimport android.view.KeyEvent.KEYCODE_4\nimport android.view.KeyEvent.KEYCODE_5\nimport android.view.KeyEvent.KEYCODE_6\nimport android.view.KeyEvent.KEYCODE_7\nimport android.view.KeyEvent.KEYCODE_APOSTROPHE\nimport android.view.KeyEvent.KEYCODE_AT\nimport java.util.concurrent.TimeUnit\nimport android.view.KeyEvent.KEYCODE_BACKSLASH\nimport android.view.KeyEvent.KEYCODE_COMMA\nimport android.view.KeyEvent.KEYCODE_EQUALS\nimport android.view.KeyEvent.KEYCODE_GRAVE\nimport android.view.KeyEvent.KEYCODE_LEFT_BRACKET\nimport android.view.KeyEvent.KEYCODE_MINUS\nimport android.view.KeyEvent.KEYCODE_NUMPAD_ADD\nimport android.view.KeyEvent.KEYCODE_NUMPAD_LEFT_PAREN\nimport android.view.KeyEvent.KEYCODE_NUMPAD_RIGHT_PAREN\nimport android.view.KeyEvent.KEYCODE_PERIOD\nimport android.view.KeyEvent.KEYCODE_POUND\nimport android.view.KeyEvent.KEYCODE_RIGHT_BRACKET\nimport android.view.KeyEvent.KEYCODE_SEMICOLON\nimport android.view.KeyEvent.KEYCODE_SLASH\nimport android.view.KeyEvent.KEYCODE_SPACE\nimport android.view.KeyEvent.KEYCODE_STAR\nimport android.view.KeyEvent.META_SHIFT_LEFT_ON\nimport android.view.WindowManager\nimport androidx.test.ext.junit.runners.AndroidJUnit4\nimport androidx.test.platform.app.InstrumentationRegistry\nimport androidx.test.uiautomator.Configurator\nimport androidx.test.uiautomator.UiDevice\nimport androidx.test.uiautomator.UiDeviceExt.clickExt\nimport com.google.android.gms.location.LocationServices\nimport dev.mobile.maestro.location.FusedLocationProvider\nimport dev.mobile.maestro.location.LocationManagerProvider\nimport dev.mobile.maestro.location.MockLocationProvider\nimport dev.mobile.maestro.location.PlayServices\nimport dev.mobile.maestro.screenshot.ScreenshotException\nimport dev.mobile.maestro.screenshot.ScreenshotService\nimport io.grpc.Metadata\nimport io.grpc.Status\nimport io.grpc.StatusRuntimeException\nimport io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder\nimport io.grpc.stub.StreamObserver\nimport maestro_android.MaestroAndroid\nimport maestro_android.MaestroDriverGrpc\nimport maestro_android.addMediaResponse\nimport maestro_android.checkWindowUpdatingResponse\nimport maestro_android.deviceInfo\nimport maestro_android.emptyResponse\nimport maestro_android.eraseAllTextResponse\nimport maestro_android.inputTextResponse\nimport maestro_android.launchAppResponse\nimport maestro_android.screenshotResponse\nimport maestro_android.setLocationResponse\nimport maestro_android.tapResponse\nimport maestro_android.viewHierarchyResponse\nimport org.junit.Test\nimport org.junit.runner.RunWith\nimport java.io.ByteArrayOutputStream\nimport java.io.OutputStream\nimport java.util.Timer\nimport java.util.TimerTask\nimport kotlin.system.measureTimeMillis\n\n/**\n * Instrumented test, which will execute on an Android device.\n *\n * See [testing documentation](http://d.android.com/tools/testing).\n */\n@RunWith(AndroidJUnit4::class)\nclass MaestroDriverService {\n\n    @Test\n    fun grpcServer() {\n        Configurator.getInstance()\n            .setActionAcknowledgmentTimeout(0L)\n            .setWaitForIdleTimeout(0L)\n            .setWaitForSelectorTimeout(0L)\n\n        val instrumentation = InstrumentationRegistry.getInstrumentation()\n        val uiDevice = UiDevice.getInstance(instrumentation)\n        val uiAutomation = instrumentation.uiAutomation\n\n        val port = InstrumentationRegistry.getArguments().getString(\"port\", \"7001\").toInt()\n\n        println(\"Server running on port [ $port ]\")\n\n        NettyServerBuilder.forPort(port)\n            .addService(Service(uiDevice, uiAutomation))\n            .permitKeepAliveTime(30, TimeUnit.SECONDS) // If a client pings more than once every 30 seconds, terminate the connection\n            .permitKeepAliveWithoutCalls(true) // Allow pings even when there are no active streams.\n            .keepAliveTimeout(20, TimeUnit.SECONDS) // wait 20 seconds for client to ack the keep alive\n            .maxConnectionIdle(30, TimeUnit.MINUTES) // If a client is idle for 30 minutes, send a GOAWAY frame.\n            .build()\n            .start()\n\n        while (!Thread.interrupted()) {\n            Thread.sleep(100)\n        }\n    }\n}\n\nclass Service(\n    private val uiDevice: UiDevice,\n    private val uiAutomation: UiAutomation,\n) : MaestroDriverGrpc.MaestroDriverImplBase() {\n\n    private var locationTimerTask : TimerTask? = null\n    private val locationTimer = Timer()\n\n    private val screenshotService = ScreenshotService()\n    private val mockLocationProviderList = mutableListOf<MockLocationProvider>()\n    private val toastAccessibilityListener = ToastAccessibilityListener.start(uiAutomation)\n\n    companion object {\n        private const val TAG = \"Maestro\"\n        private const val UPDATE_INTERVAL_IN_MILLIS = 2000L\n\n        private val ERROR_TYPE_KEY: Metadata.Key<String> =\n            Metadata.Key.of(\"error-type\", Metadata.ASCII_STRING_MARSHALLER)\n        private val ERROR_MSG_KEY: Metadata.Key<String> =\n            Metadata.Key.of(\"error-message\", Metadata.ASCII_STRING_MARSHALLER)\n        private val ERROR_CAUSE_KEY: Metadata.Key<String> =\n            Metadata.Key.of(\"error-cause\", Metadata.ASCII_STRING_MARSHALLER)\n    }\n\n    override fun launchApp(\n        request: MaestroAndroid.LaunchAppRequest,\n        responseObserver: StreamObserver<MaestroAndroid.LaunchAppResponse>\n    ) {\n        try {\n            val context = InstrumentationRegistry.getInstrumentation().targetContext\n\n            val intent = context.packageManager.getLaunchIntentForPackage(request.packageName)\n\n            if (intent == null) {\n                Log.e(\"Maestro\", \"No launcher intent found for package ${request.packageName}\")\n                responseObserver.onError(RuntimeException(\"No launcher intent found for package ${request.packageName}\"))\n                return\n            }\n\n            request.argumentsList\n                .forEach {\n                    when (it.type) {\n                        String::class.java.name -> intent.putExtra(it.key, it.value)\n                        Boolean::class.java.name -> intent.putExtra(it.key, it.value.toBoolean())\n                        Int::class.java.name -> intent.putExtra(it.key, it.value.toInt())\n                        Double::class.java.name -> intent.putExtra(it.key, it.value.toDouble())\n                        Long::class.java.name -> intent.putExtra(it.key, it.value.toLong())\n                        else -> intent.putExtra(it.key, it.value)\n                    }\n                }\n            context.startActivity(intent)\n\n            responseObserver.onNext(launchAppResponse { })\n            responseObserver.onCompleted()\n        } catch (t: Throwable) {\n            responseObserver.onError(t.internalError())\n        }\n    }\n\n    override fun deviceInfo(\n        request: MaestroAndroid.DeviceInfoRequest,\n        responseObserver: StreamObserver<MaestroAndroid.DeviceInfo>\n    ) {\n        try {\n            val windowManager = InstrumentationRegistry.getInstrumentation()\n                .context\n                .getSystemService(Context.WINDOW_SERVICE) as WindowManager\n\n            val displayMetrics = DisplayMetrics()\n            windowManager.defaultDisplay.getRealMetrics(displayMetrics)\n\n            responseObserver.onNext(\n                deviceInfo {\n                    widthPixels = displayMetrics.widthPixels\n                    heightPixels = displayMetrics.heightPixels\n                }\n            )\n            responseObserver.onCompleted()\n        } catch (t: Throwable) {\n            responseObserver.onError(t.internalError())\n        }\n    }\n\n    override fun viewHierarchy(\n        request: MaestroAndroid.ViewHierarchyRequest,\n        responseObserver: StreamObserver<MaestroAndroid.ViewHierarchyResponse>\n    ) {\n        try {\n            refreshAccessibilityCache()\n            val stream = ByteArrayOutputStream()\n\n            val ms = measureTimeMillis {\n                if (toastAccessibilityListener.getToastAccessibilityNode() != null && !toastAccessibilityListener.isTimedOut()) {\n                    Log.d(\"Maestro\", \"Requesting view hierarchy with toast\")\n                    ViewHierarchy.dump(\n                        uiDevice,\n                        uiAutomation,\n                        stream,\n                        toastAccessibilityListener.getToastAccessibilityNode()\n                    )\n                } else {\n                    Log.d(\"Maestro\", \"Requesting view hierarchy\")\n                    ViewHierarchy.dump(\n                        uiDevice,\n                        uiAutomation,\n                        stream\n                    )\n                }\n            }\n            Log.d(\"Maestro\", \"View hierarchy received in $ms ms\")\n\n            responseObserver.onNext(\n                viewHierarchyResponse {\n                    hierarchy = stream.toString(Charsets.UTF_8.name())\n                }\n            )\n            responseObserver.onCompleted()\n        } catch (t: Throwable) {\n            responseObserver.onError(t.internalError())\n        }\n    }\n\n    /**\n     * Clears the in-process Accessibility cache, removing any stale references. Because the\n     * AccessibilityInteractionClient singleton stores copies of AccessibilityNodeInfo instances,\n     * calls to public APIs such as `recycle` do not guarantee cached references get updated.\n     */\n    private fun refreshAccessibilityCache() {\n        try {\n            uiDevice.waitForIdle(500)\n            uiAutomation.serviceInfo = null\n        } catch (nullExp: NullPointerException) {\n            /* no-op */\n        }\n    }\n\n    override fun tap(\n        request: MaestroAndroid.TapRequest,\n        responseObserver: StreamObserver<MaestroAndroid.TapResponse>\n    ) {\n        try {\n            uiDevice.clickExt(\n                request.x,\n                request.y\n            )\n\n            responseObserver.onNext(tapResponse {})\n            responseObserver.onCompleted()\n        } catch (t: Throwable) {\n            responseObserver.onError(t.internalError())\n        }\n    }\n\n    override fun addMedia(responseObserver: StreamObserver<MaestroAndroid.AddMediaResponse>): StreamObserver<MaestroAndroid.AddMediaRequest> {\n        return object : StreamObserver<MaestroAndroid.AddMediaRequest> {\n\n            var outputStream: OutputStream? = null\n\n            override fun onNext(value: MaestroAndroid.AddMediaRequest) {\n                if (outputStream == null) {\n                    outputStream = MediaStorage.getOutputStream(\n                        value.mediaName,\n                        value.mediaExt\n                    )\n                }\n                value.payload.data.writeTo(outputStream)\n            }\n\n            override fun onError(t: Throwable) {\n                responseObserver.onError(t.internalError())\n            }\n\n            override fun onCompleted() {\n                responseObserver.onNext(addMediaResponse { })\n                responseObserver.onCompleted()\n            }\n\n        }\n    }\n\n    override fun eraseAllText(\n        request: MaestroAndroid.EraseAllTextRequest,\n        responseObserver: StreamObserver<MaestroAndroid.EraseAllTextResponse>\n    ) {\n        try {\n            val charactersToErase = request.charactersToErase\n            Log.d(\"Maestro\", \"Erasing text $charactersToErase\")\n\n            for (i in 1..charactersToErase) {\n                uiDevice.pressDelete()\n            }\n\n            responseObserver.onNext(eraseAllTextResponse { })\n            responseObserver.onCompleted()\n        } catch (t: Throwable) {\n            responseObserver.onError(t.internalError())\n        }\n    }\n\n    override fun inputText(\n        request: MaestroAndroid.InputTextRequest,\n        responseObserver: StreamObserver<MaestroAndroid.InputTextResponse>\n    ) {\n        try {\n            Log.d(\"Maestro\", \"Inputting text\")\n            request.text.forEach {\n                setText(it.toString())\n                Thread.sleep(75)\n            }\n\n            responseObserver.onNext(inputTextResponse { })\n            responseObserver.onCompleted()\n        } catch (e: Throwable) {\n            responseObserver.onError(e.internalError())\n        }\n    }\n\n    override fun screenshot(\n        request: MaestroAndroid.ScreenshotRequest,\n        responseObserver: StreamObserver<MaestroAndroid.ScreenshotResponse>\n    ) {\n        try {\n            val bitmap = screenshotService.takeScreenshotWithRetry { uiAutomation.takeScreenshot() }\n            val bytes = screenshotService.encodePng(bitmap)\n            responseObserver.onNext(screenshotResponse { this.bytes = bytes })\n            responseObserver.onCompleted()\n        } catch (e: NullPointerException) {\n            Log.e(TAG, \"Screenshot failed with NullPointerException: ${e.message}\", e)\n            responseObserver.onError(e.internalError())\n        } catch (e: ScreenshotException) {\n            Log.e(TAG, \"Screenshot failed with ScreenshotException: ${e.message}\", e)\n            responseObserver.onError(e.internalError())\n        } catch (e: Exception) {\n            Log.e(TAG, \"Screenshot failed with: ${e.message}\", e)\n            responseObserver.onError(e.internalError())\n        }\n    }\n\n    override fun isWindowUpdating(\n        request: MaestroAndroid.CheckWindowUpdatingRequest,\n        responseObserver: StreamObserver<MaestroAndroid.CheckWindowUpdatingResponse>\n    ) {\n        try {\n            responseObserver.onNext(checkWindowUpdatingResponse {\n                isWindowUpdating = uiDevice.waitForWindowUpdate(request.appId, 500)\n            })\n            responseObserver.onCompleted()\n        } catch (e: Throwable) {\n            responseObserver.onError(e.internalError())\n        }\n    }\n\n    override fun disableLocationUpdates(\n        request: MaestroAndroid.EmptyRequest,\n        responseObserver: StreamObserver<MaestroAndroid.EmptyResponse>\n    ) {\n        try {\n            Log.d(TAG, \"[Start] Disabling location updates\")\n            locationTimerTask?.cancel()\n            locationTimer.cancel()\n            mockLocationProviderList.forEach {\n                it.disable()\n            }\n            Log.d(TAG, \"[Done] Disabling location updates\")\n            responseObserver.onNext(emptyResponse {  })\n            responseObserver.onCompleted()\n        } catch (exception: Exception) {\n            responseObserver.onError(exception.internalError())\n        }\n    }\n\n    override fun enableMockLocationProviders(\n        request: MaestroAndroid.EmptyRequest,\n        responseObserver: StreamObserver<MaestroAndroid.EmptyResponse>\n    ) {\n        try {\n            Log.d(TAG, \"[Start] Enabling mock location providers\")\n            val context = InstrumentationRegistry.getInstrumentation().targetContext\n            val locationManager = context.getSystemService(LOCATION_SERVICE) as LocationManager\n\n            mockLocationProviderList.addAll(\n                createMockProviders(context, locationManager)\n            )\n\n            mockLocationProviderList.forEach {\n                it.enable()\n            }\n            Log.d(TAG, \"[Done] Enabling mock location providers\")\n\n            responseObserver.onNext(emptyResponse {  })\n            responseObserver.onCompleted()\n        } catch (exception: Exception) {\n            Log.e(TAG, \"Error while enabling mock location provider\", exception)\n            responseObserver.onError(exception.internalError())\n        }\n    }\n\n    private fun createMockProviders(\n        context: Context,\n        locationManager: LocationManager\n    ): List<MockLocationProvider> {\n        val playServices = PlayServices()\n        val fusedLocationProvider: MockLocationProvider? = if (playServices.isAvailable(context)) {\n            val fusedLocationProviderClient =\n                LocationServices.getFusedLocationProviderClient(context)\n            FusedLocationProvider(fusedLocationProviderClient)\n        } else {\n            null\n        }\n        return (locationManager.allProviders.mapNotNull {\n            if (it.equals(LocationManager.PASSIVE_PROVIDER)) {\n                null\n            } else {\n                val mockProvider = createLocationManagerMockProvider(locationManager, it)\n                mockProvider\n            }\n        } + fusedLocationProvider).mapNotNull { it }\n    }\n\n    private fun createLocationManagerMockProvider(\n        locationManager: LocationManager,\n        providerName: String?\n    ): MockLocationProvider? {\n        if (providerName == null) {\n            return null\n        }\n        // API level check for existence of provider properties\n        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {\n            // API level 31 and above\n            val providerProperties =\n                locationManager.getProviderProperties(providerName) ?: return null\n            return LocationManagerProvider(\n                locationManager,\n                providerName,\n                providerProperties.hasNetworkRequirement(),\n                providerProperties.hasSatelliteRequirement(),\n                providerProperties.hasCellRequirement(),\n                providerProperties.hasMonetaryCost(),\n                providerProperties.hasAltitudeSupport(),\n                providerProperties.hasSpeedSupport(),\n                providerProperties.hasBearingSupport(),\n                providerProperties.powerUsage,\n                providerProperties.accuracy\n            )\n        }\n        val provider = locationManager.getProvider(providerName) ?: return null\n        return LocationManagerProvider(\n            locationManager,\n            provider.name,\n            provider.requiresNetwork(),\n            provider.requiresSatellite(),\n            provider.requiresCell(),\n            provider.hasMonetaryCost(),\n            provider.supportsAltitude(),\n            provider.supportsSpeed(),\n            provider.supportsBearing(),\n            provider.powerRequirement,\n            provider.accuracy\n        )\n    }\n\n\n    override fun setLocation(\n        request: MaestroAndroid.SetLocationRequest,\n        responseObserver: StreamObserver<MaestroAndroid.SetLocationResponse>\n    ) {\n        try {\n            if (locationTimerTask != null) {\n                locationTimerTask?.cancel()\n            }\n\n            locationTimerTask = object : TimerTask() {\n                override fun run() {\n                    mockLocationProviderList.forEach {\n                        val latitude = request.latitude\n                        val longitude = request.longitude\n                        Log.d(TAG, \"Setting location latitude: $latitude and longitude: $longitude for ${it.getProviderName()}\")\n                        val location = Location(it.getProviderName()).apply {\n                            setLatitude(latitude)\n                            setLongitude(longitude)\n                            accuracy = Criteria.ACCURACY_FINE.toFloat()\n                            altitude = 0.0\n                            time = System.currentTimeMillis()\n                            elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos()\n                        }\n                        it.setLocation(location)\n                    }\n                }\n            }\n            locationTimer.schedule(\n                locationTimerTask,\n                0,\n                UPDATE_INTERVAL_IN_MILLIS\n            )\n            responseObserver.onNext(setLocationResponse { })\n            responseObserver.onCompleted()\n        } catch (t: Throwable) {\n            responseObserver.onError(t.internalError())\n        }\n    }\n\n    private fun setText(text: String) {\n        for (element in text) {\n            Log.d(\"Maestro\", element.code.toString())\n            when (element.code) {\n                in 48..57 -> {\n                    /** 0~9 **/\n                    uiDevice.pressKeyCode(element.code - 41)\n                }\n\n                in 65..90 -> {\n                    /** A~Z **/\n                    uiDevice.pressKeyCode(element.code - 36, 1)\n                }\n\n                in 97..122 -> {\n                    /** a~z **/\n                    uiDevice.pressKeyCode(element.code - 68)\n                }\n\n                ';'.code -> uiDevice.pressKeyCode(KEYCODE_SEMICOLON)\n                '='.code -> uiDevice.pressKeyCode(KEYCODE_EQUALS)\n                ','.code -> uiDevice.pressKeyCode(KEYCODE_COMMA)\n                '-'.code -> uiDevice.pressKeyCode(KEYCODE_MINUS)\n                '.'.code -> uiDevice.pressKeyCode(KEYCODE_PERIOD)\n                '/'.code -> uiDevice.pressKeyCode(KEYCODE_SLASH)\n                '`'.code -> uiDevice.pressKeyCode(KEYCODE_GRAVE)\n                '\\''.code -> uiDevice.pressKeyCode(KEYCODE_APOSTROPHE)\n                '['.code -> uiDevice.pressKeyCode(KEYCODE_LEFT_BRACKET)\n                ']'.code -> uiDevice.pressKeyCode(KEYCODE_RIGHT_BRACKET)\n                '\\\\'.code -> uiDevice.pressKeyCode(KEYCODE_BACKSLASH)\n                ' '.code -> uiDevice.pressKeyCode(KEYCODE_SPACE)\n                '@'.code -> uiDevice.pressKeyCode(KEYCODE_AT)\n                '#'.code -> uiDevice.pressKeyCode(KEYCODE_POUND)\n                '*'.code -> uiDevice.pressKeyCode(KEYCODE_STAR)\n                '('.code -> uiDevice.pressKeyCode(KEYCODE_NUMPAD_LEFT_PAREN)\n                ')'.code -> uiDevice.pressKeyCode(KEYCODE_NUMPAD_RIGHT_PAREN)\n                '+'.code -> uiDevice.pressKeyCode(KEYCODE_NUMPAD_ADD)\n                '!'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_1)\n                '$'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_4)\n                '%'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_5)\n                '^'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_6)\n                '&'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_7)\n                '\"'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_APOSTROPHE)\n                '{'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_LEFT_BRACKET)\n                '}'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_RIGHT_BRACKET)\n                ':'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_SEMICOLON)\n                '|'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_BACKSLASH)\n                '<'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_COMMA)\n                '>'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_PERIOD)\n                '?'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_SLASH)\n                '~'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_GRAVE)\n                '_'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_MINUS)\n            }\n        }\n    }\n\n    private fun keyPressShiftedToEvents(uiDevice: UiDevice, keyCode: Int) {\n        uiDevice.pressKeyCode(keyCode, META_SHIFT_LEFT_ON)\n    }\n\n    internal fun Throwable.internalError(): StatusRuntimeException {\n        val trailers = Metadata().apply {\n            put(ERROR_TYPE_KEY, this@internalError::class.java.name)\n            this@internalError.message?.let { put(ERROR_MSG_KEY, it) }\n            this@internalError.cause?.let { put(ERROR_CAUSE_KEY, it.toString()) }\n        }\n        return Status.INTERNAL.withDescription(message).asRuntimeException(trailers)\n    }\n\n    enum class FileType(val ext: String, val mimeType: String) {\n        JPG(\"jpg\", \"image/jpg\"),\n        JPEG(\"jpeg\", \"image/jpeg\"),\n        PNG(\"png\", \"image/png\"),\n        GIF(\"gif\", \"image/gif\"),\n        MP4(\"mp4\", \"video/mp4\"),\n    }\n}\n"
  },
  {
    "path": "maestro-android/src/androidTest/java/dev/mobile/maestro/Media.kt",
    "content": "package dev.mobile.maestro\n\nimport android.content.ContentValues\nimport android.provider.MediaStore\nimport androidx.test.platform.app.InstrumentationRegistry\nimport java.io.OutputStream\n\nobject MediaStorage {\n\n    fun getOutputStream(mediaName: String, mediaExt: String): OutputStream? {\n        val uri = when (mediaExt) {\n            Service.FileType.JPG.ext,\n            Service.FileType.PNG.ext,\n            Service.FileType.GIF.ext,\n            Service.FileType.JPEG.ext -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI\n            Service.FileType.MP4.ext -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI\n            else -> throw IllegalStateException(\"mime .$mediaExt not yet supported\")\n        }\n        val ext = Service.FileType.values().first { it.ext == mediaExt }\n        val contentValues = ContentValues()\n        contentValues.apply {\n            put(MediaStore.MediaColumns.DISPLAY_NAME, mediaName)\n            put(MediaStore.MediaColumns.MIME_TYPE, ext.mimeType)\n        }\n        val contentResolver = InstrumentationRegistry.getInstrumentation().targetContext.contentResolver\n        val outputStream = contentResolver.insert(uri, contentValues)?.let {\n            contentResolver.openOutputStream(it)\n        }\n        return outputStream\n    }\n}"
  },
  {
    "path": "maestro-android/src/androidTest/java/dev/mobile/maestro/ToastAccessibilityListener.kt",
    "content": "package dev.mobile.maestro\n\nimport android.app.UiAutomation\nimport android.os.Build\nimport android.util.Log\nimport android.view.accessibility.AccessibilityEvent\nimport android.view.accessibility.AccessibilityNodeInfo\nimport android.widget.Toast\nimport kotlin.reflect.jvm.jvmName\n\nobject ToastAccessibilityListener : UiAutomation.OnAccessibilityEventListener {\n\n    private var toastNode: AccessibilityNodeInfo? = null\n    private var isListening = false\n    private var recentToastTimeMillis: Long = 0\n\n    private const val TOAST_LENGTH_LONG_DURATION = 3500\n\n    override fun onAccessibilityEvent(accessibilityEvent: AccessibilityEvent) {\n        if (\n            accessibilityEvent.eventType == AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED &&\n            accessibilityEvent.className.toString().contains(Toast::class.jvmName)\n        ) {\n            recentToastTimeMillis = System.currentTimeMillis()\n            // Constructor for AccessibilityNodeInfo is only available on Android API 30+\n            val nodeInfo = if (Build.VERSION.SDK_INT < 30) {\n                AccessibilityNodeInfo.obtain()\n            } else {\n                AccessibilityNodeInfo()\n            }\n            toastNode = nodeInfo.apply {\n                text = accessibilityEvent.text.first().toString()\n                className = Toast::class.jvmName\n                isVisibleToUser = true\n                viewIdResourceName = \"\"\n                packageName = \"\"\n                isCheckable = false\n                isChecked = accessibilityEvent.isChecked\n                isClickable = false\n                isEnabled = accessibilityEvent.isEnabled\n                Log.d(\"Maestro\", \"Toast received with $text\")\n            }\n        }\n    }\n\n    fun getToastAccessibilityNode() = toastNode\n\n    fun isTimedOut(): Boolean {\n        return System.currentTimeMillis() - recentToastTimeMillis > TOAST_LENGTH_LONG_DURATION\n    }\n\n    fun start(uiAutomation: UiAutomation): ToastAccessibilityListener {\n        if (isListening) return this\n        uiAutomation.setOnAccessibilityEventListener(this)\n        isListening = true\n        Log.d(\"Maestro\", \"Started listening to accessibility events\")\n        return this\n    }\n\n    fun stop() {\n        isListening = false\n        Log.d(\"Maestro\", \"Stopped listening to accessibility events\")\n    }\n}"
  },
  {
    "path": "maestro-android/src/androidTest/java/dev/mobile/maestro/ViewHierarchy.kt",
    "content": "package dev.mobile.maestro\n\nimport android.app.UiAutomation\nimport android.content.Context\nimport android.graphics.Rect\nimport android.os.Build\nimport android.util.DisplayMetrics\nimport android.util.Log\nimport android.util.Xml\nimport android.view.WindowManager\nimport android.view.accessibility.AccessibilityNodeInfo\nimport android.widget.GridLayout\nimport android.widget.GridView\nimport android.widget.ListView\nimport android.widget.TableLayout\nimport androidx.test.platform.app.InstrumentationRegistry\nimport androidx.test.uiautomator.UiDevice\nimport dev.mobile.maestro.AccessibilityNodeInfoExt.getHintOrFallback\nimport org.xmlpull.v1.XmlSerializer\nimport java.io.IOException\nimport java.io.OutputStream\n\n// Logic largely copied from AccessibilityNodeInfoDumper with some modifications\nobject ViewHierarchy {\n\n    private const val LOGTAG = \"Maestro\"\n\n    fun dump(\n        device: UiDevice,\n        uiAutomation: UiAutomation,\n        out: OutputStream,\n        toastNode: AccessibilityNodeInfo? = null\n    ) {\n        val windowManager = InstrumentationRegistry.getInstrumentation()\n            .context\n            .getSystemService(Context.WINDOW_SERVICE) as WindowManager\n\n        val displayRect = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {\n            windowManager.currentWindowMetrics.bounds\n        } else {\n            val displayMetrics = DisplayMetrics()\n            windowManager.defaultDisplay.getRealMetrics(displayMetrics)\n            Rect(0, 0, displayMetrics.widthPixels, displayMetrics.heightPixels)\n        }\n\n\n        val serializer = Xml.newSerializer()\n        serializer.setFeature(\"http://xmlpull.org/v1/doc/features.html#indent-output\", true)\n        serializer.setOutput(out, \"UTF-8\")\n        serializer.startDocument(\"UTF-8\", true)\n        serializer.startTag(\"\", \"hierarchy\")\n        serializer.attribute(\"\", \"rotation\", Integer.toString(device.displayRotation))\n\n        val roots = try {\n            device.javaClass\n                .getDeclaredMethod(\"getWindowRoots\")\n                .apply {\n                    isAccessible = true\n                }\n                .let {\n                    @Suppress(\"UNCHECKED_CAST\")\n                    it.invoke(device) as Array<AccessibilityNodeInfo>\n                }\n                .toList()\n        } catch (e: Exception) {\n            // Falling back to a public method if reflection fails\n            Log.e(LOGTAG, \"Unable to call getWindowRoots\", e)\n            listOf(uiAutomation.rootInActiveWindow)\n        }\n\n        roots.forEach {\n            dumpNodeRec(\n                it,\n                serializer,\n                0,\n                displayRect\n            )\n        }\n        addToastNode(toastNode, serializer, displayRect)\n\n        serializer.endTag(\"\", \"hierarchy\")\n        serializer.endDocument()\n    }\n\n    private fun addToastNode(\n        toastNode: AccessibilityNodeInfo?,\n        serializer: XmlSerializer,\n        displayRect: Rect\n    ) {\n        if (toastNode != null) {\n            serializer.apply {\n                startTag(\"\", \"node\")\n                attribute(\"\", \"index\", \"0\")\n                attribute(\"\", \"class\", toastNode.className.toString())\n                attribute(\"\", \"text\", toastNode.text.toString())\n                attribute(\"\", \"visible-to-user\", toastNode.isVisibleToUser.toString())\n                attribute(\"\", \"checkable\", toastNode.isCheckable.toString())\n                attribute(\"\", \"clickable\", toastNode.isClickable.toString())\n                attribute(\"\", \"bounds\", getVisibleBoundsInScreen(toastNode, displayRect)?.toShortString())\n                endTag(\"\", \"node\")\n            }\n        }\n    }\n\n    private val NAF_EXCLUDED_CLASSES = arrayOf(\n        GridView::class.java.name, GridLayout::class.java.name,\n        ListView::class.java.name, TableLayout::class.java.name\n    )\n\n    @Suppress(\"LongParameterList\")\n    @Throws(IOException::class)\n    private fun dumpNodeRec(\n        node: AccessibilityNodeInfo,\n        serializer: XmlSerializer,\n        index: Int,\n        displayRect: Rect,\n        insideWebView: Boolean = false,\n    ) {\n        serializer.startTag(\"\", \"node\")\n        if (!nafExcludedClass(node) && !nafCheck(node)) {\n            serializer.attribute(\"\", \"NAF\", java.lang.Boolean.toString(true))\n        }\n\n        serializer.attribute(\"\", \"index\", Integer.toString(index))\n        serializer.attribute(\"\", \"hintText\", safeCharSeqToString(node.getHintOrFallback()))\n        serializer.attribute(\"\", \"text\", safeCharSeqToString(node.text))\n        serializer.attribute(\"\", \"resource-id\", safeCharSeqToString(node.viewIdResourceName))\n        serializer.attribute(\"\", \"class\", safeCharSeqToString(node.className))\n        serializer.attribute(\"\", \"package\", safeCharSeqToString(node.packageName))\n        serializer.attribute(\"\", \"content-desc\", safeCharSeqToString(node.contentDescription))\n        serializer.attribute(\"\", \"checkable\", java.lang.Boolean.toString(node.isCheckable))\n        serializer.attribute(\"\", \"checked\", java.lang.Boolean.toString(node.isChecked))\n        serializer.attribute(\"\", \"clickable\", java.lang.Boolean.toString(node.isClickable))\n        serializer.attribute(\"\", \"enabled\", java.lang.Boolean.toString(node.isEnabled))\n        serializer.attribute(\"\", \"focusable\", java.lang.Boolean.toString(node.isFocusable))\n        serializer.attribute(\"\", \"focused\", java.lang.Boolean.toString(node.isFocused))\n        serializer.attribute(\"\", \"scrollable\", java.lang.Boolean.toString(node.isScrollable))\n        serializer.attribute(\"\", \"long-clickable\", java.lang.Boolean.toString(node.isLongClickable))\n        serializer.attribute(\"\", \"password\", java.lang.Boolean.toString(node.isPassword))\n        serializer.attribute(\"\", \"selected\", java.lang.Boolean.toString(node.isSelected))\n        serializer.attribute(\"\", \"visible-to-user\", java.lang.Boolean.toString(node.isVisibleToUser))\n        serializer.attribute(\"\", \"important-for-accessibility\", java.lang.Boolean.toString(node.isImportantForAccessibility))\n        serializer.attribute(\"\", \"error\", safeCharSeqToString(node.error))\n        serializer.attribute(\n            \"\", \"bounds\", getVisibleBoundsInScreen(node, displayRect)?.toShortString()\n        )\n        val count = node.childCount\n        for (i in 0 until count) {\n            val child = node.getChild(i)\n            if (child != null) {\n                // This condition is different from the original.\n                // Original implementation has a bug where contents of a WebView sometimes reported as invisible.\n                // This is a workaround for that bug.\n                if (child.isVisibleToUser || insideWebView) {\n                    dumpNodeRec(\n                        child,\n                        serializer, i,\n                        displayRect,\n                        insideWebView || child.className == \"android.webkit.WebView\"\n                    )\n                    child.recycle()\n                } else {\n                    Log.i(LOGTAG, \"Skipping invisible child: $child\")\n                }\n            } else {\n                Log.i(LOGTAG, \"Null child $i/$count, parent: $node\")\n            }\n        }\n        serializer.endTag(\"\", \"node\")\n    }\n\n    /**\n     * The list of classes to exclude my not be complete. We're attempting to\n     * only reduce noise from standard layout classes that may be falsely\n     * configured to accept clicks and are also enabled.\n     *\n     * @param node\n     * @return true if node is excluded.\n     */\n    private fun nafExcludedClass(node: AccessibilityNodeInfo): Boolean {\n        val className = safeCharSeqToString(node.className)\n        for (excludedClassName in NAF_EXCLUDED_CLASSES) {\n            if (className.endsWith(excludedClassName)) return true\n        }\n        return false\n    }\n\n    /**\n     * We're looking for UI controls that are enabled, clickable but have no\n     * text nor content-description. Such controls configuration indicate an\n     * interactive control is present in the UI and is most likely not\n     * accessibility friendly. We refer to such controls here as NAF controls\n     * (Not Accessibility Friendly)\n     *\n     * @param node\n     * @return false if a node fails the check, true if all is OK\n     */\n    private fun nafCheck(node: AccessibilityNodeInfo): Boolean {\n        val isNaf = (node.isClickable && node.isEnabled\n            && safeCharSeqToString(node.contentDescription).isEmpty()\n            && safeCharSeqToString(node.text).isEmpty())\n        return if (!isNaf) true else childNafCheck(node)\n\n        // check children since sometimes the containing element is clickable\n        // and NAF but a child's text or description is available. Will assume\n        // such layout as fine.\n    }\n\n    /**\n     * This should be used when it's already determined that the node is NAF and\n     * a further check of its children is in order. A node maybe a container\n     * such as LinearLayout and may be set to be clickable but have no text or\n     * content description but it is counting on one of its children to fulfill\n     * the requirement for being accessibility friendly by having one or more of\n     * its children fill the text or content-description. Such a combination is\n     * considered by this dumper as acceptable for accessibility.\n     *\n     * @param node\n     * @return false if node fails the check.\n     */\n    @Suppress(\"ReturnCount\")\n    private fun childNafCheck(node: AccessibilityNodeInfo): Boolean {\n        val childCount = node.childCount\n        for (x in 0 until childCount) {\n            val childNode = node.getChild(x)\n            if (childNode == null) continue;\n            if (!safeCharSeqToString(childNode.contentDescription).isEmpty()\n                || !safeCharSeqToString(childNode.text).isEmpty()\n            ) return true\n            if (childNafCheck(childNode)) return true\n        }\n        return false\n    }\n\n    private fun safeCharSeqToString(cs: CharSequence?): String {\n        return cs?.let { stripInvalidXMLChars(it) } ?: \"\"\n    }\n\n    @Suppress(\"ComplexCondition\")\n    private fun stripInvalidXMLChars(cs: CharSequence): String {\n        val ret = StringBuffer()\n        var ch: Char\n        /* http://www.w3.org/TR/xml11/#charsets\n        [#x1-#x8], [#xB-#xC], [#xE-#x1F], [#x7F-#x84], [#x86-#x9F], [#xFDD0-#xFDDF],\n        [#x1FFFE-#x1FFFF], [#x2FFFE-#x2FFFF], [#x3FFFE-#x3FFFF],\n        [#x4FFFE-#x4FFFF], [#x5FFFE-#x5FFFF], [#x6FFFE-#x6FFFF],\n        [#x7FFFE-#x7FFFF], [#x8FFFE-#x8FFFF], [#x9FFFE-#x9FFFF],\n        [#xAFFFE-#xAFFFF], [#xBFFFE-#xBFFFF], [#xCFFFE-#xCFFFF],\n        [#xDFFFE-#xDFFFF], [#xEFFFE-#xEFFFF], [#xFFFFE-#xFFFFF],\n        [#x10FFFE-#x10FFFF].\n         */for (i in 0 until cs.length) {\n            ch = cs[i]\n            if (ch.code >= 0x1 && ch.code <= 0x8 || ch.code >= 0xB && ch.code <= 0xC || ch.code >= 0xE && ch.code <= 0x1F ||\n                ch.code >= 0x7F && ch.code <= 0x84 || ch.code >= 0x86 && ch.code <= 0x9f ||\n                ch.code >= 0xFDD0 && ch.code <= 0xFDDF || ch.code >= 0x1FFFE && ch.code <= 0x1FFFF ||\n                ch.code >= 0x2FFFE && ch.code <= 0x2FFFF || ch.code >= 0x3FFFE && ch.code <= 0x3FFFF ||\n                ch.code >= 0x4FFFE && ch.code <= 0x4FFFF || ch.code >= 0x5FFFE && ch.code <= 0x5FFFF ||\n                ch.code >= 0x6FFFE && ch.code <= 0x6FFFF || ch.code >= 0x7FFFE && ch.code <= 0x7FFFF ||\n                ch.code >= 0x8FFFE && ch.code <= 0x8FFFF || ch.code >= 0x9FFFE && ch.code <= 0x9FFFF ||\n                ch.code >= 0xAFFFE && ch.code <= 0xAFFFF || ch.code >= 0xBFFFE && ch.code <= 0xBFFFF ||\n                ch.code >= 0xCFFFE && ch.code <= 0xCFFFF || ch.code >= 0xDFFFE && ch.code <= 0xDFFFF ||\n                ch.code >= 0xEFFFE && ch.code <= 0xEFFFF || ch.code >= 0xFFFFE && ch.code <= 0xFFFFF ||\n                ch.code >= 0x10FFFE && ch.code <= 0x10FFFF\n            ) ret.append(\".\") else ret.append(ch)\n        }\n        return ret.toString()\n    }\n\n    // This method is copied from AccessibilityNodeInfoHelper as-is\n    private fun getVisibleBoundsInScreen(node: AccessibilityNodeInfo?, displayRect: Rect): Rect? {\n        if (node == null) {\n            return null\n        }\n        // targeted node's bounds\n        val nodeRect = Rect()\n        node.getBoundsInScreen(nodeRect)\n        return if (nodeRect.intersect(displayRect)) {\n            nodeRect\n        } else {\n            Rect()\n        }\n    }\n\n}\n"
  },
  {
    "path": "maestro-android/src/androidTest/java/dev/mobile/maestro/location/FusedLocationProvider.kt",
    "content": "package dev.mobile.maestro.location\n\nimport android.location.Location\nimport com.google.android.gms.location.FusedLocationProviderClient\n\nclass FusedLocationProvider(\n    private val fusedLocationProviderClient: FusedLocationProviderClient\n): MockLocationProvider {\n\n    companion object {\n        private const val PROVIDER_NAME = \"fused\"\n        private val TAG = FusedLocationProvider::class.java.name\n    }\n\n    override fun setLocation(location: Location) {\n        fusedLocationProviderClient.setMockLocation(location)\n    }\n\n    override fun enable() {\n        fusedLocationProviderClient.setMockMode(true)\n    }\n\n    override fun disable() {\n        fusedLocationProviderClient.setMockMode(false)\n    }\n\n    override fun getProviderName(): String {\n        return PROVIDER_NAME\n    }\n}"
  },
  {
    "path": "maestro-android/src/androidTest/java/dev/mobile/maestro/location/LocationManagerProvider.kt",
    "content": "package dev.mobile.maestro.location\n\nimport android.location.Location\nimport android.location.LocationManager\n\nclass LocationManagerProvider(\n    private val locationManager: LocationManager,\n    private val name: String,\n    private val requiresNetwork: Boolean,\n    private val requiresCell: Boolean,\n    private val requiresSatellite: Boolean,\n    private val hasMonetaryCost: Boolean,\n    private val supportsAltitude: Boolean,\n    private val supportsSpeed: Boolean,\n    private val supportsBearing: Boolean,\n    private val powerRequirement: Int,\n    private val accuracy: Int\n) : MockLocationProvider {\n\n    override fun setLocation(location: Location) {\n        locationManager.setTestProviderLocation(name, location)\n    }\n\n    override fun enable() {\n        locationManager.addTestProvider(\n            name,\n            requiresNetwork,\n            requiresSatellite,\n            requiresCell,\n            hasMonetaryCost,\n            supportsAltitude,\n            supportsSpeed,\n            supportsBearing,\n            powerRequirement,\n            accuracy\n        )\n        locationManager.setTestProviderEnabled(name, true)\n    }\n\n    override fun disable() {\n        locationManager.setTestProviderEnabled(name, false)\n        locationManager.removeTestProvider(name)\n    }\n\n    override fun getProviderName(): String {\n        return name\n    }\n}"
  },
  {
    "path": "maestro-android/src/androidTest/java/dev/mobile/maestro/location/MockLocationProvider.kt",
    "content": "package dev.mobile.maestro.location\n\nimport android.location.Location\n\ninterface MockLocationProvider {\n\n    fun setLocation(location: Location)\n\n    fun enable()\n\n    fun disable()\n\n    fun getProviderName(): String\n}"
  },
  {
    "path": "maestro-android/src/androidTest/java/dev/mobile/maestro/location/PlayServices.kt",
    "content": "package dev.mobile.maestro.location\n\nimport android.content.Context\nimport com.google.android.gms.common.ConnectionResult\nimport com.google.android.gms.common.GoogleApiAvailability\n\nclass PlayServices {\n\n    fun isAvailable(context: Context): Boolean {\n        val apiAvailability = GoogleApiAvailability.getInstance()\n        val resultCode = apiAvailability.isGooglePlayServicesAvailable(context)\n        return resultCode == ConnectionResult.SUCCESS\n    }\n}"
  },
  {
    "path": "maestro-android/src/androidTest/java/dev/mobile/maestro/screenshot/ScreenshotService.kt",
    "content": "package dev.mobile.maestro.screenshot\n\nimport android.graphics.Bitmap\nimport com.github.michaelbull.retry.policy.binaryExponentialBackoff\nimport com.github.michaelbull.retry.policy.continueIf\nimport com.github.michaelbull.retry.policy.plus\nimport com.github.michaelbull.retry.policy.stopAtAttempts\nimport com.github.michaelbull.retry.retry\nimport com.google.protobuf.ByteString\nimport java.io.ByteArrayOutputStream\nimport kotlinx.coroutines.runBlocking\n\n/**\n * Thrown when screenshot encoding fails (e.g. [Bitmap.compress] throws or returns false).\n */\nclass ScreenshotException(\n    message: String,\n    cause: Throwable? = null\n) : Exception(message, cause)\n\n/**\n * Encodes screenshots to PNG (or other formats) with validation and size limits.\n */\nclass ScreenshotService() {\n\n    /**\n     * Takes a screenshot with retry logic. Retries up to 3 times with exponential backoff\n     * when the screenshot returns null (window not ready / SurfaceControl invalid).\n     */\n    fun takeScreenshotWithRetry(screenshotProvider: () -> Bitmap?): Bitmap {\n        return runBlocking {\n            retry(\n                stopAtAttempts<Throwable>(3)\n                    + continueIf { it.failure is NullPointerException }\n                    + binaryExponentialBackoff(min = 500L, max = 5000L)\n            ) {\n                screenshotProvider()\n                    ?: throw NullPointerException(\"Screenshot returned null — window may not be ready\")\n            }\n        }\n    }\n\n    /**\n     * Encodes a screenshot bitmap to PNG bytes.\n     *\n     * @throws ScreenshotException if compression fails or output is invalid/too large.\n     */\n    fun encodePng(bitmap: Bitmap, quality: Int = 100): ByteString =\n        encode(bitmap, Bitmap.CompressFormat.PNG, quality)\n\n    /**\n     * Generic encoder for any [Bitmap.CompressFormat].\n     *\n     * @throws ScreenshotException if compression fails or output is invalid/too large.\n     */\n    fun encode(\n        bitmap: Bitmap,\n        format: Bitmap.CompressFormat,\n        quality: Int = 100,\n    ): ByteString {\n        validateQuality(format, quality)\n\n        val outputStream = ByteArrayOutputStream()\n        val ok = try {\n            bitmap.compress(format, quality, outputStream)\n        } catch (t: Throwable) {\n            throw ScreenshotException(\n                message = \"Bitmap compression failed: format=${format.name}, width=${bitmap.width}, height=${bitmap.height}, config=${bitmap.config}\",\n                cause = t\n            )\n        }\n\n        if (!ok) {\n            throw ScreenshotException(\n                message = \"Bitmap.compress returned false: format=${format.name}, quality=$quality, width=${bitmap.width}, height=${bitmap.height}, config=${bitmap.config}\"\n            )\n        }\n\n        val bytes = outputStream.toByteArray()\n        if (bytes.isEmpty()) {\n            throw ScreenshotException(\n                message = \"Bitmap compressed but produced empty output: format=${format.name}, quality=$quality, width=${bitmap.width}, height=${bitmap.height}, config=${bitmap.config}\"\n            )\n        }\n        return ByteString.copyFrom(bytes)\n    }\n\n    private fun validateQuality(format: Bitmap.CompressFormat, quality: Int) {\n        if (quality !in 0..100) {\n            throw IllegalArgumentException(\"quality must be in 0..100, got $quality\")\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-android/src/androidTest/java/dev/mobile/maestro/screenshot/ScreenshotServiceTest.kt",
    "content": "package dev.mobile.maestro.screenshot\n\nimport android.graphics.Bitmap\nimport androidx.test.ext.junit.runners.AndroidJUnit4\nimport org.junit.Assert.assertEquals\nimport org.junit.Assert.assertNotNull\nimport org.junit.Assert.assertSame\nimport org.junit.Assert.assertTrue\nimport org.junit.Assert.fail\nimport org.junit.Ignore\nimport org.junit.Test\nimport org.junit.runner.RunWith\n\n@Ignore(\"Disabled since this structure of unit test is weird - We should have libs which should be having their own tests\")\n@RunWith(AndroidJUnit4::class)\nclass ScreenshotServiceTest {\n    private val screenshotService = ScreenshotService()\n\n    @Test\n    fun encodePng_withValidBitmap_returnsBytes() {\n        val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)\n        try {\n            val result = screenshotService.encodePng(bitmap)\n            assertNotNull(result)\n            assertTrue(\"Encoded bytes should not be empty\", result.size() > 0)\n        } finally {\n            bitmap.recycle()\n        }\n    }\n\n    @Test\n    fun encode_withInvalidQualityTooHigh_throwsIllegalArgumentException() {\n        val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)\n        try {\n            screenshotService.encode(bitmap, Bitmap.CompressFormat.PNG, quality = 101)\n            fail(\"Expected IllegalArgumentException to be thrown\")\n        } catch (e: IllegalArgumentException) {\n            assertTrue(\n                \"Message should mention quality\",\n                e.message?.contains(\"quality\") == true\n            )\n        } finally {\n            bitmap.recycle()\n        }\n    }\n\n    @Test\n    fun encode_withInvalidQualityNegative_throwsIllegalArgumentException() {\n        val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)\n        try {\n            screenshotService.encode(bitmap, Bitmap.CompressFormat.PNG, quality = -1)\n            fail(\"Expected IllegalArgumentException to be thrown\")\n        } catch (e: IllegalArgumentException) {\n            assertTrue(\n                \"Message should mention quality\",\n                e.message?.contains(\"quality\") == true\n            )\n        } finally {\n            bitmap.recycle()\n        }\n    }\n\n    @Test\n    fun encode_withJpegFormat_returnsBytes() {\n        val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)\n        try {\n            val result = screenshotService.encode(bitmap, Bitmap.CompressFormat.JPEG, quality = 80)\n            assertNotNull(result)\n            assertTrue(\"Encoded bytes should not be empty\", result.size() > 0)\n        } finally {\n            bitmap.recycle()\n        }\n    }\n\n    @Test\n    fun encode_withWebpFormat_returnsBytes() {\n        val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)\n        try {\n            @Suppress(\"DEPRECATION\")\n            val result = screenshotService.encode(bitmap, Bitmap.CompressFormat.WEBP, quality = 80)\n            assertNotNull(result)\n            assertTrue(\"Encoded bytes should not be empty\", result.size() > 0)\n        } finally {\n            bitmap.recycle()\n        }\n    }\n\n    @Test\n    fun takeScreenshotWithRetry_returnsOnFirstSuccess() {\n        val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)\n        var callCount = 0\n        try {\n            val result = screenshotService.takeScreenshotWithRetry {\n                callCount++\n                bitmap\n            }\n            assertSame(bitmap, result)\n            assertEquals(1, callCount)\n        } finally {\n            bitmap.recycle()\n        }\n    }\n\n    @Test\n    fun takeScreenshotWithRetry_retriesOnNull() {\n        val bitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)\n        var callCount = 0\n        try {\n            val result = screenshotService.takeScreenshotWithRetry {\n                callCount++\n                if (callCount < 3) null else bitmap\n            }\n            assertSame(bitmap, result)\n            assertEquals(3, callCount)\n        } finally {\n            bitmap.recycle()\n        }\n    }\n\n    @Test\n    fun takeScreenshotWithRetry_throwsAfterAllRetriesExhausted() {\n        var callCount = 0\n        try {\n            screenshotService.takeScreenshotWithRetry {\n                callCount++\n                null\n            }\n            fail(\"Expected NullPointerException to be thrown\")\n        } catch (e: NullPointerException) {\n            assertEquals(3, callCount)\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-android/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:tools=\"http://schemas.android.com/tools\"\n    xmlns:android=\"http://schemas.android.com/apk/res/android\">\n\n    <uses-permission android:name=\"android.permission.INTERNET\" />\n    <uses-permission android:name=\"android.permission.ACCESS_MOCK_LOCATION\"\n        tools:ignore=\"MockLocation,ProtectedPermissions\"/>\n    <uses-permission android:name=\"android.permission.QUERY_ALL_PACKAGES\"\n        tools:ignore=\"QueryAllPackagesPermission\"/>\n    <uses-permission android:name=\"android.permission.WRITE_SETTINGS\"\n        tools:ignore=\"ProtectedPermissions\" />\n    <uses-permission android:name=\"android.permission.CHANGE_CONFIGURATION\"\n        tools:ignore=\"ProtectedPermissions\" />\n    <uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\" />\n    <uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\" />\n\n    <application\n        android:usesCleartextTraffic=\"true\">\n        <meta-data android:name=\"com.google.android.gms.version\" android:value=\"@integer/google_play_services_version\" />\n        <receiver android:name=\".receivers.LocaleSettingReceiver\"\n            android:exported=\"true\"\n            tools:ignore=\"ExportedReceiver\" />\n    </application>\n</manifest>"
  },
  {
    "path": "maestro-android/src/main/java/dev/mobile/maestro/handlers/AbstractSettingHandler.kt",
    "content": "package dev.mobile.maestro.handlers\n\nimport android.content.Context\nimport android.content.pm.PackageManager\nimport android.util.Log\n\nabstract class AbstractSettingHandler(private val context: Context, private val permissions: List<String>) {\n    protected fun hasPermissions(): Boolean {\n        for (p in permissions) {\n            if (context.checkCallingOrSelfPermission(p) != PackageManager.PERMISSION_GRANTED) {\n                val logMessage = String.format(\n                    \"The permission %s is not set. Cannot change state of %s.\",\n                    p, settingDescription\n                )\n                Log.e(TAG, logMessage)\n                return false\n            }\n        }\n        return true\n    }\n\n    abstract fun setState(state: Boolean): Boolean\n    abstract val settingDescription: String\n\n    companion object {\n        private const val TAG = \"Maestro\"\n    }\n}"
  },
  {
    "path": "maestro-android/src/main/java/dev/mobile/maestro/handlers/LocaleSettingHandler.kt",
    "content": "package dev.mobile.maestro.handlers\n\nimport android.annotation.SuppressLint\nimport android.content.Context\nimport android.content.res.Configuration\nimport android.os.Build\nimport org.lsposed.hiddenapibypass.HiddenApiBypass\nimport java.lang.reflect.InvocationTargetException\nimport java.util.*\n\nclass LocaleSettingHandler(context: Context) : AbstractSettingHandler(context, listOf(CHANGE_CONFIGURATION)) {\n    fun setLocale(locale: Locale) {\n        if (hasPermissions()) {\n            setLocaleWith(locale)\n        }\n    }\n\n    @SuppressLint(\"PrivateApi\")\n    @Throws(\n        ClassNotFoundException::class,\n        NoSuchMethodException::class,\n        InvocationTargetException::class,\n        IllegalAccessException::class,\n        NoSuchFieldException::class\n    )\n    private fun setLocaleWith(locale: Locale) {\n        var activityManagerNativeClass = Class.forName(\"android.app.ActivityManagerNative\")\n        val methodGetDefault = activityManagerNativeClass.getMethod(\"getDefault\")\n        methodGetDefault.isAccessible = true\n        val amn = methodGetDefault.invoke(activityManagerNativeClass)\n\n        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {\n            // getConfiguration moved from ActivityManagerNative to ActivityManagerProxy\n            activityManagerNativeClass = Class.forName(amn.javaClass.name)\n        }\n\n        val methodGetConfiguration = activityManagerNativeClass.getMethod(\"getConfiguration\")\n        methodGetConfiguration.isAccessible = true\n        val config = methodGetConfiguration.invoke(amn) as Configuration\n        val configClass: Class<*> = config.javaClass\n        val f = configClass.getField(\"userSetLocale\")\n        f.setBoolean(config, true)\n        config.locale = locale\n        config.setLayoutDirection(locale)\n\n        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {\n            HiddenApiBypass.invoke(\n                activityManagerNativeClass,\n                amn,\n                \"updateConfiguration\",\n                config\n            )\n        } else {\n            val methodUpdateConfiguration = activityManagerNativeClass.getMethod(\n                \"updateConfiguration\",\n                Configuration::class.java\n            )\n            methodUpdateConfiguration.isAccessible = true\n            methodUpdateConfiguration.invoke(amn, config)\n        }\n    }\n\n    override fun setState(state: Boolean): Boolean {\n        return false\n    }\n\n    override val settingDescription: String = \"locale\"\n\n    companion object {\n        private const val CHANGE_CONFIGURATION = \"android.permission.CHANGE_CONFIGURATION\"\n    }\n}\n\n"
  },
  {
    "path": "maestro-android/src/main/java/dev/mobile/maestro/receivers/HasAction.kt",
    "content": "package dev.mobile.maestro.receivers\n\ninterface HasAction {\n    fun action(): String\n}"
  },
  {
    "path": "maestro-android/src/main/java/dev/mobile/maestro/receivers/LocaleSettingReceiver.kt",
    "content": "package dev.mobile.maestro.receivers\n\nimport android.content.BroadcastReceiver\nimport android.content.Context\nimport android.content.Intent\nimport android.util.Log\nimport dev.mobile.maestro.handlers.LocaleSettingHandler\nimport org.apache.commons.lang3.LocaleUtils\nimport java.util.*\nimport java.lang.Exception\n\nclass LocaleSettingReceiver : BroadcastReceiver(), HasAction {\n\n    override fun onReceive(context: Context, intent: Intent) {\n        var language = intent.getStringExtra(LANG)\n        var country = intent.getStringExtra(COUNTRY)\n\n        if (language == null || country == null) {\n            Log.w(\n                TAG, \"It is required to provide both language and country, for example: \" +\n                        \"am broadcast -a dev.mobile.maestro --es lang ja --es country JP\"\n            )\n            Log.i(TAG, \"Set en-US by default.\")\n            language = \"en\"\n            country = \"US\"\n        }\n\n        var locale = Locale(language, country)\n\n        Log.i(TAG, \"Obtained locale: $locale\")\n\n        try {\n            Log.i(TAG, \"getting string extra for device locale\")\n            val script = intent.getStringExtra(SCRIPT)\n            if (script != null) {\n                Log.i(TAG, \"setting script with device locale\")\n                Locale.Builder().setLocale(locale).setScript(script).build().also { locale = it }\n                Log.i(TAG, \"script set for device locale\")\n            }\n\n            if (!LocaleUtils.isAvailableLocale(locale)) {\n                val approximateMatchesLc = matchLocales(language, country)\n\n                if (approximateMatchesLc.isNotEmpty() && script.isNullOrBlank()) {\n                    Log.i(\n                        TAG,\n                        \"The locale $locale is not known. Selecting the closest known one ${approximateMatchesLc[0]} instead\"\n                    )\n                    locale = approximateMatchesLc[0]\n                } else {\n                    val approximateMatchesL = matchLocales(language)\n                    if (approximateMatchesL.isEmpty()) {\n                        Log.e(\n                            TAG,\n                            \"The locale $locale is not known. Only the following locales are available: ${LocaleUtils.availableLocaleList()}\"\n                        )\n                    } else {\n                        Log.e(\n                            TAG,\n                            \"The locale $locale is not known. \" +\n                                    \"The following locales are available for the $language language: $approximateMatchesL\" +\n                                    \"The following locales are available altogether: ${LocaleUtils.availableLocaleList()}\"\n                        )\n                    }\n                    resultCode = RESULT_LOCALE_NOT_VALID\n                    resultData = \"Failed to set locale $locale, the locale is not valid\"\n                    return\n                }\n            }\n        } catch (e: Exception) {\n            Log.e(TAG, \"Failed to validate device locale\", e)\n            resultCode = RESULT_LOCALE_VALIDATION_FAILED\n            resultData = \"Failed to set locale $locale: ${e.message}\"\n        }\n\n        try {\n            LocaleSettingHandler(context).setLocale(locale)\n            Log.i(TAG, \"Set locale: $locale\")\n            resultCode = RESULT_SUCCESS\n            resultData = locale.toString()\n        } catch (e: Exception) {\n            Log.e(TAG, \"Failed to set locale\", e)\n            resultCode = RESULT_UPDATE_CONFIGURATION_FAILED\n            resultData = \"Failed to set locale $locale, exception during updating configuration occurred: $e\"\n        }\n    }\n\n    private fun matchLocales(language: String): List<Locale> {\n        val matches = ArrayList<Locale>()\n        for (locale in LocaleUtils.availableLocaleList()) {\n            if (locale.language == language) {\n                matches.add(locale)\n            }\n        }\n        return matches\n    }\n\n    private fun matchLocales(language: String, country: String): List<Locale> {\n        val matches = ArrayList<Locale>()\n        for (locale in LocaleUtils.availableLocaleList()) {\n            if (locale.language == language &&\n                locale.country == country\n            ) {\n                matches.add(locale)\n            }\n        }\n        return matches\n    }\n\n    override fun action(): String {\n        return ACTION\n    }\n\n    companion object {\n        private const val LANG = \"lang\"\n        private const val COUNTRY = \"country\"\n        private const val SCRIPT = \"script\"\n        private const val ACTION = \"dev.mobile.maestro.locale\"\n        private const val TAG = \"Maestro\"\n\n        private const val RESULT_SUCCESS = 0\n        private const val RESULT_LOCALE_NOT_VALID = 1\n        private const val RESULT_UPDATE_CONFIGURATION_FAILED = 2\n        private const val RESULT_LOCALE_VALIDATION_FAILED = 3\n    }\n}"
  },
  {
    "path": "maestro-android/src/main/res/values/stub.xml",
    "content": "<resources>\n    <string name=\"app_name\">Maestro Driver</string>\n</resources>\n"
  },
  {
    "path": "maestro-cli/build.gradle.kts",
    "content": "import org.jreleaser.model.Active.ALWAYS\nimport org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask\nimport org.jreleaser.model.Stereotype\nimport java.util.Properties\n\n@Suppress(\"DSL_SCOPE_VIOLATION\")\nplugins {\n    application\n    alias(libs.plugins.kotlin.jvm)\n    alias(libs.plugins.jreleaser)\n    alias(libs.plugins.shadow)\n    alias(libs.plugins.mavenPublish)\n    alias(libs.plugins.kotlin.serialization)\n}\n\ngroup = \"dev.mobile\"\n\nval CLI_VERSION: String by project\n\napplication {\n    applicationName = \"maestro\"\n    mainClass.set(\"maestro.cli.AppKt\")\n}\n\ntasks.named<Jar>(\"jar\") {\n    manifest {\n        attributes[\"Main-Class\"] = \"maestro.cli.AppKt\"\n    }\n    // Include the driver source directly\n    from(\"../maestro-ios-xctest-runner\") {\n        into(\"driver/ios\")\n        include(\n            \"maestro-driver-ios/**\",\n            \"maestro-driver-iosUITests/**\",\n            \"maestro-driver-ios.xcodeproj/**\",\n        )\n    }\n}\n\ntasks.named<JavaExec>(\"run\") {\n    standardInput = System.`in`\n    workingDir = rootDir\n}\n\n/** The source that was used to create JvmVersion is here. It was compiled with JDK 1.1\n * on a Windows 32 bit machine using https://www.oracle.com/java/technologies/java-archive-downloads-javase11-downloads.html\n * with a META-INF/MANIFEST.MF of Main-Class: JvmVersion for the jvm-version.jar\n *\n * import java.util.StringTokenizer;\n *\n * class JvmVersion {\n *     public static void main(String[] args) {\n *         try {\n *             String javaVersion = System.getProperty(\"java.version\");\n * \t\t\tStringTokenizer tokenizer = new StringTokenizer(javaVersion, \".\");\n * \t\t\tString[] split = new String[tokenizer.countTokens()];\n * \t\t\tint count = 0;\n * \t\t\twhile (tokenizer.hasMoreTokens()) {\n * \t\t\t\tsplit[count] = tokenizer.nextToken();\n * \t\t\t\tcount++;\n * \t\t\t}\n *             if (javaVersion.startsWith(\"1.\")) {\n *                 String version = split[1];\n *                 if (Integer.parseInt(version) >= 1 && Integer.parseInt(version) <= 8) {\n *                     System.out.println(version);\n *                     System.exit(0);\n *                 } else {\n * \t\t\t\t\tString base = \"Expected a JVM version of 1.0 through to 1.8 for legacy JVM versioning. Instead got \";\n *                     String output = base.concat(version);\n *                     System.out.println(output);\n *                     System.exit(1);\n *                 }\n *             } else {\n *                 String version = split[0];\n *                 if (Integer.parseInt(version) >= 9) {\n *                     System.out.println(version);\n *                     System.exit(0);\n *                 } else {\n * \t\t\t\t\tString base = \"Expected a JVM version of 9 or greater for new JVM versioning. Instead got \";\n * \t\t\t\t\tString output = base.concat(version);\n * \t\t\t\t\tSystem.out.println(output);\n *                     System.exit(1);\n *                 }\n *             }\n *         } catch (Exception e) {\n *             System.err.println(e.getMessage());\n *             System.exit(1);\n *         }\n *     }\n * }\n *\n */\n\nfun windowsMinimumJavaText(minimumJavaVersion: String): String = \"\"\"\nset JAVA_VERSION=0\nfor /f \"tokens=*\" %%g in ('cmd /c \"\"%JAVA_EXE%\" -classpath \"%APP_HOME%\\bin\\*\" JvmVersion\"') do (\n  set JAVA_VERSION=%%g\n)\n\nif %JAVA_VERSION% LSS $minimumJavaVersion (\n  echo.\n  echo ERROR: Java $minimumJavaVersion or higher is required.\n  echo.\n  echo Please update Java, then try again.\n  echo To check your Java version, run: java -version\n  echo.\n  echo See https://maestro.dev/blog/introducing-maestro-2-0-0 for more details.\n  goto fail\n)\n\"\"\".trimIndent().replace(\"\\n\", \"\\r\\n\")\n\nfun unixMinimumJavaText(minimumJavaVersion: String): String = \"\"\"\nJAVA_VERSION=$( \"${'$'}JAVACMD\" -classpath \"${'$'}APP_HOME\"/bin/*.jar JvmVersion )\nif [ \"${'$'}JAVA_VERSION\" -lt $minimumJavaVersion ]; then\n  die \"ERROR: Java $minimumJavaVersion or higher is required.\n\nPlease update Java, then try again.\nTo check your Java version, run: java -version\n\nSee https://maestro.dev/blog/introducing-maestro-2-0-0 for more details.\"\nfi\n\"\"\".trimIndent()\n\ntasks.named<CreateStartScripts>(\"startScripts\") {\n    classpath = files(\"${layout.buildDirectory}/libs/*\")\n    doLast {\n        val minimumJavaVersion = \"17\"\n        val unixExec = \"exec \\\"\\$JAVACMD\\\" \\\"$@\\\"\"\n\n        val currentUnix = unixScript.readText()\n        val replacedUnix = currentUnix.replaceFirst(unixExec,\n            unixMinimumJavaText(minimumJavaVersion) + \"\\n\\n\" + unixExec)\n        unixScript.writeText(replacedUnix)\n\n        val currentWindows = windowsScript.readText()\n        val windowsExec = \"@rem Execute maestro\"\n        val replacedWindows = currentWindows.replaceFirst(windowsExec,\n            windowsMinimumJavaText(minimumJavaVersion) + \"\\r\\n\\r\\n\" + windowsExec)\n        windowsScript.writeText(replacedWindows)\n\n        val path = project.projectDir.toPath().resolve(\"jvm-version.jar\")\n\n        copy {\n            from(path)\n            into(outputDir)\n        }\n    }\n}\n\ndependencies {\n    implementation(project(path = \":maestro-utils\"))\n    annotationProcessor(libs.picocli.codegen)\n\n    implementation(project(\":maestro-orchestra\"))\n    implementation(project(\":maestro-client\"))\n    implementation(project(\":maestro-ios\"))\n    implementation(project(\":maestro-ios-driver\"))\n    implementation(project(\":maestro-studio:server\"))\n    implementation(libs.apk.parser)\n    implementation(libs.dd.plist)\n    implementation(libs.posthog)\n    implementation(libs.dadb)\n    implementation(libs.picocli)\n    implementation(libs.jackson.core.databind)\n    implementation(libs.jackson.module.kotlin)\n    implementation(libs.jackson.dataformat.yaml)\n    implementation(libs.jackson.dataformat.xml)\n    implementation(libs.jackson.datatype.jsr310)\n    implementation(libs.jansi)\n    implementation(libs.jcodec)\n    implementation(libs.jcodec.awt)\n    implementation(libs.square.okhttp)\n    implementation(libs.ktor.client.core)\n    implementation(libs.ktor.client.cio)\n    implementation(libs.ktor.server.core)\n    implementation(libs.ktor.server.netty)\n    implementation(libs.ktor.server.cors)\n    implementation(libs.ktor.server.status.pages)\n    implementation(libs.jarchivelib)\n    implementation(libs.commons.codec)\n    implementation(libs.kotlinx.coroutines.core)\n    implementation(libs.kotlinx.html)\n    implementation(libs.skiko.macos.arm64)\n    implementation(libs.skiko.macos.x64)\n    implementation(libs.skiko.linux.arm64)\n    implementation(libs.skiko.linux.x64)\n    implementation(libs.skiko.windows.arm64)\n    implementation(libs.skiko.windows.x64)\n    implementation(libs.kotlinx.serialization.json)\n    implementation(\"org.jetbrains.kotlinx:kotlinx-io-core:0.2.0\")\n    implementation(libs.mcp.kotlin.sdk) {\n        version {\n            branch = \"steviec/kotlin-1.8\"\n        }\n        exclude(group = \"org.slf4j\", module = \"slf4j-simple\")\n    }\n    implementation(libs.logging.sl4j)\n    implementation(libs.logging.api)\n    implementation(libs.logging.layout.template)\n    implementation(libs.log4j.core)\n    implementation(libs.mordant)\n\n    testImplementation(libs.junit.jupiter.api)\n    testRuntimeOnly(libs.junit.jupiter.engine)\n    testImplementation(libs.mockk)\n    testImplementation(libs.google.truth)\n}\n\ntasks.named<Test>(\"test\") {\n    useJUnitPlatform()\n}\n\njava {\n    sourceCompatibility = JavaVersion.VERSION_17\n    targetCompatibility = JavaVersion.VERSION_17\n}\n\nkotlin {\n    jvmToolchain(17)\n}\n\ntasks.named(\"compileKotlin\", KotlinCompilationTask::class.java) {\n    compilerOptions {\n        freeCompilerArgs.addAll(\"-Xjdk-release=17\")\n    }\n}\n\ntasks.create(\"createProperties\") {\n    dependsOn(\"processResources\")\n\n    doLast {\n        File(\"$buildDir/resources/main/version.properties\").writer().use { w ->\n            val p = Properties()\n            p[\"version\"] = CLI_VERSION\n            p.store(w, null)\n        }\n    }\n}\n\ntasks.register<Copy>(\"createTestResources\") {\n    from(\"../maestro-ios-xctest-runner\") {\n        into(\"driver/ios\")\n        include(\n            \"maestro-driver-ios/**\",\n            \"maestro-driver-iosUITests/**\",\n            \"maestro-driver-ios.xcodeproj/**\"\n        )\n    }\n    into(layout.buildDirectory.dir(\"resources/test\"))\n}\n\ntasks.named(\"classes\") {\n    dependsOn(\"createTestResources\")\n    dependsOn(\"createProperties\")\n}\n\ntasks.named<Zip>(\"distZip\") {\n    archiveFileName.set(\"maestro.zip\")\n}\n\ntasks.named<Tar>(\"distTar\") {\n    archiveFileName.set(\"maestro.tar\")\n}\n\ntasks.shadowJar {\n    setProperty(\"zip64\", true)\n}\n\nmavenPublishing {\n    publishToMavenCentral(true)\n    signAllPublications()\n}\n\njreleaser {\n    version = CLI_VERSION\n    gitRootSearch.set(true)\n\n    project {\n        name.set(\"Maestro CLI\")\n        description.set(\"The easiest way to automate UI testing for your mobile app\")\n        links {\n            homepage.set(\"https://maestro.mobile.dev\")\n            bugTracker.set(\"https://github.com/mobile-dev-inc/maestro/issues\")\n        }\n        authors.set(listOf(\"Dmitry Zaytsev\", \"Amanjeet Singh\", \"Leland Takamine\", \"Arthur Saveliev\", \"Axel Niklasson\", \"Berik Visschers\"))\n        license.set(\"Apache-2.0\")\n        copyright.set(\"mobile.dev 2024\")\n    }\n\n    distributions {\n        create(\"maestro\") {\n            stereotype.set(Stereotype.CLI)\n\n            executable {\n                name.set(\"maestro\")\n            }\n\n            artifact {\n                setPath(\"build/distributions/maestro.zip\")\n            }\n\n            release {\n                github {\n                    repoOwner.set(\"mobile-dev-inc\")\n                    name.set(\"maestro\")\n                    tagName.set(\"cli-$CLI_VERSION\")\n                    releaseName.set(\"CLI $CLI_VERSION\")\n                    overwrite.set(true)\n\n                    changelog {\n                        // GitHub removes dots Markdown headers (1.37.5 becomes 1375)\n                        extraProperties.put(\"versionHeader\", CLI_VERSION.replace(\".\", \"\"))\n\n                        formatted.set(ALWAYS)\n                        content.set(\"\"\"\n                            [See changelog in the CHANGELOG.md file][link]\n\n                            [link]: https://github.com/mobile-dev-inc/maestro/blob/main/CHANGELOG.md#{{changelogVersionHeader}}\n                        \"\"\".trimIndent()\n                        )\n                    }\n                }\n            }\n        }\n    }\n\n    packagers {\n        brew {\n            setActive(\"RELEASE\")\n            extraProperties.put(\"skipJava\", \"true\")\n            formulaName.set(\"Maestro\")\n\n            // The default template path\n            templateDirectory.set(file(\"src/jreleaser/distributions/maestro/brew\"))\n\n            repoTap {\n                repoOwner.set(\"mobile-dev-inc\")\n                name.set(\"homebrew-tap\")\n            }\n\n            dependencies {\n                dependency(\"openjdk\", \"17+\")\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-cli/gradle.properties",
    "content": "CLI_VERSION=2.4.0\n"
  },
  {
    "path": "maestro-cli/src/jreleaser/distributions/maestro/brew/formula.rb.tpl",
    "content": "# {{jreleaserCreationStamp}}\n{{#brewRequireRelative}}\nrequire_relative \"{{.}}\"\n{{/brewRequireRelative}}\n\nclass {{brewFormulaName}} < Formula\n  desc \"{{projectDescription}}\"\n  homepage \"{{projectLinkHomepage}}\"\n  url \"{{distributionUrl}}\"{{#brewDownloadStrategy}}, :using => {{.}}{{/brewDownloadStrategy}}\n  version \"{{projectVersion}}\"\n  sha256 \"{{distributionChecksumSha256}}\"\n  license \"{{projectLicense}}\"\n\n  {{#brewHasLivecheck}}\n  livecheck do\n    {{#brewLivecheck}}\n    {{.}}\n    {{/brewLivecheck}}\n  end\n  {{/brewHasLivecheck}}\n  {{#brewDependencies}}\n  depends_on {{.}}\n  {{/brewDependencies}}\n\n  def install\n    libexec.install Dir[\"*\"]\n    bin.install_symlink \"#{libexec}/bin/{{distributionExecutableUnix}}\" => \"{{distributionExecutableName}}\"\n  end\n\n  test do\n    output = shell_output(\"#{bin}/{{distributionExecutableName}} --version\")\n    assert_match \"{{projectVersion}}\", output\n  end\nend\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/App.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.cli\n\nimport maestro.MaestroException\nimport maestro.cli.analytics.Analytics\nimport maestro.cli.analytics.CliCommandRunEvent\nimport maestro.cli.command.BugReportCommand\nimport maestro.cli.command.ChatCommand\nimport maestro.cli.command.CheckSyntaxCommand\nimport maestro.cli.command.CloudCommand\nimport maestro.cli.command.DownloadSamplesCommand\nimport maestro.cli.command.DriverCommand\nimport maestro.cli.command.ListCloudDevicesCommand\nimport maestro.cli.command.ListDevicesCommand\nimport maestro.cli.command.LoginCommand\nimport maestro.cli.command.LogoutCommand\nimport maestro.cli.command.McpCommand\nimport maestro.cli.command.PrintHierarchyCommand\nimport maestro.cli.command.QueryCommand\nimport maestro.cli.command.RecordCommand\nimport maestro.cli.command.StartDeviceCommand\nimport maestro.cli.command.StudioCommand\nimport maestro.cli.command.TestCommand\nimport maestro.cli.insights.TestAnalysisManager\nimport maestro.cli.update.Updates\nimport maestro.cli.util.ChangeLogUtils\nimport maestro.cli.util.ErrorReporter\nimport maestro.cli.view.box\nimport maestro.debuglog.DebugLogStore\nimport picocli.AutoComplete.GenerateCompletion\nimport picocli.CommandLine\nimport picocli.CommandLine.Command\nimport picocli.CommandLine.Option\nimport java.util.*\nimport kotlin.system.exitProcess\n\n@Command(\n    name = \"maestro\",\n    subcommands = [\n        TestCommand::class,\n        CloudCommand::class,\n        RecordCommand::class,\n        PrintHierarchyCommand::class,\n        QueryCommand::class,\n        DownloadSamplesCommand::class,\n        LoginCommand::class,\n        LogoutCommand::class,\n        BugReportCommand::class,\n        StudioCommand::class,\n        StartDeviceCommand::class,\n        ListDevicesCommand::class,\n        ListCloudDevicesCommand::class,\n        GenerateCompletion::class,\n        ChatCommand::class,\n        CheckSyntaxCommand::class,\n        DriverCommand::class,\n        McpCommand::class,\n    ]\n)\nclass App {\n    @CommandLine.Mixin\n    var disableANSIMixin: DisableAnsiMixin? = null\n\n    @CommandLine.Mixin\n    var showHelpMixin: ShowHelpMixin? = null\n\n    @Option(names = [\"-v\", \"--version\"], versionHelp = true, description = [\"Display CLI version\"])\n    var requestedVersion: Boolean? = false\n\n    @Option(names = [\"-p\", \"--platform\"], description = [\"(Optional) Select a platform to run on\"])\n    var platform: String? = null\n\n    @Option(names = [\"--host\"], hidden = true)\n    var host: String? = null\n\n    @Option(names = [\"--port\"], hidden = true)\n    var port: Int? = null\n\n    @Option(\n        names = [\"--device\", \"--udid\"],\n        description = [\"(Optional) Device ID to run on explicitly, can be a comma separated list of IDs: --device \\\"Emulator_1,Emulator_2\\\" \"],\n    )\n    var deviceId: String? = null\n\n    @Option(names = [\"--verbose\"], description = [\"Enable verbose logging\"])\n    var verbose: Boolean = false\n}\n\nprivate fun printVersion() {\n    val props = App::class.java.classLoader.getResourceAsStream(\"version.properties\").use {\n        Properties().apply { load(it) }\n    }\n\n    println(props[\"version\"])\n}\n\nfun main(args: Array<String>) {\n    // Disable icon in Mac dock\n    // https://stackoverflow.com/a/17544259\n    try {\n        System.setProperty(\"apple.awt.UIElement\", \"true\")\n        Analytics.warnAndEnableAnalyticsIfNotDisable()\n\n        Dependencies.install()\n        Updates.fetchUpdatesAsync()\n\n        val commandLine = CommandLine(App())\n            .setUsageHelpWidth(160)\n            .setCaseInsensitiveEnumValuesAllowed(true)\n            .setExecutionStrategy(DisableAnsiMixin::executionStrategy)\n            .setExecutionExceptionHandler { ex, cmd, cmdParseResult ->\n\n                runCatching { ErrorReporter.report(ex, cmdParseResult) }\n\n                // make errors red\n                println()\n                cmd.colorScheme = CommandLine.Help.ColorScheme.Builder()\n                    .errors(CommandLine.Help.Ansi.Style.fg_red)\n                    .build()\n\n                cmd.err.println(\n                    cmd.colorScheme.errorText(ex.message.orEmpty())\n                )\n\n                if (\n                    ex !is CliError && ex !is MaestroException.UnsupportedJavaVersion\n                    && ex !is MaestroException.MissingAppleTeamId && ex !is MaestroException.IOSDeviceDriverSetupException\n                ) {\n                    cmd.err.println(\"\\nThe stack trace was:\")\n                    cmd.err.println(ex.stackTraceToString())\n                }\n\n                1\n            }\n\n        // Track CLI run\n        if (args.isNotEmpty())\n            Analytics.trackEvent(CliCommandRunEvent(command = args[0]))\n\n        val generateCompletionCommand = commandLine.subcommands[\"generate-completion\"]\n        generateCompletionCommand?.commandSpec?.usageMessage()?.hidden(true)\n\n        val exitCode = commandLine\n            .execute(*args)\n\n        DebugLogStore.finalizeRun()\n        TestAnalysisManager.maybeNotify()\n\n        val newVersion = Updates.checkForUpdates()\n        if (newVersion != null) {\n            Updates.fetchChangelogAsync()\n            System.err.println()\n            val changelog = Updates.getChangelog()\n            val anchor = newVersion.toString().replace(\".\", \"\")\n            System.err.println(\n                listOf(\n                    \"A new version of the Maestro CLI is available ($newVersion).\\n\",\n                    \"See what's new:\",\n                    \"https://github.com/mobile-dev-inc/maestro/blob/main/CHANGELOG.md#$anchor\",\n                    ChangeLogUtils.print(changelog),\n                    \"Upgrade command:\",\n                    \"curl -Ls \\\"https://get.maestro.mobile.dev\\\" | bash\",\n                ).joinToString(\"\\n\").box()\n            )\n        }\n\n        if (commandLine.isVersionHelpRequested) {\n            printVersion()\n            Analytics.close()\n            exitProcess(0)\n        }\n\n        Analytics.close()\n        exitProcess(exitCode)\n    } catch (e: Throwable) {\n        Analytics.close()\n        throw e\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/CliError.kt",
    "content": "package maestro.cli\n\nclass CliError(override val message: String) : RuntimeException(message)\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/Dependencies.kt",
    "content": "package maestro.cli\n\nimport maestro.cli.util.Unpacker.binaryDependency\nimport maestro.cli.util.Unpacker.unpack\n\nobject Dependencies {\n    private val appleSimUtils = binaryDependency(\"applesimutils\")\n\n    fun install() {\n        unpack(\n            jarPath = \"deps/applesimutils\",\n            target = appleSimUtils,\n        )\n    }\n\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/DisableAnsiMixin.kt",
    "content": "package maestro.cli\n\nimport org.fusesource.jansi.Ansi\nimport org.fusesource.jansi.AnsiConsole\nimport org.fusesource.jansi.internal.CLibrary\nimport picocli.CommandLine\n\nclass DisableAnsiMixin {\n    @CommandLine.Option(\n        names = [\"--no-color\", \"--no-ansi\"],\n        negatable = true,\n        description = [\"Enable / disable colors and ansi output\"]\n    )\n    var enableANSIOutput = true\n\n    companion object {\n        var ansiEnabled = true\n            private set\n\n        fun executionStrategy(parseResult: CommandLine.ParseResult): Int {\n            applyCLIMixin(parseResult)\n            return CommandLine.RunLast().execute(parseResult)\n        }\n\n        private fun findFirstParserWithMatchedParamLabel(parseResult: CommandLine.ParseResult, paramLabel: String): CommandLine.ParseResult? {\n            val found = parseResult.matchedOptions().find { it.paramLabel() == paramLabel }\n            if (found != null) {\n                return parseResult\n            }\n\n            parseResult.subcommands().forEach {\n                return findFirstParserWithMatchedParamLabel(it, paramLabel) ?: return@forEach\n            }\n\n            return null\n        }\n\n        private fun applyCLIMixin(parseResult: CommandLine.ParseResult) {\n            // Find the first mixin for which of the enable-ansi parameter was specified\n            val parserWithANSIOption = findFirstParserWithMatchedParamLabel(parseResult, \"<enableANSIOutput>\")\n            val mixin = parserWithANSIOption?.commandSpec()?.mixins()?.values?.firstNotNullOfOrNull { it.userObject() as? DisableAnsiMixin }\n\n            val stdoutIsTTY = CLibrary.isatty(CLibrary.STDOUT_FILENO) != 0\n            ansiEnabled = mixin?.enableANSIOutput // Use the param value if it was specified\n                ?: stdoutIsTTY // Otherwise fall back to checking if output is a tty\n\n            Ansi.setEnabled(ansiEnabled)\n\n            if (ansiEnabled) {\n                AnsiConsole.systemInstall()\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/ShowHelpMixin.kt",
    "content": "package maestro.cli\n\nimport picocli.CommandLine\n\nclass ShowHelpMixin {\n    @CommandLine.Option(\n        names = [\"-h\", \"--help\"],\n        usageHelp = true,\n        description = [\"Display help message\"],\n    )\n    var help = false\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/analytics/Analytics.kt",
    "content": "package maestro.cli.analytics\n\nimport com.fasterxml.jackson.databind.DeserializationFeature\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport com.posthog.server.PostHog\nimport com.posthog.server.PostHogConfig\nimport com.posthog.server.PostHogInterface\nimport maestro.auth.ApiKey\nimport maestro.cli.api.ApiClient\nimport maestro.cli.util.EnvUtils\nimport org.slf4j.LoggerFactory\nimport java.nio.file.Path\nimport java.util.concurrent.Executors\nimport java.util.concurrent.TimeUnit\nimport kotlin.String\n\nobject Analytics : AutoCloseable {\n    private const val POSTHOG_API_KEY: String = \"phc_XKhdIS7opUZiS58vpOqbjzgRLFpi0I6HU2g00hR7CVg\"\n    private const val POSTHOG_HOST: String = \"https://us.i.posthog.com\"\n    private const val DISABLE_ANALYTICS_ENV_VAR = \"MAESTRO_CLI_NO_ANALYTICS\"\n    private val JSON = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)\n    private val apiClient = ApiClient(EnvUtils.BASE_API_URL)\n    private val posthog: PostHogInterface = PostHog.with(\n        PostHogConfig.builder(POSTHOG_API_KEY)\n            .host(POSTHOG_HOST)\n            .build()\n    )\n\n    private val logger = LoggerFactory.getLogger(Analytics::class.java)\n    private val analyticsStatePath: Path = EnvUtils.xdgStateHome().resolve(\"analytics.json\")\n    private val analyticsStateManager = AnalyticsStateManager(analyticsStatePath)\n\n    // Simple executor for analytics events - following ErrorReporter pattern\n    private val executor = Executors.newCachedThreadPool {\n        Executors.defaultThreadFactory().newThread(it).apply { isDaemon = true }\n    }\n\n    private val analyticsDisabledWithEnvVar: Boolean\n      get() = System.getenv(DISABLE_ANALYTICS_ENV_VAR) != null\n\n    val hasRunBefore: Boolean\n        get() = analyticsStateManager.hasRunBefore()\n\n    val uuid: String\n        get() = analyticsStateManager.getState().uuid\n\n\n    /**\n     * Super properties to be sent with the event\n     */\n    private val superProperties = SuperProperties.create()\n\n    /**\n     * Call initially just to inform user and set a default state\n     */\n    fun warnAndEnableAnalyticsIfNotDisable() {\n        if (hasRunBefore) return\n        val analyticsShouldBeEnabled = !analyticsDisabledWithEnvVar\n        if (analyticsShouldBeEnabled)\n            println(\"Anonymous analytics enabled. To opt out, set $DISABLE_ANALYTICS_ENV_VAR environment variable to any value before running Maestro.\\n\")\n        analyticsStateManager.saveInitialState(granted = analyticsShouldBeEnabled, uuid = uuid)\n    }\n\n    /**\n     * Identify user in PostHog and update local state.\n     *\n     * This function:\n     * 1. Sends user identification to PostHog analytics\n     * 2. Updates local analytics state with user info\n     * 3. Tracks login event for analytics\n     *\n     * Should only be called when user identity changes (login/logout).\n     */\n    fun identifyAndUpdateState(token: String) {\n        try {\n            val user = apiClient.getUser(token)\n            val org =  apiClient.getOrg(token)\n\n            // Update local state with user info\n            val updatedAnalyticsState = analyticsStateManager.updateState(token, user, org)\n            val identifyProperties = UserProperties.fromAnalyticsState(updatedAnalyticsState).toMap()\n\n            // Send identification to PostHog\n            posthog.identify(analyticsStateManager.getState().uuid, identifyProperties)\n            // Track user authentication event\n            val isFirstAuth = analyticsStateManager.getState().cachedToken == null\n            trackEvent(UserAuthenticatedEvent(\n                isFirstAuth = isFirstAuth,\n                authMethod = \"oauth\"\n            ))\n        } catch (e: Exception) {\n            // Analytics failures should never break CLI functionality or show errors to users\n            logger.trace(\"Failed to identify user: ${e.message}\", e)\n        }\n    }\n\n    /**\n     * Conditionally identify user based on current and cashed token\n     */\n    fun identifyUserIfNeeded() {\n        // No identification needed if token is null\n        val token = ApiKey.getToken() ?: return\n        val cachedToken = analyticsStateManager.getState().cachedToken\n        // No identification needed if token is same as cachedToken\n        if (!cachedToken.isNullOrEmpty() && (token == cachedToken)) return\n        // Else Update identification\n        identifyAndUpdateState(token)\n    }\n\n    /**\n     * Track events asynchronously to prevent blocking CLI operations\n     * Use this for important events like authentication, errors, test results, etc.\n     * This method is \"fire and forget\" - it will never block the calling thread\n     */\n    fun trackEvent(event: PostHogEvent) {\n        executor.submit {\n            try {\n                if (!analyticsStateManager.getState().enabled || analyticsDisabledWithEnvVar) return@submit\n\n                identifyUserIfNeeded()\n\n                // Include super properties in each event since PostHog Java client doesn't have register\n                val eventData = convertEventToEventData(event)\n                val userState = analyticsStateManager.getState()\n                val groupProperties = userState.orgId?.let { orgId ->\n                   mapOf(\n                       \"\\$groups\" to mapOf(\n                           \"company\" to orgId\n                       )\n                   )\n                } ?: emptyMap()\n                val properties =\n                    eventData.properties +\n                    superProperties.toMap() +\n                    UserProperties.fromAnalyticsState(userState).toMap() +\n                    groupProperties\n\n                // Send Event\n                posthog.capture(\n                    uuid,\n                    eventData.eventName,\n                    properties\n                )\n            } catch (e: Exception) {\n                // Analytics failures should never break CLI functionality\n                logger.trace(\"Failed to track event ${event.name}: ${e.message}\", e)\n            }\n        }\n    }\n\n    /**\n     * Flush pending PostHog events immediately\n     * Use this when you need to ensure events are sent before continuing\n     */\n    fun flush() {\n        try {\n            posthog.flush()\n        } catch (e: Exception) {\n            // Analytics failures should never break CLI functionality or show errors to users\n            logger.trace(\"Failed to flush PostHog: ${e.message}\", e)\n        }\n    }\n\n    /**\n     * Convert a PostHogEvent to EventData with eventName and properties separated\n     * This allows for clean destructuring in the calling code\n     */\n    private fun convertEventToEventData(event: PostHogEvent): EventData {\n        return try {\n            // Use Jackson to convert the data class to a Map\n            val jsonString = JSON.writeValueAsString(event)\n            val eventMap = JSON.readValue(jsonString, Map::class.java) as Map<String, Any>\n\n            // Extract the name and create properties without it\n            val eventName = event.name\n            val properties = eventMap.filterKeys { it != \"name\" }\n\n            EventData(eventName, properties)\n        } catch (e: Exception) {\n            // Analytics failures should never break CLI functionality or show errors to users\n            logger.trace(\"Failed to serialize event ${event.name}: ${e.message}\", e)\n            EventData(event.name, mapOf())\n        }\n    }\n\n   /**\n    * Close and cleanup resources\n    * Ensures pending analytics events are sent before shutdown\n    */\n    override fun close() {\n        // First, flush any pending PostHog events before shutting down threads\n        flush()\n\n        // Now shutdown PostHog to cleanup resources\n        try {\n            posthog.close()\n        } catch (e: Exception) {\n            // Analytics failures should never break CLI functionality or show errors to users\n            logger.trace(\"Failed to close PostHog: ${e.message}\", e)\n        }\n\n        // Now shutdown the executor\n        try {\n            executor.shutdown()\n            if (!executor.awaitTermination(2, TimeUnit.SECONDS)) {\n                // Analytics failures should never break CLI functionality or show errors to users\n                logger.trace(\"Analytics executor did not shutdown gracefully, forcing shutdown\")\n                executor.shutdownNow()\n            }\n        } catch (e: InterruptedException) {\n            executor.shutdownNow()\n            Thread.currentThread().interrupt()\n        }\n    }\n}\n\n/**\n * Data class to hold event name and properties for destructuring\n */\ndata class EventData(\n    val eventName: String,\n    val properties: Map<String, Any>\n)\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/analytics/AnalyticsStateManager.kt",
    "content": "package maestro.cli.analytics\n\nimport com.fasterxml.jackson.annotation.JsonFormat\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties\nimport com.fasterxml.jackson.databind.SerializationFeature\nimport com.fasterxml.jackson.datatype.jsr310.JavaTimeModule\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport com.fasterxml.jackson.module.kotlin.readValue\nimport maestro.cli.api.OrgResponse\nimport maestro.cli.api.UserResponse\nimport maestro.cli.util.CiUtils\nimport maestro.cli.util.EnvUtils\nimport org.slf4j.LoggerFactory\nimport java.nio.file.Path\nimport java.time.Instant\nimport java.time.LocalDate\nimport java.time.format.DateTimeFormatter\nimport java.time.format.DateTimeParseException\nimport java.util.*\nimport kotlin.String\nimport kotlin.io.path.exists\nimport kotlin.io.path.readText\nimport kotlin.io.path.writeText\n\n\n@JsonIgnoreProperties(ignoreUnknown = true)\ndata class AnalyticsState(\n    val uuid: String,\n    val enabled: Boolean,\n    val cachedToken: String? = null,\n    val lastUploadedForCLI: String? = null,\n    @JsonFormat(shape = JsonFormat.Shape.STRING, timezone = \"UTC\") val lastUploadedTime: Instant?,\n    val email: String? = null,\n    val user_id: String? = null,\n    val name: String? = null,\n    val workOSOrgId: String? = null,\n    val orgId: String? = null,\n    val orgName: String? = null,\n    val orgPlan: String? = null,\n    val orgTrialExpiresOn: String? = null,\n    // Org status properties\n    val orgStatus: OrgStatus? = null,\n    val currentPlan: OrgPlans? = null,\n    val isInTrial: Boolean? = null,\n    val daysUntilTrialExpiry: Int? = null,\n    val daysUntilGracePeriodExpiry: Int? = null\n)\n\n/**\n * Manages analytics state persistence and caching.\n * Separated from Analytics object to improve separation of concerns.\n */\nclass AnalyticsStateManager(\n    private val analyticsStatePath: Path\n) {\n    private val logger = LoggerFactory.getLogger(AnalyticsStateManager::class.java)\n    \n    private val JSON = jacksonObjectMapper().apply {\n        registerModule(JavaTimeModule())\n        enable(SerializationFeature.INDENT_OUTPUT)\n    }\n\n    private var _analyticsState: AnalyticsState? = null\n\n    fun getState(): AnalyticsState {\n        if (_analyticsState == null) {\n            _analyticsState = loadState()\n        }\n        return _analyticsState!!\n    }\n\n    fun hasRunBefore(): Boolean {\n        return analyticsStatePath.exists()\n    }\n\n    fun updateState(\n        token: String,\n        user: UserResponse,\n        org: OrgResponse,\n    ): AnalyticsState {\n        val currentState = getState()\n        val updatedState = currentState.copy(\n            cachedToken = token,\n            lastUploadedForCLI = EnvUtils.CLI_VERSION?.toString(),\n            lastUploadedTime = Instant.now(),\n            user_id = user.id,\n            email = user.email,\n            name = user.name,\n            workOSOrgId = user.workOSOrgId,\n            orgId = org.id,\n            orgName = org.name,\n            orgPlan = org.metadata?.get(\"pricing_plan\"),\n        ).addOrgStatusProperties(org)\n        saveState(updatedState)\n        return updatedState\n    }\n\n    fun saveInitialState(\n        granted: Boolean,\n        uuid: String? = null,\n    ): AnalyticsState {\n        val state = AnalyticsState(\n          uuid = uuid ?: generateUUID(),\n          enabled = granted,\n          lastUploadedTime = null\n        )\n        saveState(state)\n        return state\n    }\n\n    private fun saveState(state: AnalyticsState) {\n        val stateJson = JSON.writeValueAsString(state)\n        analyticsStatePath.parent.toFile().mkdirs()\n        analyticsStatePath.writeText(stateJson + \"\\n\")\n        logger.trace(\"Saved analytics to {}, value: {}\", analyticsStatePath, stateJson)\n        \n        // Refresh the cached state\n        _analyticsState = state\n    }\n\n    private fun loadState(): AnalyticsState {\n        return try {\n            if (analyticsStatePath.exists()) {\n                JSON.readValue(analyticsStatePath.readText())\n            } else {\n                createDefaultState()\n            }\n        } catch (e: Exception) {\n            logger.warn(\"Failed to read analytics state: ${e.message}. Using default.\")\n            createDefaultState()\n        }\n    }\n\n    private fun createDefaultState(): AnalyticsState {\n        return AnalyticsState(\n          uuid = generateUUID(),\n          enabled = false,\n          lastUploadedTime = null,\n        )\n    }\n\n    private fun generateUUID(): String {\n        return CiUtils.getCiProvider() ?: UUID.randomUUID().toString()\n    }\n}\n\n/**\n * Extension function to add organization status to AnalyticsState\n */\nfun AnalyticsState.addOrgStatusProperties(org: OrgResponse?): AnalyticsState {\n    if (org == null) return this\n\n    val orgStatus = getOrgStatus(org)\n    val pricingPlan = org.metadata?.get(\"pricing_plan\")\n\n    // Trial status requires checking both plan type and org status\n    val isInTrial = pricingPlan == \"BASIC\" && orgStatus == OrgStatus.ACTIVE && org.metadata[\"trial_expires_on\"] != null\n\n    return this.copy(\n      orgStatus = orgStatus,\n      currentPlan = pricingPlan?.let { OrgPlans.valueOf(it) },\n      isInTrial = isInTrial,\n      daysUntilTrialExpiry = if (isInTrial) calculateDaysUntil(org.metadata[\"trial_expires_on\"]) else null,\n      daysUntilGracePeriodExpiry = if (orgStatus == OrgStatus.IN_GRACE_PERIOD) calculateDaysUntil(org.metadata?.get(\"subscription_grace_period\")) else null\n    )\n}\n\n/**\n * Helper function to get organization status\n */\nprivate fun getOrgStatus(org: OrgResponse?): OrgStatus? {\n    if (org == null) return null\n    if (org.metadata == null) return null\n\n    val trialExpirationDate = org.metadata.get(\"trial_expires_on\")\n    val pricingPlan = org.metadata.get(\"pricing_plan\")\n    val gracePeriod = org.metadata.get(\"subscription_grace_period\")\n\n    if (gracePeriod != null) {\n        val graceDate = parseDate(gracePeriod)\n        if (graceDate != null) {\n            val now = LocalDate.now()\n            // If grace period is in the past, expired\n            return if (graceDate.isBefore(now)) {\n              OrgStatus.GRACE_PERIOD_EXPIRED\n            } else {\n              OrgStatus.IN_GRACE_PERIOD\n            }\n        } else {\n            // If we can't parse the date, assume it's active\n            return OrgStatus.IN_GRACE_PERIOD\n        }\n    }\n\n    if (pricingPlan == \"BASIC\" && trialExpirationDate != null) {\n        val trialDate = parseDate(trialExpirationDate)\n        if (trialDate != null) {\n            val now = LocalDate.now()\n            if (trialDate.isBefore(now)) {\n                return OrgStatus.TRIAL_EXPIRED\n            } else {\n                return OrgStatus.ACTIVE\n            }\n        } else {\n            // If we can't parse the date, assume trial is active\n            return OrgStatus.ACTIVE\n        }\n    }\n\n    if (pricingPlan == \"BASIC\") {\n        return OrgStatus.TRIAL_NOT_ACTIVE\n    }\n\n    if (listOf(\"CLOUD_MANUAL\", \"ENTERPRISE\", \"CLOUD\").contains(pricingPlan)) {\n        return OrgStatus.ACTIVE\n    }\n\n    return OrgStatus.TRIAL_NOT_ACTIVE\n}\n\n/**\n * Helper function to parse dates in multiple formats\n */\nprivate fun parseDate(dateString: String?): LocalDate? {\n    if (dateString == null) return null\n    \n    val formatters = listOf(\n        DateTimeFormatter.ISO_LOCAL_DATE, // \"2030-02-19\"\n        DateTimeFormatter.ofPattern(\"MMM d yyyy\", Locale.ENGLISH), // \"Sep 1 2025\"\n        DateTimeFormatter.ofPattern(\"MMM dd yyyy\", Locale.ENGLISH), // \"Sep 01 2025\" (with zero-padded day)\n        DateTimeFormatter.ofPattern(\"MMMM d yyyy\", Locale.ENGLISH), // \"September 1 2025\"\n        DateTimeFormatter.ofPattern(\"MMMM dd yyyy\", Locale.ENGLISH), // \"September 01 2025\" (with zero-padded day)\n        DateTimeFormatter.ofPattern(\"MM/dd/yyyy\"), // \"02/19/2030\"\n        DateTimeFormatter.ofPattern(\"dd/MM/yyyy\"), // \"19/02/2030\"\n        DateTimeFormatter.ofPattern(\"yyyy-MM-dd\"), // \"2030-02-19\"\n    )\n    \n    for (formatter in formatters) {\n        try {\n            return LocalDate.parse(dateString, formatter)\n        } catch (e: DateTimeParseException) {\n            // Try next formatter\n        }\n    }\n    return null\n}\n\n/**\n * Helper function to calculate days until a date\n */\nprivate fun calculateDaysUntil(dateString: String?): Int? {\n    if (dateString == null) return null\n    val targetDate = parseDate(dateString) ?: return null\n    val now = LocalDate.now()\n    return java.time.temporal.ChronoUnit.DAYS.between(now, targetDate).toInt()\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/analytics/PostHogEvents.kt",
    "content": "package maestro.cli.analytics\n\nimport maestro.cli.model.FlowStatus\nimport maestro.cli.util.EnvUtils\nimport maestro.cli.util.IOSEnvUtils\nimport maestro.device.util.AndroidEnvUtils\n\n/**\n * Organization status enum\n */\nenum class OrgStatus(val value: String) {\n    TRIAL_EXPIRED(\"TRIAL_EXPIRED\"),\n    GRACE_PERIOD_EXPIRED(\"GRACE_PERIOD_EXPIRED\"),\n    ACTIVE(\"ACTIVE\"),\n    TRIAL_NOT_ACTIVE(\"TRIAL_NOT_ACTIVE\"),\n    IN_GRACE_PERIOD(\"IN_GRACE_PERIOD\");\n}\n\nenum class OrgPlans(val value: String) {\n    BASIC(\"BASIC\"),\n    CLOUD(\"CLOUD\"),\n    CLOUD_MANUAL(\"CLOUD_MANUAL\"),\n    ENTERPRISE(\"ENTERPRISE\"),\n}\n\n/**\n * Strongly-typed PostHog events for Maestro CLI using discriminated unions\n * This ensures compile-time type safety for all analytics events\n */\n\n/**\n * Super properties that are automatically included with every event\n */\ndata class SuperProperties(\n    val app_version: String,\n    val platform: String,\n    val env: String,\n    val app: String = \"cli\",\n    val cli_version: String,\n    val java_version: String,\n    val os_arch: String,\n    val os_version: String,\n    val xcode_version: String? = null,\n    val flutter_version: String? = null,\n    val flutter_channel: String? = null,\n    val android_versions: List<String>? = null,\n    val ios_versions: List<String>? = null,\n) {\n    /**\n     * Convert to Map for analytics tracking\n     */\n    fun toMap(): Map<String, Any> {\n        return mapOf(\n            \"app_version\" to app_version,\n            \"platform\" to platform,\n            \"env\" to env,\n            \"app\" to app,\n            \"cli_version\" to cli_version,\n            \"java_version\" to java_version,\n            \"os_arch\" to os_arch,\n            \"os_version\" to os_version,\n            \"xcode_version\" to xcode_version,\n            \"flutter_version\" to flutter_version,\n            \"flutter_channel\" to flutter_channel,\n            \"android_versions\" to android_versions,\n            \"ios_versions\" to ios_versions\n        ) as Map<String, Any>\n    }\n    \n    /**\n     * Create SuperProperties with current system information\n     */\n    companion object {\n        fun create(): SuperProperties {\n            return SuperProperties(\n                app_version = EnvUtils.getVersion().toString(),\n                platform = EnvUtils.OS_NAME,\n                env = if (System.getenv(\"MAESTRO_API_URL\") != null) \"dev\" else \"prod\",\n                app = \"cli\",\n                cli_version = EnvUtils.CLI_VERSION.toString(),\n                java_version = EnvUtils.getJavaVersion().toString(),\n                os_arch = EnvUtils.OS_ARCH,\n                os_version = EnvUtils.OS_VERSION,\n                xcode_version = IOSEnvUtils.xcodeVersion,\n                flutter_version = EnvUtils.getFlutterVersionAndChannel().first,\n                flutter_channel = EnvUtils.getFlutterVersionAndChannel().second,\n                android_versions = AndroidEnvUtils.androidEmulatorSdkVersions,\n                ios_versions = IOSEnvUtils.simulatorRuntimes\n            )\n        }\n    }\n}\n\n/**\n * User properties for user identification\n */\ndata class UserProperties(\n    val user_id: String?,\n    val email: String?,\n    val name: String?,\n    val organizationId: String? = null,\n    val org_id: String?,\n    val org_name: String?,\n    val plan: String?,\n    val orgPlan: String?,\n    val orgTrialExpiresOn: String?,\n) {\n    /**\n     * Convert to Map for analytics tracking\n     */\n    fun toMap(): Map<String, Any> {\n        return mapOf(\n            \"user_id\" to user_id,\n            \"email\" to email,\n            \"name\" to name,\n            \"organizationId\" to organizationId,\n            \"org_id\" to org_id,\n            \"org_name\" to org_name,\n            \"plan\" to plan,\n            \"orgPlan\" to orgPlan,\n            \"orgTrialExpiresOn\" to orgTrialExpiresOn\n        ) as Map<String, Any>\n    }\n    /**\n     * Create UserProperties from AnalyticsState\n     */\n    companion object {\n        fun fromAnalyticsState(state: AnalyticsState): UserProperties {\n            return UserProperties(\n                user_id = state.user_id,\n                email = state.email,\n                name = state.name,\n                organizationId = state.orgId,\n                org_id = state.orgId,\n                org_name = state.orgName,\n                plan = state.orgPlan,\n                orgPlan = state.orgPlan,\n                orgTrialExpiresOn = state.orgTrialExpiresOn,\n            )\n        }\n    }\n}\n\n/**\n * Base interface for all PostHog events\n */\nsealed interface PostHogEvent {\n    val name: String\n}\n\n/**\n * CLI Usage Events\n */\nsealed interface CliUsageEvent : PostHogEvent\n\ndata class CliCommandRunEvent(\n  override val name: String = \"maestro_cli_command_run\",\n  val command: String\n) : CliUsageEvent\n\n/**\n * Test execution events\n */\nsealed interface TestRunEvent : PostHogEvent\n\ndata class TestRunStartedEvent(\n    override val name: String = \"test_run_started\",\n    val platform: String,\n) : TestRunEvent\n\ndata class TestRunFailedEvent(\n    override val name: String = \"test_run_failed\",\n    val error: String,\n    val platform: String,\n) : TestRunEvent\n\ndata class TestRunFinishedEvent(\n    override val name: String = \"test_run_finished\",\n    val status: FlowStatus,\n    val platform: String,\n    val durationMs: Long\n) : TestRunEvent\n\n\n/**\n * Workspace execution events\n */\nsealed interface WorkspaceRunEvent : PostHogEvent\n\ndata class WorkspaceRunStartedEvent(\n    override val name: String = \"workspace_run_started\",\n    val flowCount: Int,\n    val platform: String,\n    val deviceCount: Int\n) : WorkspaceRunEvent\n\ndata class WorkspaceRunFailedEvent(\n    override val name: String = \"workspace_run_failed\",\n    val error: String,\n    val flowCount: Int,\n    val platform: String,\n    val deviceCount: Int,\n) : WorkspaceRunEvent\n\ndata class WorkspaceRunFinishedEvent(\n    override val name: String = \"workspace_run_finished\",\n    val flowCount: Int,\n    val platform: String,\n    val deviceCount: Int,\n    val durationMs: Long\n) : WorkspaceRunEvent\n\n/**\n * Record Screen Event\n */\nsealed interface RecordEvent : PostHogEvent\n\ndata class RecordStartedEvent(\n    override val name: String = \"maestro_cli_record_start\",\n    val platform: String,\n) : RecordEvent\n\ndata class RecordFinishedEvent(\n  override val name: String = \"maestro_cli_record_finished\",\n  val platform: String,\n  val durationMs: Long\n) : RecordEvent\n\n/**\n * Cloud Upload Events\n */\nsealed interface CloudUploadEvent : PostHogEvent\n\ndata class CloudUploadTriggeredEvent(\n    override val name: String = \"cloud_upload_triggered\",\n    val projectId: String,\n    val isBinaryUpload: Boolean = false,\n    val usesEnvironment: Boolean = false,\n    val deviceModel: String? = null,\n    val deviceOs: String? = null\n) : CloudUploadEvent\n\ndata class CloudUploadStartedEvent(\n    override val name: String = \"cloud_upload_started\",\n    val projectId: String,\n    val isBinaryUpload: Boolean = false,\n    val usesEnvironment: Boolean = false,\n    val platform: String,\n    val deviceModel: String? = null,\n    val deviceOs: String? = null\n) : CloudUploadEvent\n\ndata class CloudUploadSucceededEvent(\n    override val name: String = \"cloud_upload_succeeded\",\n    val projectId: String,\n    val platform: String,\n    val isBinaryUpload: Boolean,\n    val usesEnvironment: Boolean,\n    val deviceModel: String? = null,\n    val deviceOs: String? = null,\n) : CloudUploadEvent\n\n/**\n * Cloud Run Event\n */\nsealed interface CloudRunEvent : PostHogEvent\n\ndata class CloudRunFinishedEvent(\n    override val name: String = \"cloud_run_finished\",\n    val projectId: String,\n    val appPackageId: String,\n    val totalFlows: Number,\n    val totalPassedFlows: Number,\n    val totalFailedFlows: Number,\n    val wasAppLaunched: Boolean\n) : CloudRunEvent\n\n/**\n * User Auth Event\n */\n\nsealed interface AuthEvent : PostHogEvent\n\ndata class UserAuthenticatedEvent(\n    override val name: String = \"user_authenticated\",\n    val isFirstAuth: Boolean,\n    val authMethod: String,\n) : AuthEvent\n\ndata class UserLoggedOutEvent(\n  override val name: String = \"user_logged_out\",\n) : AuthEvent\n\n/**\n * Print Hierarchy Events\n */\nsealed interface PrintHierarchyEvent : PostHogEvent\n\ndata class PrintHierarchyStartedEvent(\n    override val name: String = \"print_hierarchy_started\",\n    val platform: String\n) : PrintHierarchyEvent\n\ndata class PrintHierarchyFinishedEvent(\n    override val name: String = \"print_hierarchy_finished\",\n    val platform: String,\n    val success: Boolean,\n    val durationMs: Long,\n    val errorMessage: String? = null\n) : PrintHierarchyEvent\n\n/**\n * Trial Events\n */\nsealed interface TrialEvent : PostHogEvent\n\ndata class TrialStartPromptedEvent(\n    override val name: String = \"trial_start_prompted\",\n) : TrialEvent\n\ndata class TrialStartedEvent(\n    override val name: String = \"trial_started\",\n    val companyName: String,\n) : TrialEvent\n\ndata class TrialStartFailedEvent(\n    override val name: String = \"trial_start_failed\",\n    val companyName: String,\n    val failureReason: String,\n) : TrialEvent\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/api/ApiClient.kt",
    "content": "package maestro.cli.api\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties\nimport com.fasterxml.jackson.core.type.TypeReference\nimport com.fasterxml.jackson.databind.DeserializationFeature\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport com.github.michaelbull.result.Err\nimport com.github.michaelbull.result.Ok\nimport com.github.michaelbull.result.Result\nimport maestro.cli.CliError\nimport maestro.cli.analytics.Analytics\nimport maestro.cli.analytics.TrialStartedEvent\nimport maestro.cli.analytics.TrialStartFailedEvent\nimport maestro.cli.analytics.TrialStartPromptedEvent\nimport maestro.cli.insights.AnalysisDebugFiles\nimport maestro.cli.model.FlowStatus\nimport maestro.cli.runner.resultview.AnsiResultView\nimport maestro.cli.util.CiUtils\nimport maestro.cli.util.EnvUtils\nimport maestro.cli.util.PrintUtils\nimport maestro.cli.view.brightRed\nimport maestro.cli.view.cyan\nimport maestro.cli.view.green\nimport maestro.utils.HttpClient\nimport okhttp3.Interceptor\nimport okhttp3.MediaType.Companion.toMediaType\nimport okhttp3.MultipartBody\nimport okhttp3.Protocol\nimport okhttp3.Request\nimport okhttp3.RequestBody\nimport okhttp3.RequestBody.Companion.asRequestBody\nimport okhttp3.RequestBody.Companion.toRequestBody\nimport okhttp3.Response\nimport okio.Buffer\nimport okio.BufferedSink\nimport okio.ForwardingSink\nimport okio.IOException\nimport okio.buffer\nimport java.io.File\nimport java.nio.file.Path\nimport java.util.Scanner\nimport kotlin.io.path.absolutePathString\nimport kotlin.io.path.exists\nimport kotlin.time.Duration.Companion.minutes\n\nclass ApiClient(\n    private val baseUrl: String,\n) {\n\n    private val client = HttpClient.build(\n        name = \"ApiClient\",\n        readTimeout = 5.minutes,\n        writeTimeout = 5.minutes,\n        protocols = listOf(Protocol.HTTP_1_1),\n        interceptors = listOf(SystemInformationInterceptor()),\n    )\n\n    val domain: String\n        get() {\n            val regex = \"https?://[^.]+.([a-zA-Z0-9.-]*).*\".toRegex()\n            val matchResult = regex.matchEntire(baseUrl)\n            val domain = if (!matchResult?.groups?.get(1)?.value.isNullOrEmpty()) {\n                matchResult?.groups?.get(1)?.value\n            } else {\n                matchResult?.groups?.get(0)?.value\n            }\n            return domain ?: \"mobile.dev\"\n        }\n\n    fun sendErrorReport(exception: Exception, commandLine: String) {\n        post<Unit>(\n            path = \"/maestro/error\",\n            body = mapOf(\n                \"exception\" to exception,\n                \"commandLine\" to commandLine\n            )\n        )\n    }\n\n    fun sendScreenReport(maxDepth: Int) {\n        post<Unit>(\n            path = \"/maestro/screen\",\n            body = mapOf(\n                \"maxDepth\" to maxDepth\n            )\n        )\n    }\n\n    fun getLatestCliVersion(): CliVersion {\n        val request = Request.Builder()\n            .header(\"X-FRESH-INSTALL\", if (!Analytics.hasRunBefore) \"true\" else \"false\")\n            .url(\"$baseUrl/v2/maestro/version\")\n            .get()\n            .build()\n\n        val response = try {\n            client.newCall(request).execute()\n        } catch (e: IOException) {\n            throw ApiException(statusCode = null)\n        }\n\n        response.use {\n            if (!response.isSuccessful) {\n                throw ApiException(\n                    statusCode = response.code\n                )\n            }\n\n            return JSON.readValue(response.body?.bytes(), CliVersion::class.java)\n        }\n    }\n\n    fun getAuthUrl(port: String): String {\n        return \"$baseUrl/v2/maestroLogin/authUrl?port=$port\"\n    }\n\n    fun exchangeToken(code: String): String {\n        val requestBody = code.toRequestBody(\"text/plain\".toMediaType())\n\n        val request = Request.Builder()\n            .url(\"$baseUrl/v2/maestroLogin/exchange\")\n            .post(requestBody)\n            .build()\n\n        try {\n            client.newCall(request).execute().use { response ->\n                val responseBody = response.body?.string()\n                println(responseBody ?: \"No response body received\")\n                if (!response.isSuccessful) {\n                    throw IOException(\"HTTP ${response.code}: ${response.message}\\nBody: $responseBody\")\n                }\n                return responseBody ?: throw IOException(\"Empty response body\")\n            }\n        } catch (e: Exception) {\n            throw IOException(\"${e.message}\", e)\n        }\n    }\n\n    fun isAuthTokenValid(authToken: String): Boolean {\n        val request = Request.Builder()\n            .url(\"$baseUrl/v2/maestroLogin/valid\")\n            .header(\"Authorization\", \"Bearer $authToken\")\n            .get()\n            .build()\n\n        client.newCall(request).execute().use { response ->\n            return !(!response.isSuccessful && (response.code == 401 || response.code == 403))\n        }\n    }\n\n    private fun getAgent(): String {\n        return CiUtils.getCiProvider() ?: \"cli\"\n    }\n\n    fun uploadStatus(\n        authToken: String,\n        uploadId: String,\n        projectId: String?,\n    ): UploadStatus {\n        val baseUrl = \"$baseUrl/v2/project/$projectId/upload/$uploadId\"\n\n        val request = Request.Builder()\n            .header(\"Authorization\", \"Bearer $authToken\")\n            .url(baseUrl)\n            .get()\n            .build()\n\n        val response = try {\n            client.newCall(request).execute()\n        } catch (e: IOException) {\n            throw ApiException(statusCode = null)\n        }\n\n        response.use {\n            if (!response.isSuccessful) {\n                throw ApiException(\n                    statusCode = response.code\n                )\n            }\n\n            return JSON.readValue(response.body?.bytes(), UploadStatus::class.java)\n        }\n    }\n\n    fun render(\n        screenRecording: File,\n        frames: List<AnsiResultView.Frame>,\n        progressListener: (totalBytes: Long, bytesWritten: Long) -> Unit = { _, _ -> },\n    ): String {\n        val baseUrl = \"https://maestro-record.ngrok.io\"\n        val body = MultipartBody.Builder()\n            .setType(MultipartBody.FORM)\n            .addFormDataPart(\n                \"screenRecording\",\n                screenRecording.name,\n                screenRecording.asRequestBody(\"application/mp4\".toMediaType()).observable(progressListener)\n            )\n            .addFormDataPart(\"frames\", JSON.writeValueAsString(frames))\n            .build()\n        val request = Request.Builder()\n            .url(\"$baseUrl/render\")\n            .post(body)\n            .build()\n        val response = client.newCall(request).execute().use { response ->\n            if (!response.isSuccessful) {\n                throw CliError(\"Render request failed (${response.code}): ${response.body?.string()}\")\n            }\n            JSON.readValue(response.body?.bytes(), RenderResponse::class.java)\n        }\n        return response.id\n    }\n\n    fun getRenderState(id: String): RenderState {\n        val baseUrl = \"https://maestro-record.ngrok.io\"\n        val request = Request.Builder()\n            .url(\"$baseUrl/v2/render/$id\")\n            .get()\n            .build()\n        val response = client.newCall(request).execute().use { response ->\n            if (!response.isSuccessful) {\n                throw CliError(\"Get render state request failed (${response.code}): ${response.body?.string()}\")\n            }\n            JSON.readValue(response.body?.bytes(), RenderState::class.java)\n        }\n        val downloadUrl = if (response.downloadUrl == null) null else \"$baseUrl${response.downloadUrl}\"\n        return response.copy(downloadUrl = downloadUrl)\n    }\n\n    fun upload(\n        authToken: String,\n        appFile: Path?,\n        workspaceZip: Path,\n        uploadName: String?,\n        mappingFile: Path?,\n        repoOwner: String?,\n        repoName: String?,\n        branch: String?,\n        commitSha: String?,\n        pullRequestId: String?,\n        env: Map<String, String>? = null,\n        appBinaryId: String? = null,\n        includeTags: List<String> = emptyList(),\n        excludeTags: List<String> = emptyList(),\n        maxRetryCount: Int = 3,\n        completedRetries: Int = 0,\n        disableNotifications: Boolean,\n        deviceLocale: String? = null,\n        progressListener: (totalBytes: Long, bytesWritten: Long) -> Unit = { _, _ -> },\n        projectId: String,\n        deviceModel: String? = null,\n        deviceOs: String? = null,\n        androidApiLevel: Int?,\n        iOSVersion: String? = null,\n    ): UploadResponse {\n        if (appBinaryId == null && appFile == null) throw CliError(\"Missing required parameter for option '--app-file' or '--app-binary-id'\")\n        if (appFile != null && !appFile.exists()) throw CliError(\"App file does not exist: ${appFile.absolutePathString()}\")\n        if (!workspaceZip.exists()) throw CliError(\"Workspace zip does not exist: ${workspaceZip.absolutePathString()}\")\n\n        val requestPart = mutableMapOf<String, Any>()\n        if (uploadName != null) {\n            requestPart[\"benchmarkName\"] = uploadName\n        }\n        repoOwner?.let { requestPart[\"repoOwner\"] = it }\n        repoName?.let { requestPart[\"repoName\"] = it }\n        branch?.let { requestPart[\"branch\"] = it }\n        commitSha?.let { requestPart[\"commitSha\"] = it }\n        pullRequestId?.let { requestPart[\"pullRequestId\"] = it }\n        env?.let { requestPart[\"env\"] = it }\n        requestPart[\"agent\"] = getAgent()\n        appBinaryId?.let { requestPart[\"appBinaryId\"] = it }\n        deviceLocale?.let { requestPart[\"deviceLocale\"] = it }\n        requestPart[\"projectId\"] = projectId\n        deviceModel?.let { requestPart[\"deviceModel\"] = it }\n        deviceOs?.let { requestPart[\"deviceOs\"] = it }\n        if (includeTags.isNotEmpty()) requestPart[\"includeTags\"] = includeTags\n        if (excludeTags.isNotEmpty()) requestPart[\"excludeTags\"] = excludeTags\n        if (disableNotifications) requestPart[\"disableNotifications\"] = true\n\n        val bodyBuilder = MultipartBody.Builder()\n            .setType(MultipartBody.FORM)\n            .addFormDataPart(\n                \"workspace\",\n                \"workspace.zip\",\n                workspaceZip.toFile().asRequestBody(\"application/zip\".toMediaType())\n            )\n            .addFormDataPart(\"request\", JSON.writeValueAsString(requestPart))\n\n        if (appFile != null) {\n            bodyBuilder.addFormDataPart(\n                \"app_binary\",\n                \"app.zip\",\n                appFile.toFile().asRequestBody(\"application/zip\".toMediaType()).observable(progressListener)\n            )\n        }\n\n        if (mappingFile != null) {\n            bodyBuilder.addFormDataPart(\n                \"mapping\",\n                \"mapping.txt\",\n                mappingFile.toFile().asRequestBody(\"text/plain\".toMediaType())\n            )\n        }\n\n        val body = bodyBuilder.build()\n\n        fun retry(message: String, e: Throwable? = null): UploadResponse {\n            if (completedRetries >= maxRetryCount) {\n                e?.printStackTrace()\n                throw CliError(message)\n            }\n\n            PrintUtils.message(\"$message, retrying (${completedRetries + 1}/$maxRetryCount)...\")\n            Thread.sleep(BASE_RETRY_DELAY_MS + (2000 * completedRetries))\n\n            return upload(\n                authToken = authToken,\n                appFile = appFile,\n                workspaceZip = workspaceZip,\n                uploadName = uploadName,\n                mappingFile = mappingFile,\n                repoOwner = repoOwner,\n                repoName = repoName,\n                branch = branch,\n                commitSha = commitSha,\n                pullRequestId = pullRequestId,\n                env = env,\n                includeTags = includeTags,\n                excludeTags = excludeTags,\n                maxRetryCount = maxRetryCount,\n                completedRetries = completedRetries + 1,\n                progressListener = progressListener,\n                appBinaryId = appBinaryId,\n                disableNotifications = disableNotifications,\n                deviceLocale = deviceLocale,\n                projectId = projectId,\n                deviceModel = deviceModel,\n                deviceOs = deviceOs,\n                androidApiLevel = androidApiLevel,\n                iOSVersion = iOSVersion,\n            )\n        }\n\n        val url = \"$baseUrl/v2/project/$projectId/runMaestroTest\"\n\n        val response = try {\n            val request = Request.Builder()\n                .header(\"Authorization\", \"Bearer $authToken\")\n                .url(url)\n                .post(body)\n                .build()\n\n            client.newCall(request).execute()\n        } catch (e: IOException) {\n            return retry(\"Upload failed due to socket exception\", e)\n        }\n\n        response.use {\n            if (!response.isSuccessful) {\n                val errorMessage = response.body?.string().takeIf { it?.isNotEmpty() == true } ?: \"Unknown\"\n\n                if (response.code == 403 && errorMessage.contains(\n                        \"Your trial has not started yet\",\n                        ignoreCase = true\n                    )\n                ) {\n                    Analytics.trackEvent(TrialStartPromptedEvent())\n                    PrintUtils.info(\"\\n[ERROR] Your trial has not started yet\".brightRed())\n                    PrintUtils.info(\"[INFO] Start your 7-day free trial with no credit card required!\".green())\n                    PrintUtils.info(\"${\"[INPUT]\".cyan()} Please enter your company name to start the free trial: \")\n                    \n                    val scanner = Scanner(System.`in`)\n                    val companyName = scanner.nextLine().trim()\n\n                    if (companyName.isNotEmpty()) {\n                        println(\"\\u001B[33;1m[INFO]\\u001B[0m Starting your trial for company: \\u001B[36;1m$companyName\\u001B[0m...\")\n\n                        val isTrialStarted = startTrial(authToken, companyName);\n                        if (isTrialStarted) {\n                            println(\"\\u001B[32;1m[SUCCESS]\\u001B[0m Free trial successfully started! Enjoy your 7-day free trial!\\n\")\n                            return upload(\n                                authToken = authToken,\n                                appFile = appFile,\n                                workspaceZip = workspaceZip,\n                                uploadName = uploadName,\n                                mappingFile = mappingFile,\n                                repoOwner = repoOwner,\n                                repoName = repoName,\n                                branch = branch,\n                                commitSha = commitSha,\n                                pullRequestId = pullRequestId,\n                                env = env,\n                                includeTags = includeTags,\n                                excludeTags = excludeTags,\n                                maxRetryCount = maxRetryCount,\n                                completedRetries = completedRetries + 1,\n                                progressListener = progressListener,\n                                appBinaryId = appBinaryId,\n                                disableNotifications = disableNotifications,\n                                deviceLocale = deviceLocale,\n                                projectId = projectId,\n                                deviceModel = deviceModel,\n                                deviceOs = deviceOs,\n                                androidApiLevel = androidApiLevel,\n                                iOSVersion = iOSVersion,\n                            )\n                        } else {\n                            println(\"\\u001B[31;1m[ERROR]\\u001B[0m Failed to start trial. Please check your details and try again.\")\n                        }\n                    } else {\n                        println(\"\\u001B[31;1m[ERROR]\\u001B[0m Company name is required to start your free trial.\")\n                        // Track trial start failed event for empty company name\n                        Analytics.trackEvent(TrialStartFailedEvent(\n                            companyName = \"\",\n                            failureReason = \"EMPTY_COMPANY_NAME\"\n                        ))\n                    }\n                }\n\n                if (response.code >= 500) {\n                    return retry(\"Upload failed with status code ${response.code}: $errorMessage\")\n                } else {\n                    throw CliError(\"Upload request failed (${response.code}): $errorMessage\")\n                }\n            }\n\n            val responseBody = JSON.readValue(response.body?.bytes(), Map::class.java)\n\n            return parseUploadResponse(responseBody)\n        }\n    }\n\n    private fun startTrial(authToken: String, companyName: String): Boolean {\n        println(\"Starting your trial...\")\n        val url = \"$baseUrl/v2/start-trial\"\n\n        val request = StartTrialRequest(companyName, referralSource = \"cli\")\n        val jsonBody = JSON.writeValueAsString(request).toRequestBody(\"application/json\".toMediaType())\n        val trialRequest = Request.Builder()\n            .header(\"Authorization\", \"Bearer $authToken\")\n            .url(url)\n            .post(jsonBody)\n            .build()\n\n        try {\n            val response = client.newCall(trialRequest).execute()\n            if (response.isSuccessful) {\n                Analytics.trackEvent(TrialStartedEvent(companyName = companyName))\n                return true\n            }\n            val errorMessage = response.body?.string() ?: \"Unknown error\"\n            println(\"\\u001B[31m$errorMessage\\u001B[0m\");\n            // Track trial start failed event\n            Analytics.trackEvent(TrialStartFailedEvent(\n                companyName = companyName,\n                failureReason = \"API_ERROR: $errorMessage\"\n            ))\n            return false\n        } catch (e: IOException) {\n            println(\"\\u001B[31;1m[ERROR]\\u001B[0m We're experiencing connectivity issues, please try again in sometime, reach out to the slack channel in case if this doesn't work.\")\n            // Track trial start failed event\n            Analytics.trackEvent(TrialStartFailedEvent(\n                companyName = companyName,\n                failureReason = \"CONNECTIVITY_ERROR: ${e.message}\"\n            ))\n            return false\n        }\n    }\n\n    private fun parseUploadResponse(responseBody: Map<*, *>): UploadResponse {\n        @Suppress(\"UNCHECKED_CAST\")\n        val orgId = responseBody[\"orgId\"] as String\n        val uploadId = responseBody[\"uploadId\"] as String\n        val appId = responseBody[\"appId\"] as String\n        val appBinaryId = responseBody[\"appBinaryId\"] as String\n\n        val deviceConfigMap = responseBody[\"deviceConfiguration\"] as Map<String, Any>\n        val platform = deviceConfigMap[\"platform\"].toString().uppercase()\n        val deviceConfiguration = DeviceConfiguration(\n            platform = platform,\n            deviceName = deviceConfigMap[\"deviceName\"] as String,\n            orientation = deviceConfigMap[\"orientation\"] as String,\n            osVersion = deviceConfigMap[\"osVersion\"] as String,\n            displayInfo = deviceConfigMap[\"displayInfo\"] as String,\n            deviceLocale = deviceConfigMap[\"deviceLocale\"] as? String\n        )\n\n        return UploadResponse(\n            orgId = orgId,\n            uploadId = uploadId,\n            deviceConfiguration = deviceConfiguration,\n            appId = appId,\n            appBinaryId = appBinaryId\n        )\n    }\n\n\n    private inline fun <reified T> post(path: String, body: Any): Result<T, Response> {\n        val bodyBytes = JSON.writeValueAsBytes(body)\n        val request = Request.Builder()\n            .post(bodyBytes.toRequestBody(\"application/json\".toMediaType()))\n            .url(\"$baseUrl$path\")\n            .build()\n        val response = client.newCall(request).execute()\n\n        if (!response.isSuccessful) return Err(response)\n        if (Unit is T) return Ok(Unit)\n        val parsed = JSON.readValue(response.body?.bytes(), T::class.java)\n        return Ok(parsed)\n    }\n\n    private fun RequestBody.observable(\n        progressListener: (totalBytes: Long, bytesWritten: Long) -> Unit,\n    ) = object : RequestBody() {\n\n        override fun contentLength() = this@observable.contentLength()\n\n        override fun contentType() = this@observable.contentType()\n\n        override fun writeTo(sink: BufferedSink) {\n            val forwardingSink = object : ForwardingSink(sink) {\n\n                private var bytesWritten = 0L\n\n                override fun write(source: Buffer, byteCount: Long) {\n                    super.write(source, byteCount)\n                    bytesWritten += byteCount\n                    progressListener(contentLength(), bytesWritten)\n                }\n            }.buffer()\n            progressListener(contentLength(), 0)\n            this@observable.writeTo(forwardingSink)\n            forwardingSink.flush()\n        }\n    }\n\n    fun analyze(\n        authToken: String,\n        debugFiles: AnalysisDebugFiles,\n    ): AnalyzeResponse {\n        val mediaType = \"application/json; charset=utf-8\".toMediaType()\n        val body = JSON.writeValueAsString(debugFiles).toRequestBody(mediaType)\n\n        val url = \"$baseUrl/v2/analyze\"\n\n        val request = Request.Builder()\n            .header(\"Authorization\", \"Bearer $authToken\")\n            .url(url)\n            .post(body)\n            .build()\n\n        val response = client.newCall(request).execute()\n\n        response.use {\n            if (!response.isSuccessful) {\n                val errorMessage = response.body?.string().takeIf { it?.isNotEmpty() == true } ?: \"Unknown\"\n                throw CliError(\"Analyze request failed (${response.code}): $errorMessage\")\n            }\n\n            val parsed = JSON.readValue(response.body?.bytes(), AnalyzeResponse::class.java)\n\n            return parsed;\n        }\n    }\n\n    fun listCloudDevices(): Map<String, Map<String, List<String>>> {\n        val request = Request.Builder()\n            .url(\"$baseUrl/v2/device/list\")\n            .get()\n            .build()\n\n        val response = try {\n            client.newCall(request).execute()\n        } catch (e: IOException) {\n            throw ApiException(statusCode = null)\n        }\n\n        response.use {\n            if (!response.isSuccessful) throw ApiException(statusCode = response.code)\n            return JSON.readValue(response.body?.bytes(), object : TypeReference<Map<String, Map<String, List<String>>>>() {})\n        }\n    }\n\n    fun botMessage(question: String, sessionId: String, authToken: String): List<MessageContent> {\n        val body = JSON.writeValueAsString(\n            MessageRequest(\n                sessionId = sessionId,\n                context = emptyList(),\n                messages = listOf(\n                    ContentDetail(\n                        type = \"text\",\n                        text = question\n                    )\n                )\n            )\n        )\n\n        val url = \"$baseUrl/v2/bot/message\"\n\n        val request = Request.Builder()\n            .url(url)\n            .header(\"Authorization\", \"Bearer $authToken\")\n            .post(body.toRequestBody(\"application/json\".toMediaType()))\n            .build()\n\n        val response = client.newCall(request).execute()\n\n        response.use {\n            if (!response.isSuccessful) {\n                val errorMessage = response.body?.string().takeIf { it?.isNotEmpty() == true } ?: \"Unknown\"\n                throw CliError(\"bot message request failed (${response.code}): $errorMessage\")\n            }\n\n            val data = response.body?.bytes()\n            val parsed = JSON.readValue(data, object : TypeReference<List<MessageContent>>() {})\n\n            return parsed;\n        }\n    }\n\n\n    fun getUser(authToken: String): UserResponse {\n        val baseUrl = \"$baseUrl/v2/maestro-studio/user\"\n\n        val request = Request.Builder()\n          .header(\"Authorization\", \"Bearer $authToken\")\n          .url(baseUrl)\n          .get()\n          .build()\n\n        val response = try {\n          client.newCall(request).execute()\n        } catch (e: IOException) {\n          throw ApiException(statusCode = null)\n        }\n\n        response.use {\n            if (!response.isSuccessful) {\n                throw ApiException(\n                  statusCode = response.code\n                )\n            }\n            val responseBody = response.body?.string()\n            try {\n                val user = JSON.readValue(responseBody, UserResponse::class.java)\n                return user\n            } catch (e: Exception) {\n                throw e\n            }\n        }\n    }\n\n    fun getOrg(authToken: String): OrgResponse {\n        val baseUrl = \"$baseUrl/v2/maestro-studio/org\"\n\n        val request = Request.Builder()\n            .header(\"Authorization\", \"Bearer $authToken\")\n            .url(baseUrl)\n            .get()\n            .build()\n\n        val response = try {\n            client.newCall(request).execute()\n        } catch (e: IOException) {\n            throw ApiException(statusCode = null)\n        }\n\n        response.use {\n            if (!response.isSuccessful) {\n                throw ApiException(\n                    statusCode = response.code\n                )\n            }\n            val responseBody = response.body?.string()\n            try {\n                val user = JSON.readValue(responseBody, OrgResponse::class.java)\n                return user\n            } catch (e: Exception) {\n                throw e\n            }\n        }\n    }\n\n    fun getOrgs(authToken: String): List<OrgResponse> {\n        val url = \"$baseUrl/v2/maestro-studio/orgs\"\n      \n        val request = Request.Builder()\n            .header(\"Authorization\", \"Bearer $authToken\")\n            .url(url)\n            .get()\n            .build()\n\n        val response = try {\n            client.newCall(request).execute()\n        } catch (e: IOException) {\n            throw ApiException(statusCode = null)\n        }\n\n        response.use {\n            if (!response.isSuccessful) {\n                throw ApiException(\n                    statusCode = response.code\n                )\n            }\n            val responseBody = response.body?.string()\n            try {\n                val orgs = JSON.readValue(responseBody, object : TypeReference<List<OrgResponse>>() {})\n                return orgs\n            } catch (e: Exception) {\n                throw e\n            }\n        }\n    }\n\n    fun switchOrg(authToken: String, orgId: String): String {\n        val url = \"$baseUrl/v2/maestro-studio/org/switch\"\n\n        val request = Request.Builder()\n            .header(\"Authorization\", \"Bearer $authToken\")\n            .url(url)\n            .post(orgId.toRequestBody(\"text/plain\".toMediaType()))\n            .build()\n\n        val response = try {\n            client.newCall(request).execute()\n        } catch (e: IOException) {\n            throw ApiException(statusCode = null)\n        }\n\n        response.use {\n            if (!response.isSuccessful) {\n                throw ApiException(\n                    statusCode = response.code\n                )\n            }\n            val responseBody = response.body?.string()\n            try {\n                // The endpoint returns the API key directly as plain text\n                return responseBody ?: throw Exception(\"No API key in switch org response\")\n            } catch (e: Exception) {\n                throw e\n            }\n        }\n    }\n\n    fun getProjects(authToken: String): List<ProjectResponse> {\n        val url = \"$baseUrl/v2/maestro-studio/projects\"\n\n        val request = Request.Builder()\n            .header(\"Authorization\", \"Bearer $authToken\")\n            .url(url)\n            .get()\n            .build()\n\n        val response = try {\n            client.newCall(request).execute()\n        } catch (e: IOException) {\n            throw ApiException(statusCode = null)\n        }\n\n        response.use {\n            if (!response.isSuccessful) {\n                throw ApiException(\n                    statusCode = response.code\n                )\n            }\n            val responseBody = response.body?.string()\n            try {\n                val projects = JSON.readValue(responseBody, object : TypeReference<List<ProjectResponse>>() {})\n                return projects\n            } catch (e: Exception) {\n                throw e\n            }\n        }\n    }\n\n    fun getAppBinaryInfo(authToken: String, appBinaryId: String): AppBinaryInfo {\n        val request = Request.Builder()\n            .header(\"Authorization\", \"Bearer $authToken\")\n            .url(\"$baseUrl/v2/maestro-studio/app-binary/$appBinaryId\")\n            .get()\n            .build()\n\n        val response = try {\n            client.newCall(request).execute()\n        } catch (e: IOException) {\n            throw ApiException(statusCode = null)\n        }\n\n        response.use {\n            if (!response.isSuccessful) {\n                throw ApiException(statusCode = response.code)\n            }\n            return JSON.readValue(response.body?.bytes(), AppBinaryInfo::class.java)\n        }\n    }\n\n    data class ApiException(\n        val statusCode: Int?,\n    ) : Exception(\"Request failed. Status code: $statusCode\")\n\n    companion object {\n        private const val BASE_RETRY_DELAY_MS = 3000L\n        private val JSON = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)\n    }\n}\n\n\ndata class UploadResponse(\n    val orgId: String,\n    val uploadId: String,\n    val appId: String,\n    val deviceConfiguration: DeviceConfiguration?,\n    val appBinaryId: String?,\n)\n\ndata class AppBinaryInfo(\n    val appBinaryId: String,\n    val platform: String,\n    val appId: String,\n)\n\ndata class DeviceConfiguration(\n    val platform: String,\n    val deviceName: String,\n    val orientation: String,\n    val osVersion: String,\n    val displayInfo: String,\n    val deviceLocale: String?\n)\n\n@JsonIgnoreProperties(ignoreUnknown = true)\ndata class DeviceInfo(\n    val platform: String,\n    val displayInfo: String,\n    val isDefaultOsVersion: Boolean,\n    val deviceLocale: String,\n)\n\n@JsonIgnoreProperties(ignoreUnknown = true)\ndata class UploadStatus(\n    val uploadId: String,\n    val status: Status,\n    val completed: Boolean,\n    val totalTime: Long?,\n    val startTime: Long?,\n    val flows: List<FlowResult>,\n    val appPackageId: String?,\n    val wasAppLaunched: Boolean\n) {\n\n    data class FlowResult(\n        val name: String,\n        val status: FlowStatus,\n        val errors: List<String>,\n        val startTime: Long,\n        val totalTime: Long? = null,\n        val cancellationReason: CancellationReason? = null\n    )\n\n    enum class Status {\n        PENDING,\n        PREPARING,\n        INSTALLING,\n        RUNNING,\n        SUCCESS,\n        ERROR,\n        CANCELED,\n        WARNING,\n        STOPPED\n    }\n\n    // These values must match backend monorepo models\n    // in package models.benchmark.BenchmarkCancellationReason\n    enum class CancellationReason {\n        BENCHMARK_DEPENDENCY_FAILED,\n        INFRA_ERROR,\n        OVERLAPPING_BENCHMARK,\n        TIMEOUT,\n        CANCELED_BY_USER,\n        RUN_EXPIRED,\n    }\n}\n\ndata class RenderResponse(\n    val id: String,\n)\n\ndata class RenderState(\n    val status: String,\n    val positionInQueue: Int?,\n    val currentTaskProgress: Float?,\n    val error: String?,\n    val downloadUrl: String?,\n)\n\n\ndata class UserResponse(\n  val id: String,\n  val email: String,\n  val firstName: String?,\n  val lastName: String?,\n  val status: String,\n  val role: String,\n  val workOSOrgId: String,\n) {\n  val name: String\n    get() = when {\n      !firstName.isNullOrBlank() && !lastName.isNullOrBlank() -> \"$firstName $lastName\"\n      !firstName.isNullOrBlank() -> firstName!!\n      !lastName.isNullOrBlank() -> lastName!!\n      else -> email\n    }\n}\n\ndata class OrgResponse(\n  val id: String,\n  val name: String,\n  val quota: Map<String, Map<String, Number>>?,\n  val metadata: Map<String, String>?,\n  val workOSOrgId: String?,\n)\n\ndata class ProjectResponse(\n  val id: String,\n  val name: String,\n)\n\ndata class CliVersion(\n    val major: Int,\n    val minor: Int,\n    val patch: Int,\n) : Comparable<CliVersion> {\n\n    override fun compareTo(other: CliVersion): Int {\n        return COMPARATOR.compare(this, other)\n    }\n\n    override fun toString(): String {\n        return \"$major.$minor.$patch\"\n    }\n\n    companion object {\n\n        private val COMPARATOR = compareBy<CliVersion>({ it.major }, { it.minor }, { it.patch })\n\n        fun parse(versionString: String): CliVersion? {\n            val parts = versionString.split('.')\n            if (parts.size != 3) return null\n            val major = parts[0].toIntOrNull() ?: return null\n            val minor = parts[1].toIntOrNull() ?: return null\n            val patch = parts[2].toIntOrNull() ?: return null\n            return CliVersion(major, minor, patch)\n        }\n    }\n}\n\nclass SystemInformationInterceptor : Interceptor {\n    override fun intercept(chain: Interceptor.Chain): Response {\n        val newRequest = chain.request().newBuilder()\n            .header(\"X-UUID\", Analytics.uuid)\n            .header(\"X-VERSION\", EnvUtils.getVersion().toString())\n            .header(\"X-OS\", EnvUtils.OS_NAME)\n            .header(\"X-OSARCH\", EnvUtils.OS_ARCH)\n            .build()\n\n        return chain.proceed(newRequest)\n    }\n}\n\ndata class Insight(\n    val category: String,\n    val reasoning: String,\n)\n\ndata class StartTrialRequest(\n    val companyName: String,\n    val referralSource: String,\n)\n\nclass AnalyzeResponse(\n    val htmlReport: String?,\n    val output: String,\n    val insights: List<Insight>\n)\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/api/Chatbot.kt",
    "content": "package maestro.cli.api\n\nimport com.fasterxml.jackson.annotation.JsonProperty\n\ndata class MessageRequest(\n    @JsonProperty(\"sessionId\") val sessionId: String,\n    val context: List<ContentDetail>,\n    val messages: List<ContentDetail>\n)\n\ndata class ContentDetail(\n    val type: String, // \"text\" or \"image_url\" for now\n    val text: String? = null,\n    val image_url: Base64Image? = null\n)\n\ndata class Base64Image(\n    val url: String,\n    val detail: String,\n)\n\ndata class MessageContent(\n    val role: String,\n    val content: List<ContentDetail> = emptyList(),\n    val tool_calls: List<ToolCall>? = null,\n    val tool_call_id: String? = null\n)\n\ndata class ToolCall(\n    val id: String,\n    val function: ToolFunction\n)\n\ndata class ToolFunction(\n    val name: String,\n    val arguments: String?,\n)\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/auth/Auth.kt",
    "content": "package maestro.cli.auth\n\nimport io.ktor.http.*\nimport io.ktor.server.application.*\nimport io.ktor.server.engine.*\nimport io.ktor.server.netty.*\nimport io.ktor.server.response.*\nimport io.ktor.server.routing.*\nimport kotlinx.coroutines.CompletableDeferred\nimport kotlinx.coroutines.runBlocking\nimport maestro.auth.ApiKey\nimport maestro.cli.api.ApiClient\nimport maestro.cli.util.PrintUtils.err\nimport maestro.cli.util.PrintUtils.info\nimport maestro.cli.util.PrintUtils.success\nimport maestro.cli.util.getFreePort\nimport java.awt.Desktop\nimport java.net.URI\n\nprivate const val SUCCESS_HTML = \"\"\"\n    <!DOCTYPE html>\n<html>\n<head>\n    <title>Authentication Successful</title>\n    <script src=\"https://cdn.tailwindcss.com\"></script>\n</head>\n<body class=\"bg-white from-blue-500 to-purple-600 min-h-screen flex items-center justify-center\">\n<div class=\"bg-white p-8 rounded-lg border border-gray-300 max-w-md w-full mx-4\">\n    <div class=\"text-center\">\n        <svg class=\"w-16 h-16 text-green-500 mx-auto mb-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 13l4 4L19 7\"></path>\n        </svg>\n        <h1 class=\"text-2xl font-bold text-gray-800 mb-2\">Authentication Successful!</h1>\n        <p class=\"text-gray-600\">You can close this window and return to the CLI.</p>\n    </div>\n</div>\n</body>\n</html>\n    \"\"\"\n\nprivate const val FAILURE_DEFAULT_DESCRIPTION = \"Something went wrong. Please try again.\"\n\nprivate const val FAILURE_HTML = \"\"\"\n    <!DOCTYPE html>\n<html>\n<head>\n    <title>Authentication Failed</title>\n    <script src=\"https://cdn.tailwindcss.com\"></script>\n</head>\n<body class=\"bg-white min-h-screen flex items-center justify-center\">\n<div class=\"bg-white p-8 rounded-lg border border-gray-300 max-w-md w-full mx-4\">\n    <div class=\"text-center\">\n        <svg class=\"w-16 h-16 text-red-500 mx-auto mb-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\"></path>\n        </svg>\n        <h1 class=\"text-2xl font-bold text-gray-800 mb-2\">Authentication Failed</h1>\n        <p class=\"text-gray-600\">${FAILURE_DEFAULT_DESCRIPTION}</p>\n    </div>\n</div>\n</body>\n</html>\n\"\"\"\n\nclass Auth(\n    private val apiClient: ApiClient\n) {\n\n    fun getAuthToken(apiKey: String?, triggerSignIn: Boolean = true): String? {\n        if (triggerSignIn) {\n            return apiKey // Check for API key\n                ?: ApiKey.getToken()\n                ?: triggerSignInFlow() // Otherwise, trigger the sign-in flow\n        }\n        return apiKey // Check for API key\n            ?: ApiKey.getToken()\n    }\n\n\n    fun triggerSignInFlow(): String {\n        val deferredToken = CompletableDeferred<String>()\n\n        val port = getFreePort()\n        val server = embeddedServer(Netty, configure = { shutdownTimeout = 0; shutdownGracePeriod = 0 }, port = port) {\n            routing {\n                get(\"/callback\") {\n                    handleCallback(call, deferredToken)\n                }\n            }\n        }.start(wait = false)\n\n        val authUrl = apiClient.getAuthUrl(port.toString())\n        info(\"Your browser has been opened to visit:\\n\\n\\t$authUrl\")\n\n        if (Desktop.isDesktopSupported()) {\n            Desktop.getDesktop().browse(URI(authUrl))\n        } else {\n            err(\"Failed to open browser on this platform. Please open the above URL in your preferred browser.\")\n            throw UnsupportedOperationException(\"Failed to open browser automatically on this platform. Please open the above URL in your preferred browser.\")\n        }\n\n        val token = runBlocking {\n            deferredToken.await()\n        }\n        server.stop(0, 0)\n        ApiKey.setToken(token)\n        success(\"Authentication completed.\")\n        return token\n    }\n\n    private suspend fun handleCallback(call: ApplicationCall, deferredToken: CompletableDeferred<String>) {\n        val code = call.request.queryParameters[\"code\"]\n        if (code.isNullOrEmpty()) {\n            err(\"No authorization code received. Please try again.\")\n            call.respondText(FAILURE_HTML, ContentType.Text.Html)\n            return\n        }\n\n        try {\n            val newApiKey = apiClient.exchangeToken(code)\n            call.respondText(SUCCESS_HTML, ContentType.Text.Html)\n            deferredToken.complete(newApiKey)\n        } catch (e: Exception) {\n            val errorMessage = \"Failed to exchange token: ${e.message}\"\n            call.respondText(\n                if (errorMessage.isNotBlank()) FAILURE_HTML.replace(FAILURE_DEFAULT_DESCRIPTION, errorMessage) else FAILURE_HTML,\n                ContentType.Text.Html,\n                status = HttpStatusCode.InternalServerError\n            )\n            deferredToken.completeExceptionally(e)\n        }\n    }\n\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/cloud/CloudInteractor.kt",
    "content": "package maestro.cli.cloud\n\nimport maestro.cli.CliError\nimport maestro.cli.analytics.Analytics\nimport maestro.cli.analytics.CloudUploadTriggeredEvent\nimport maestro.cli.api.ApiClient\nimport maestro.cli.api.DeviceConfiguration\nimport maestro.cli.api.OrgResponse\nimport maestro.cli.api.ProjectResponse\nimport maestro.cli.api.UploadStatus\nimport maestro.cli.auth.Auth\nimport maestro.device.Platform\nimport maestro.cli.insights.AnalysisDebugFiles\nimport maestro.cli.model.FlowStatus\nimport maestro.cli.model.RunningFlow\nimport maestro.cli.model.RunningFlows\nimport maestro.cli.model.TestExecutionSummary\nimport maestro.cli.report.HtmlInsightsAnalysisReporter\nimport maestro.cli.report.ReportFormat\nimport maestro.cli.report.ReporterFactory\nimport maestro.cli.util.FileUtils.isWebFlow\nimport maestro.cli.util.FileUtils.isZip\nimport maestro.cli.util.PrintUtils\nimport maestro.cli.util.WorkspaceUtils\nimport maestro.cli.view.ProgressBar\nimport com.github.ajalt.mordant.terminal.Terminal\nimport com.github.ajalt.mordant.input.interactiveSelectList\nimport maestro.cli.analytics.CloudRunFinishedEvent\nimport maestro.cli.analytics.CloudUploadSucceededEvent\nimport maestro.cli.view.TestSuiteStatusView\nimport maestro.cli.view.TestSuiteStatusView.TestSuiteViewModel.Companion.toViewModel\nimport maestro.cli.view.TestSuiteStatusView.uploadUrl\nimport maestro.cli.view.box\nimport maestro.cli.view.cyan\nimport maestro.cli.view.render\nimport maestro.cli.promotion.PromotionStateManager\nimport maestro.orchestra.validation.AppMetadataAnalyzer\nimport maestro.orchestra.validation.AppMetadata\nimport maestro.cli.web.WebInteractor\nimport maestro.orchestra.validation.AppValidationException\nimport maestro.orchestra.validation.AppValidator\nimport maestro.orchestra.validation.WorkspaceValidationException\nimport maestro.orchestra.validation.WorkspaceValidator\nimport maestro.device.DeviceSpec\nimport maestro.device.DeviceSpecRequest\nimport maestro.utils.TemporaryDirectory\nimport okio.BufferedSink\nimport okio.buffer\nimport okio.sink\nimport org.rauschig.jarchivelib.ArchiveFormat\nimport org.rauschig.jarchivelib.ArchiverFactory\nimport java.io.File\nimport java.nio.file.Path\nimport java.util.*\nimport java.util.concurrent.TimeUnit\nimport kotlin.String\nimport kotlin.io.path.absolute\nimport kotlin.time.Duration.Companion.milliseconds\n\nval terminalStatuses = listOf(FlowStatus.CANCELED, FlowStatus.STOPPED, FlowStatus.SUCCESS, FlowStatus.ERROR)\n\nclass CloudInteractor(\n    private val client: ApiClient,\n    private val appFileValidator: (File) -> AppMetadata?,\n    private val workspaceValidator: WorkspaceValidator,\n    private val webManifestProvider: (() -> File?)? = null,\n    private val auth: Auth = Auth(client),\n    private val waitTimeoutMs: Long = TimeUnit.MINUTES.toMillis(30),\n    private val minPollIntervalMs: Long = TimeUnit.SECONDS.toMillis(10),\n    private val maxPollingRetries: Int = 5,\n    private val failOnTimeout: Boolean = true,\n) {\n\n    fun upload(\n        flowFile: File,\n        appFile: File?,\n        async: Boolean,\n        mapping: File? = null,\n        apiKey: String? = null,\n        uploadName: String? = null,\n        repoOwner: String? = null,\n        repoName: String? = null,\n        branch: String? = null,\n        commitSha: String? = null,\n        pullRequestId: String? = null,\n        env: Map<String, String> = emptyMap(),\n        appBinaryId: String? = null,\n        failOnCancellation: Boolean = false,\n        includeTags: List<String> = emptyList(),\n        excludeTags: List<String> = emptyList(),\n        reportFormat: ReportFormat = ReportFormat.NOOP,\n        reportOutput: File? = null,\n        testSuiteName: String? = null,\n        disableNotifications: Boolean = false,\n        deviceLocale: String? = null,\n        projectId: String? = null,\n        deviceModel: String? = null,\n        deviceOs: String? = null,\n        androidApiLevel: Int? = null,\n        iOSVersion: String? = null,\n    ): Int {\n        if (!flowFile.exists()) throw CliError(\"File does not exist: ${flowFile.absolutePath}\")\n        if (mapping?.exists() == false) throw CliError(\"File does not exist: ${mapping.absolutePath}\")\n        if (async && reportFormat != ReportFormat.NOOP) throw CliError(\"Cannot use --format with --async\")\n\n        // In case apiKey is provided use that, else fallback to signIn and org Selection\n        val authToken: String = auth.getAuthToken(apiKey, triggerSignIn = false) ?:\n          selectOrganization(auth.getAuthToken(apiKey, triggerSignIn = true) ?:\n          throw CliError(\"Failed to get authentication token\"))\n\n        // Fetch and select project if not provided\n        val selectedProjectId = projectId ?: selectProject(authToken)\n\n        // Record cloud command usage for promotion message suppression\n        PromotionStateManager().recordCloudCommandUsage()\n\n        // Track cloud upload triggered before any file I/O; platform unknown until binary is analyzed\n        Analytics.trackEvent(CloudUploadTriggeredEvent(\n            projectId = selectedProjectId,\n            isBinaryUpload = appBinaryId != null,\n            usesEnvironment = env.isNotEmpty(),\n            deviceModel = deviceModel,\n            deviceOs = deviceOs\n        ))\n      \n        PrintUtils.message(\"Uploading Flow(s)...\")\n\n        TemporaryDirectory.use { tmpDir ->\n            val workspaceZip = tmpDir.resolve(\"workspace.zip\")\n            WorkspaceUtils.createWorkspaceZip(flowFile.toPath().absolute(), workspaceZip)\n            val progressBar = ProgressBar(20)\n\n            // Binary id or Binary file\n            val appFileToSend = getAppFile(appFile, appBinaryId, tmpDir, flowFile)\n\n            // Validate app and resolve platform\n            val appValidator = AppValidator(\n                appFileValidator = appFileValidator,\n                appBinaryInfoProvider = { binaryId ->\n                    try {\n                        val info = client.getAppBinaryInfo(authToken, binaryId)\n                        AppValidator.AppBinaryInfoResult(info.appBinaryId, info.platform, info.appId)\n                    } catch (e: ApiClient.ApiException) {\n                        if (e.statusCode == 404) throw AppValidationException.AppBinaryNotFound(binaryId)\n                        throw AppValidationException.AppBinaryFetchError(e.statusCode)\n                    }\n                },\n                webManifestProvider = webManifestProvider,\n                iosMinOSVersionProvider = { file ->\n                    val metadata = AppMetadataAnalyzer.getIosAppMetadata(file) ?: return@AppValidator null\n                    val major = metadata.minimumOSVersion.substringBefore(\".\").toIntOrNull() ?: return@AppValidator null\n                    AppValidator.IosMinOSVersion(major = major, full = metadata.minimumOSVersion)\n                },\n            )\n            val resolvedAppValidation = try {\n                appValidator.validate(appFile = appFileToSend, appBinaryId = appBinaryId)\n            } catch (e: AppValidationException) {\n                throw CliError(e.message ?: \"App validation failed\")\n            }\n\n            // Fetch supported devices and validate device spec\n            val supportedDevices = try {\n                client.listCloudDevices()\n            } catch (e: ApiClient.ApiException) {\n                throw CliError(\"Failed to fetch supported devices. Status code: ${e.statusCode}\")\n            }\n\n            // Validate workspace against appId before uploading to catch errors early\n            try {\n                workspaceValidator.validate(\n                    workspace = workspaceZip.toFile(),\n                    appId = resolvedAppValidation.appIdentifier,\n                    env = env,\n                    includeTags = includeTags,\n                    excludeTags = excludeTags,\n                )\n            } catch (e: WorkspaceValidationException) {\n                throw CliError(e.message ?: \"Workspace validation failed\")\n            }\n\n            val response = client.upload(\n                authToken = authToken,\n                appFile = appFileToSend?.toPath(),\n                workspaceZip = workspaceZip,\n                uploadName = uploadName,\n                mappingFile = mapping?.toPath(),\n                repoOwner = repoOwner,\n                repoName = repoName,\n                branch = branch,\n                commitSha = commitSha,\n                pullRequestId = pullRequestId,\n                env = env,\n                appBinaryId = appBinaryId,\n                includeTags = includeTags,\n                excludeTags = excludeTags,\n                disableNotifications = disableNotifications,\n                projectId = selectedProjectId,\n                progressListener = { totalBytes, bytesWritten ->\n                    progressBar.set(bytesWritten.toFloat() / totalBytes.toFloat())\n                },\n                deviceLocale = deviceLocale,\n                deviceModel = deviceModel,\n                deviceOs = deviceOs,\n                androidApiLevel = androidApiLevel,\n                iOSVersion = iOSVersion,\n            )\n\n            // Track finish after upload completion\n            val platform = response.deviceConfiguration?.platform?.lowercase() ?: \"unknown\"\n            Analytics.trackEvent(CloudUploadSucceededEvent(\n                projectId = selectedProjectId,\n                platform = platform,\n                isBinaryUpload = appBinaryId != null,\n                usesEnvironment = env.isNotEmpty(),\n                deviceModel = deviceModel,\n                deviceOs = deviceOs\n            ))\n\n            val project = requireNotNull(selectedProjectId)\n            val appId = response.appId\n            val uploadUrl = uploadUrl(project, appId, response.uploadId, client.domain)\n            val deviceMessage =\n                if (response.deviceConfiguration != null) printDeviceInfo(response.deviceConfiguration) else \"\"\n\n            val uploadResponse = printMaestroCloudResponse(\n                async,\n                authToken,\n                failOnCancellation,\n                reportFormat,\n                reportOutput,\n                testSuiteName,\n                uploadUrl,\n                deviceMessage,\n                appId,\n                response.appBinaryId,\n                response.uploadId,\n                selectedProjectId,\n            )\n\n            Analytics.trackEvent(CloudRunFinishedEvent(\n                projectId = selectedProjectId,\n                totalFlows = uploadResponse.flows.size,\n                totalPassedFlows = uploadResponse.flows.count { it.status == FlowStatus.SUCCESS },\n                totalFailedFlows = uploadResponse.flows.count { it.status == FlowStatus.ERROR },\n                appPackageId = uploadResponse.appPackageId ?: \"\",\n                wasAppLaunched = uploadResponse.wasAppLaunched\n            ))\n\n            Analytics.flush()\n            \n            return when (uploadResponse.status) {\n                UploadStatus.Status.SUCCESS -> 0\n                UploadStatus.Status.ERROR -> 1\n                UploadStatus.Status.CANCELED -> if (failOnCancellation) 1 else 0\n                UploadStatus.Status.STOPPED -> 1\n                else -> 1\n            }\n        }\n    }\n\n    private fun selectProject(authToken: String): String {\n        val projects = try {\n            client.getProjects(authToken)\n        } catch (e: ApiClient.ApiException) {\n            throw CliError(\"Failed to fetch projects. Status code: ${e.statusCode}\")\n        } catch (e: Exception) {\n            throw CliError(\"Failed to fetch projects: ${e.message}\")\n        }\n\n        if (projects.isEmpty()) {\n            throw CliError(\"No projects found. Please create a project first at https://console.mobile.dev\")\n        }\n\n        return when (projects.size) {\n            1 -> {\n                val project = projects.first()\n                PrintUtils.info(\"Using project: ${project.name} (${project.id})\")\n                project.id\n            }\n            else -> {\n                val selectedProject = pickProject(projects)\n                PrintUtils.info(\"Selected project: ${selectedProject.name} (${selectedProject.id})\")\n                selectedProject.id\n            }\n        }\n    }\n\n    fun pickProject(projects: List<ProjectResponse>): ProjectResponse {\n        val terminal = Terminal()\n        val choices = projects.map { \"${it.name} (${it.id})\" }\n        \n        val selection = terminal.interactiveSelectList(\n            choices,\n            title = \"Multiple projects found. Please select one (Bypass this prompt by using --project-id=<>):\"\n        )\n        \n        if (selection == null) {\n            terminal.println(\"No project selected\")\n            throw CliError(\"Project selection was cancelled\")\n        }\n        \n        val selectedIndex = choices.indexOf(selection)\n        return projects[selectedIndex]\n    }\n\n    private fun selectOrganization(authToken: String): String {\n        val orgs = try {\n            client.getOrgs(authToken)\n        } catch (e: ApiClient.ApiException) {\n            throw CliError(\"Failed to fetch organizations. Status code: ${e.statusCode}\")\n        } catch (e: Exception) {\n            throw CliError(\"Failed to fetch organizations: ${e.message}\")\n        }\n\n        return when (orgs.size) {\n            1 -> {\n                val org = orgs.first()\n                PrintUtils.message(\"Using organization: ${org.name} (${org.id})\")\n                authToken\n            }\n            else -> {\n                val selectedOrg = pickOrganization(orgs)\n                PrintUtils.info(\"Selected organization: ${selectedOrg.name} (${selectedOrg.id})\")\n                // Switch to the selected organization to get org-scoped token\n                try {\n                    client.switchOrg(authToken, selectedOrg.id)\n                } catch (e: ApiClient.ApiException) {\n                    throw CliError(\"Failed to switch to organization. Status code: ${e.statusCode}\")\n                } catch (e: Exception) {\n                    throw CliError(\"Failed to switch to organization: ${e.message}\")\n                }\n            }\n        }\n    }\n\n    fun pickOrganization(orgs: List<OrgResponse>): OrgResponse {\n        val terminal = Terminal()\n        val choices = orgs.map { \"${it.name} (${it.id})\" }\n        \n        val selection = terminal.interactiveSelectList(\n            choices,\n            title = \"Multiple organizations found. Please select one (Bypass this prompt by using --api-key=<>):\",\n        )\n        \n        if (selection == null) {\n            terminal.println(\"No organization selected\")\n            throw CliError(\"Organization selection was cancelled\")\n        }\n        \n        val selectedIndex = choices.indexOf(selection)\n        return orgs[selectedIndex]\n    }\n\n    private fun getAppFile(\n        appFile: File?,\n        appBinaryId: String?,\n        tmpDir: Path,\n        flowFile: File\n    ): File? {\n        when {\n            appBinaryId != null -> return null\n\n            appFile != null -> if (appFile.isZip()) {\n                return appFile\n            } else {\n                val archiver = ArchiverFactory.createArchiver(ArchiveFormat.ZIP)\n\n                // An awkward API of Archiver that has a different behaviour depending on\n                // whether we call a vararg method or a normal method. The *arrayOf() construct\n                // forces compiler to choose vararg method.\n                @Suppress(\"RemoveRedundantSpreadOperator\")\n                return archiver.create(appFile.name + \".zip\", tmpDir.toFile(), *arrayOf(appFile.absoluteFile))\n            }\n\n            flowFile.isWebFlow() -> return WebInteractor.createManifestFromWorkspace(flowFile)\n\n            else -> return null\n        }\n    }\n\n    private fun printMaestroCloudResponse(\n        async: Boolean,\n        authToken: String,\n        failOnCancellation: Boolean,\n        reportFormat: ReportFormat,\n        reportOutput: File?,\n        testSuiteName: String?,\n        uploadUrl: String,\n        deviceInfoMessage: String,\n        appId: String,\n        appBinaryIdResponse: String?,\n        uploadId: String,\n        projectId: String\n    ): UploadStatus {\n        if (async) {\n            PrintUtils.message(\"✅ Upload successful!\")\n\n            println(deviceInfoMessage)\n            PrintUtils.info(\"View the results of your upload below:\")\n            PrintUtils.info(uploadUrl.cyan())\n\n            if (appBinaryIdResponse != null) PrintUtils.info(\"App binary id: ${appBinaryIdResponse.cyan()}\\n\")\n\n            // Return a simple UploadStatus for async case\n            return UploadStatus(\n                uploadId = uploadId,\n                status = UploadStatus.Status.SUCCESS,\n                completed = true,\n                totalTime = null,\n                startTime = null,\n                flows = emptyList(),\n                appPackageId = null,\n                wasAppLaunched = false,\n            )\n        } else {\n            println(deviceInfoMessage)\n            \n            // Print the upload URL\n            PrintUtils.info(\"Visit Maestro Cloud for more details about this upload:\")\n            PrintUtils.info(uploadUrl.cyan())\n            println()\n\n            if (appBinaryIdResponse != null) PrintUtils.info(\"App binary id: ${appBinaryIdResponse.cyan()}\\n\")\n\n            PrintUtils.info(\"Waiting for runs to be completed...\")\n\n            return waitForCompletion(\n                authToken = authToken,\n                uploadId = uploadId,\n                appId = appId,\n                failOnCancellation = failOnCancellation,\n                reportFormat = reportFormat,\n                reportOutput = reportOutput,\n                testSuiteName = testSuiteName,\n                uploadUrl = uploadUrl,\n                projectId = projectId,\n            )\n        }\n    }\n\n    private fun printDeviceInfo(deviceConfiguration: DeviceConfiguration): String {\n        val platform = Platform.fromString(deviceConfiguration.platform)\n        PrintUtils.info(\"\\n\")\n\n        val version = deviceConfiguration.osVersion\n        val lines = listOf(\n            \"Maestro cloud device specs:\\n* @|magenta ${deviceConfiguration.displayInfo} - ${deviceConfiguration.deviceLocale}|@\\n\",\n            \"To change OS version use this option: @|magenta ${if (platform == Platform.IOS) \"--device-os=<version>\" else \"--android-api-level=<version>\"}|@\",\n            \"To change devices use this option: @|magenta --device-model=<device_model>|@\",\n            \"To change device locale use this option: @|magenta --device-locale=<device_locale>|@\",\n            \"To create a similar device locally, run: @|magenta `maestro start-device --platform=${\n                platform.toString().lowercase()\n            } --os-version=$version --device-locale=${deviceConfiguration.deviceLocale}`|@\"\n        )\n\n        return lines.joinToString(\"\\n\").render().box()\n    }\n\n\n    internal fun waitForCompletion(\n        authToken: String,\n        uploadId: String,\n        appId: String,\n        failOnCancellation: Boolean,\n        reportFormat: ReportFormat,\n        reportOutput: File?,\n        testSuiteName: String?,\n        uploadUrl: String,\n        projectId: String?\n    ): UploadStatus {\n        val startTime = System.currentTimeMillis()\n\n        var pollingInterval = minPollIntervalMs\n        var retryCounter = 0\n        val printedFlows = mutableSetOf<UploadStatus.FlowResult>()\n\n        do {\n            val upload: UploadStatus = try {\n                client.uploadStatus(authToken, uploadId, projectId)\n            } catch (e: ApiClient.ApiException) {\n                if (e.statusCode == 429) {\n                    // back off through extending sleep duration with 25%\n                    pollingInterval = (pollingInterval * 1.25).toLong()\n                    Thread.sleep(pollingInterval)\n                    continue\n                }\n\n                if (e.statusCode == 500 || e.statusCode == 502 || e.statusCode == 404) {\n                    if (++retryCounter <= maxPollingRetries) {\n                        // retry on 500\n                        Thread.sleep(pollingInterval)\n                        continue\n                    }\n                }\n\n                throw CliError(\"Failed to fetch the status of an upload $uploadId. Status code = ${e.statusCode}\")\n            }\n\n            for (uploadFlowResult in upload.flows) {\n                if(printedFlows.contains(uploadFlowResult)) { continue }\n                if(!terminalStatuses.contains(uploadFlowResult.status)) { continue }\n\n                printedFlows.add(uploadFlowResult);\n                TestSuiteStatusView.showFlowCompletion(\n                  uploadFlowResult.toViewModel()\n                )\n            }\n\n            if (upload.completed) {\n                val runningFlows = RunningFlows(\n                    flows = upload.flows.map { flowResult ->\n                        RunningFlow(\n                            flowResult.name,\n                            flowResult.status,\n                            duration = flowResult.totalTime?.milliseconds,\n                            startTime = flowResult.startTime\n                        )\n                    },\n                    duration = upload.totalTime?.milliseconds,\n                    startTime = upload.startTime\n                )\n                return handleSyncUploadCompletion(\n                    upload = upload,\n                    runningFlows = runningFlows,\n                    appId = appId,\n                    failOnCancellation = failOnCancellation,\n                    reportFormat = reportFormat,\n                    reportOutput = reportOutput,\n                    testSuiteName = testSuiteName,\n                    uploadUrl = uploadUrl\n                )\n            }\n\n            Thread.sleep(pollingInterval)\n        } while (System.currentTimeMillis() - startTime < waitTimeoutMs)\n\n        val displayedMin = TimeUnit.MILLISECONDS.toMinutes(waitTimeoutMs)\n\n        PrintUtils.warn(\"Waiting for flows to complete has timed out ($displayedMin minutes)\")\n        PrintUtils.warn(\"* To extend the timeout, run maestro with this option `maestro cloud --timeout=<timeout in minutes>`\")\n\n        PrintUtils.warn(\"* Follow the results of your upload here:\\n$uploadUrl\")\n\n        if (failOnTimeout) {\n            PrintUtils.message(\"Process will exit with code 1 (FAIL)\")\n            PrintUtils.message(\"* To change exit code on Timeout, run maestro with this option: `maestro cloud --fail-on-timeout=<true|false>`\")\n        } else {\n            PrintUtils.message(\"Process will exit with code 0 (SUCCESS)\")\n            PrintUtils.message(\"* To change exit code on Timeout, run maestro with this option: `maestro cloud --fail-on-timeout=<true|false>`\")\n        }\n\n        // Fetch the latest upload status before returning\n        return try {\n            client.uploadStatus(authToken, uploadId, projectId)\n        } catch (e: Exception) {\n            // If we can't fetch the latest status, return a timeout status\n            UploadStatus(\n                uploadId = uploadId,\n                status = UploadStatus.Status.ERROR,\n                completed = false,\n                totalTime = null,\n                startTime = null,\n                flows = emptyList(),\n                appPackageId = null,\n                wasAppLaunched = false,\n            )\n        }\n    }\n\n    private fun handleSyncUploadCompletion(\n        upload: UploadStatus,\n        runningFlows: RunningFlows,\n        appId: String,\n        failOnCancellation: Boolean,\n        reportFormat: ReportFormat,\n        reportOutput: File?,\n        testSuiteName: String?,\n        uploadUrl: String,\n    ): UploadStatus {\n        TestSuiteStatusView.showSuiteResult(\n            upload.toViewModel(\n                TestSuiteStatusView.TestSuiteViewModel.UploadDetails(\n                    uploadId = upload.uploadId,\n                    appId = appId,\n                    domain = client.domain,\n                )\n            ),\n            uploadUrl\n        )\n\n        val isCancelled = upload.status == UploadStatus.Status.CANCELED\n        val isFailure = upload.status == UploadStatus.Status.ERROR\n        val containsFailure =\n            upload.flows.find { it.status == FlowStatus.ERROR } != null // status can be cancelled but also contain flow with failure\n\n        val failed = isFailure || containsFailure || isCancelled && failOnCancellation\n\n        val reportOutputSink = reportFormat.fileExtension\n            ?.let { extension ->\n                (reportOutput ?: File(\"report$extension\"))\n                    .sink()\n                    .buffer()\n            }\n\n        if (reportOutputSink != null) {\n            saveReport(\n                reportFormat,\n                !failed,\n                createSuiteResult(!failed, upload, runningFlows),\n                reportOutputSink,\n                testSuiteName\n            )\n        }\n\n\n        if (!failed) {\n            PrintUtils.message(\"Process will exit with code 0 (SUCCESS)\")\n            if (isCancelled) {\n                PrintUtils.message(\"* To change exit code on Cancellation, run maestro with this option: `maestro cloud --fail-on-cancellation=<true|false>`\")\n            }\n        } else {\n            PrintUtils.message(\"Process will exit with code 1 (FAIL)\")\n            if (isCancelled && !containsFailure) {\n                PrintUtils.message(\"* To change exit code on cancellation, run maestro with this option: `maestro cloud --fail-on-cancellation=<true|false>`\")\n            }\n        }\n\n        return upload\n    }\n\n    private fun saveReport(\n        reportFormat: ReportFormat,\n        passed: Boolean,\n        suiteResult: TestExecutionSummary.SuiteResult,\n        reportOutputSink: BufferedSink,\n        testSuiteName: String?\n    ) {\n        ReporterFactory.buildReporter(reportFormat, testSuiteName)\n            .report(\n                TestExecutionSummary(\n                    passed = passed,\n                    suites = listOf(suiteResult)\n                ),\n                reportOutputSink,\n            )\n    }\n\n    private fun createSuiteResult(\n        passed: Boolean,\n        upload: UploadStatus,\n        runningFlows: RunningFlows\n    ): TestExecutionSummary.SuiteResult {\n        return TestExecutionSummary.SuiteResult(\n            passed = passed,\n            flows = upload.flows.map { uploadFlowResult ->\n                val failure = uploadFlowResult.errors.firstOrNull()\n                val currentRunningFlow = runningFlows.flows.find { it.name == uploadFlowResult.name }\n                TestExecutionSummary.FlowResult(\n                    name = uploadFlowResult.name,\n                    fileName = null,\n                    status = uploadFlowResult.status,\n                    failure = if (failure != null) TestExecutionSummary.Failure(failure) else null,\n                    duration = currentRunningFlow?.duration,\n                    startTime = currentRunningFlow?.startTime\n                )\n            },\n            duration = runningFlows.duration,\n            startTime = runningFlows.startTime\n        )\n    }\n\n    fun analyze(\n        apiKey: String?,\n        debugFiles: AnalysisDebugFiles,\n        debugOutputPath: Path,\n    ): Int {\n        val authToken = auth.getAuthToken(apiKey)\n        if (authToken == null) throw CliError(\"Failed to get authentication token\")\n\n        PrintUtils.info(\"\\n\\uD83D\\uDD0E Analyzing Flow(s)...\")\n\n        try {\n            val response = client.analyze(authToken, debugFiles)\n\n            if (response.htmlReport.isNullOrEmpty()) {\n                PrintUtils.info(response.output)\n                return 0\n            }\n\n            val outputFilePath = HtmlInsightsAnalysisReporter().report(response.htmlReport, debugOutputPath)\n            val os = System.getProperty(\"os.name\").lowercase(Locale.getDefault())\n\n            val formattedOutput = response.output.replace(\n                \"{{outputFilePath}}\",\n                \"file:${if (os.contains(\"win\")) \"///\" else \"//\"}${outputFilePath}\\n\"\n            )\n\n            PrintUtils.info(formattedOutput);\n            return 0;\n        } catch (error: CliError) {\n            PrintUtils.err(\"Unexpected error while analyzing Flow(s): ${error.message}\")\n            return 1\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/command/BugReportCommand.kt",
    "content": "package maestro.cli.command\n\nimport maestro.cli.DisableAnsiMixin\nimport maestro.cli.ShowHelpMixin\nimport maestro.debuglog.DebugLogStore\nimport picocli.CommandLine\nimport java.util.concurrent.Callable\n\n@CommandLine.Command(\n    name = \"bugreport\",\n    description = [\n        \"Report a bug - Help us improve your experience!\"\n    ]\n)\nclass BugReportCommand : Callable<Int> {\n\n    @CommandLine.Mixin\n    var disableANSIMixin: DisableAnsiMixin? = null\n\n    @CommandLine.Mixin\n    var showHelpMixin: ShowHelpMixin? = null\n\n    override fun call(): Int {\n        val message = \"\"\"\n            Please open an issue on GitHub: https://github.com/mobile-dev-inc/Maestro/issues/new?template=bug_report.yaml\n            Attach the files found in this folder ${DebugLogStore.logDirectory}\n            \"\"\".trimIndent()\n        println(message)\n        return 0\n    }\n\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/command/ChatCommand.kt",
    "content": "package maestro.cli.command\n\nimport maestro.auth.ApiKey\nimport maestro.cli.api.ApiClient\nimport maestro.cli.auth.Auth\nimport maestro.cli.util.EnvUtils.BASE_API_URL\nimport org.fusesource.jansi.Ansi.ansi\nimport picocli.CommandLine\nimport java.util.*\nimport java.util.concurrent.Callable\n\n@CommandLine.Command(\n    name = \"chat\",\n    description = [\n        \"Use Maestro GPT to help you with Maestro documentation and code questions\"\n    ]\n)\nclass ChatCommand : Callable<Int> {\n\n    @CommandLine.Option(order = 0, names = [\"--api-key\", \"--apiKey\"], description = [\"API key\"])\n    private var apiKey: String? = null\n\n    @CommandLine.Option(order = 1, names = [\"--api-url\", \"--apiUrl\"], description = [\"API base URL\"])\n    private var apiUrl: String = BASE_API_URL\n\n    @CommandLine.Option(\n        order = 2,\n        names = [\"--ask\"],\n        description = [\"Gets a response and immediately exits the chat session\"]\n    )\n    private var ask: String? = null\n\n    private val auth by lazy {\n        Auth(ApiClient(apiUrl))\n    }\n\n    override fun call(): Int {\n        if (apiKey == null) {\n            apiKey = ApiKey.getToken()\n        }\n\n        if (apiKey == null) {\n            println(\"You must log in first in to use this command (maestro login).\")\n            return 1\n        }\n\n        val client = ApiClient(apiUrl)\n        if (ask == null) {\n            println(\n                \"\"\"\n            Welcome to MaestroGPT!\n\n            You can ask questions about Maestro documentation and code.\n            To exit, type \"quit\" or \"exit\".\n            \n            \"\"\".trimIndent()\n            )\n        }\n        val sessionId = \"maestro_cli:\" + UUID.randomUUID().toString()\n\n        while (true) {\n            if(ask == null) {\n                print(ansi().fgBrightMagenta().a(\"> \").reset().toString())\n            }\n            val question = ask ?: readLine()\n\n            if (question == null || question == \"quit\" || question == \"exit\") {\n                println(\"Goodbye!\")\n                return 0\n            }\n\n            val messages = client.botMessage(question, sessionId, apiKey!!)\n            println()\n            messages.filter { it.role == \"assistant\" }.mapNotNull { message ->\n                message.content.map { it.text }.joinToString(\"\\n\").takeIf { it.isNotBlank() }\n            }.forEach { message ->\n                if(ask != null) {\n                    println(message)\n                } else {\n                    println(\n                        ansi().fgBrightMagenta().a(\"MaestroGPT> \").reset().fgBrightCyan().a(message).reset().toString()\n                    )\n                }\n                println()\n            }\n\n            if (ask != null) {\n                return 0\n            }\n        }\n    }\n\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/command/CheckSyntaxCommand.kt",
    "content": "package maestro.cli.command\n\nimport maestro.cli.CliError\nimport maestro.orchestra.error.SyntaxError\nimport maestro.orchestra.yaml.YamlCommandReader\nimport picocli.CommandLine\nimport java.io.File\nimport java.util.concurrent.Callable\n\n@CommandLine.Command(\n    name = \"check-syntax\",\n    description = [\n        \"Check syntax of Maestro code\"\n    ]\n)\nclass CheckSyntaxCommand : Callable<Int> {\n\n    @CommandLine.Parameters(\n        index = \"0\",\n        description = [\"Check syntax of Maestro flow file or \\\"-\\\" for stdin\"],\n    )\n    private lateinit var file: File\n\n    override fun call(): Int {\n        val maestroCode = if (file.path == \"-\") {\n            System.`in`.readBytes().toString(Charsets.UTF_8)\n        } else {\n            if (!file.exists()) throw CliError(\"File does not exist: ${file.absolutePath}\")\n            file.readText()\n        }\n        if (maestroCode.isBlank()) throw CliError(\"Maestro code is empty.\")\n        try {\n            YamlCommandReader.checkSyntax(maestroCode)\n            println(\"OK\")\n        } catch (e: SyntaxError) {\n            throw CliError(e.message)\n        }\n        return 0\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/command/CloudCommand.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.cli.command\n\nimport maestro.cli.App\nimport maestro.cli.CliError\nimport maestro.cli.DisableAnsiMixin\nimport maestro.cli.ShowHelpMixin\nimport maestro.cli.api.ApiClient\nimport maestro.cli.cloud.CloudInteractor\nimport maestro.cli.report.ReportFormat\nimport maestro.orchestra.validation.AppMetadataAnalyzer\nimport maestro.cli.web.WebInteractor\nimport maestro.cli.report.TestDebugReporter\nimport maestro.cli.util.FileUtils.isWebFlow\nimport maestro.cli.util.PrintUtils\nimport maestro.orchestra.util.Env.withInjectedShellEnvVars\nimport maestro.orchestra.workspace.WorkspaceExecutionPlanner\nimport picocli.CommandLine\nimport picocli.CommandLine.Option\nimport java.io.File\nimport java.util.concurrent.Callable\nimport java.util.concurrent.TimeUnit\nimport maestro.orchestra.util.Env.withDefaultEnvVars\n\n@CommandLine.Command(\n    name = \"cloud\",\n    description = [\n        \"Upload your flows on Cloud by using @|yellow `maestro cloud sample/app.apk flows_folder/`|@ (@|cyan https://app.maestro.dev|@)\",\n        \"Provide your application file and a folder with Maestro flows to run them in parallel on multiple devices in the cloud\",\n        \"By default, the command will block until all analyses have completed. You can use the --async flag to run the command asynchronously and exit immediately.\",\n    ]\n)\nclass CloudCommand : Callable<Int> {\n\n    @CommandLine.Spec\n    var spec: CommandLine.Model.CommandSpec? = null\n\n    @CommandLine.Mixin\n    var disableANSIMixin: DisableAnsiMixin? = null\n\n    @CommandLine.Mixin\n    var showHelpMixin: ShowHelpMixin? = null\n\n    @CommandLine.Parameters(hidden = true, arity = \"0..2\", description = [\"App file and/or Flow file i.e <appFile> <flowFile>\"])\n    private lateinit var files: List<File>\n\n    @Option(names = [\"--config\"], description = [\"Optional .yaml configuration file for Flows. If not provided, Maestro will look for a config.yaml file in the root directory.\"])\n    private var configFile: File? = null\n\n    @Option(names = [\"--app-file\"], description = [\"App binary to run your Flows against\"])\n    private var appFile: File? = null\n\n    @Option(order = 1, names = [\"--flows\"], description = [\"A Flow filepath or a folder path that contains Flows\"])\n    private lateinit var flowsFile: File\n\n    @Option(order = 0, names = [\"--api-key\", \"--apiKey\"], description = [\"API key\"])\n    private var apiKey: String? = null\n\n    @Option(order = 1, names = [\"--project-id\", \"--projectId\"], description = [\"Project Id\"])\n    private var projectId: String? = null\n\n    @Option(order = 2, names = [\"--api-url\", \"--apiUrl\"], description = [\"API base URL\"])\n    private var apiUrl: String? = null\n\n    @Option(order = 3, names = [\"--mapping\"], description = [\"dSYM file (iOS) or Proguard mapping file (Android)\"])\n    private var mapping: File? = null\n\n    @Option(order = 4, names = [\"--repo-owner\", \"--repoOwner\"], description = [\"Repository owner (ie: GitHub organization or user slug)\"])\n    private var repoOwner: String? = null\n\n    @Option(order = 5, names = [\"--repo-name\", \"--repoName\"], description = [\"Repository name (ie: GitHub repo slug)\"])\n    private var repoName: String? = null\n\n    @Option(order = 6, names = [\"--branch\"], description = [\"The branch this upload originated from\"])\n    private var branch: String? = null\n\n    @Option(order = 7, names = [\"--commit-sha\", \"--commitSha\"], description = [\"The commit SHA of this upload\"])\n    private var commitSha: String? = null\n\n    @Option(order = 8, names = [\"--pull-request-id\", \"--pullRequestId\"], description = [\"The ID of the pull request this upload originated from\"])\n    private var pullRequestId: String? = null\n\n    @Option(order = 9, names = [\"-e\", \"--env\"], description = [\"Environment variables to inject into your Flows\"])\n    private var env: Map<String, String> = emptyMap()\n\n    @Option(order = 10, names = [\"--name\"], description = [\"Name of the upload\"])\n    private var uploadName: String? = null\n\n    @Option(order = 11, names = [\"--async\"], description = [\"Run the upload asynchronously\"])\n    private var async: Boolean = false\n\n    @Deprecated(\"Use --device-os instead\")\n    @Option(order = 12, hidden = true, names = [\"--android-api-level\"], description = [\"Android API level to run your flow against\"])\n    private var androidApiLevel: Int? = null\n\n    @Option(\n        order = 13,\n        names = [\"--include-tags\"],\n        description = [\"List of tags that will remove the Flows that does not have the provided tags\"],\n        split = \",\",\n    )\n    private var includeTags: List<String> = emptyList()\n\n    @Option(\n        order = 14,\n        names = [\"--exclude-tags\"],\n        description = [\"List of tags that will remove the Flows containing the provided tags\"],\n        split = \",\",\n    )\n    private var excludeTags: List<String> = emptyList()\n\n    @Option(\n        order = 15,\n        names = [\"--format\"],\n        description = [\"Test report format (default=\\${DEFAULT-VALUE}): \\${COMPLETION-CANDIDATES}\"],\n    )\n    private var format: ReportFormat = ReportFormat.NOOP\n\n    @Option(\n        names = [\"--test-suite-name\"],\n        description = [\"Test suite name\"],\n    )\n    private var testSuiteName: String? = null\n\n    @Option(\n        order = 16,\n        names = [\"--output\"],\n        description = [\"File to write report into (default=report.xml)\"],\n    )\n    private var output: File? = null\n\n    @Deprecated(\"Use --device-os instead\")\n    @Option(order = 17, hidden = true, names = [\"--ios-version\"], description = [\"iOS version to run your flow against. Please use --device-os instead\"])\n    private var iOSVersion: String? = null\n\n    @Option(order = 18, names = [\"--app-binary-id\", \"--appBinaryId\"], description = [\"The ID of the app binary previously uploaded to Maestro Cloud\"])\n    private var appBinaryId: String? = null\n\n    @Option(order = 19, names = [\"--device-locale\"], description = [\"Locale that will be set to a device, ISO-639-1 code and uppercase ISO-3166-1 code i.e. \\\"de_DE\\\" for Germany\"])\n    private var deviceLocale: String? = null\n\n    @Option(order = 20, names = [\"--device-model\"], description = [\n      \"Device model to run your flow against. \" +\n              \"iOS: iPhone-11, iPhone-11-Pro, etc. Run command: maestro list-cloud-devices\" +\n              \"Android: pixel_6, etc. Run command: maestro list-cloud-devices\"\n    ])\n    private var deviceModel: String? = null\n\n    @Option(order = 21, names = [\"--device-os\"], description = [\n      \"OS version to run your flow against. \" +\n              \"iOS: iOS-16-2, iOS-17-5, iOS-18-2, etc. maestro list-devices\" +\n              \"Android: android-33, android-34, etc. maestro list-cloud-devices\"\n    ])\n    private var deviceOs: String? = null\n\n    @Option(hidden = true, names = [\"--fail-on-cancellation\"], description = [\"Fail the command if the upload is marked as cancelled\"])\n    private var failOnCancellation: Boolean = false\n\n    @Option(hidden = true, names = [\"--fail-on-timeout\"], description = [\"Fail the command if the upload times outs\"])\n    private var failOnTimeout: Boolean = true\n\n    @Option(hidden = true, names = [\"--disable-notifications\"], description = [\"Do not send the notifications configured in config.yaml\"])\n    private var disableNotifications = false\n\n    @Option(hidden = true, names = [\"--timeout\"], description = [\"Minutes to wait until all flows complete\"])\n    private var resultWaitTimeout = 60\n\n    @CommandLine.ParentCommand\n    private val parent: App? = null\n\n    override fun call(): Int {\n        TestDebugReporter.install(\n            debugOutputPathAsString = null,\n            flattenDebugOutput = false,\n            printToConsole = parent?.verbose == true,\n        )\n\n        validateFiles()\n        validateWorkSpace()\n\n        // Upload\n        val apiUrl = apiUrl ?: \"https://api.copilot.mobile.dev\"\n\n        env = env\n            .withInjectedShellEnvVars()\n            .withDefaultEnvVars(flowsFile)\n\n        val apiClient = ApiClient(apiUrl)\n        val webManifestProvider = if (flowsFile.isWebFlow()) {\n            { WebInteractor.createManifestFromWorkspace(flowsFile) }\n        } else null\n\n        return CloudInteractor(\n            client = apiClient,\n            appFileValidator = { AppMetadataAnalyzer.validateAppFile(it) },\n            workspaceValidator = maestro.orchestra.validation.WorkspaceValidator(),\n            webManifestProvider = webManifestProvider,\n            failOnTimeout = failOnTimeout,\n            waitTimeoutMs = TimeUnit.MINUTES.toMillis(resultWaitTimeout.toLong())\n        ).upload(\n            async = async,\n            flowFile = flowsFile,\n            appFile = appFile,\n            mapping = mapping,\n            env = env,\n            uploadName = uploadName,\n            repoOwner = repoOwner,\n            repoName = repoName,\n            branch = branch,\n            commitSha = commitSha,\n            pullRequestId = pullRequestId,\n            apiKey = apiKey,\n            appBinaryId = appBinaryId,\n            includeTags = includeTags,\n            excludeTags = excludeTags,\n            reportFormat = format,\n            reportOutput = output,\n            failOnCancellation = failOnCancellation,\n            testSuiteName = testSuiteName,\n            disableNotifications = disableNotifications,\n            deviceLocale = deviceLocale,\n            projectId = projectId,\n            deviceModel = deviceModel,\n            deviceOs = deviceOs,\n            androidApiLevel = androidApiLevel,\n            iOSVersion = iOSVersion\n        )\n    }\n\n    private fun validateWorkSpace() {\n        try {\n            PrintUtils.message(\"Evaluating flow(s)...\")\n            WorkspaceExecutionPlanner\n                .plan(\n                    input = setOf(flowsFile.toPath().toAbsolutePath()),\n                    includeTags = includeTags,\n                    excludeTags = excludeTags,\n                    config = configFile?.toPath()?.toAbsolutePath(),\n                )\n        } catch (e: Exception) {\n            throw CliError(\"Upload aborted. Received error when evaluating flow(s):\\n\\n${e.message}\")\n        }\n    }\n\n    private fun validateFiles() {\n\n        if (configFile != null && configFile?.exists()?.not() == true) {\n            throw CliError(\"The config file ${configFile?.absolutePath} does not exist.\")\n        }\n\n        // Maintains backwards compatibility for this syntax: maestro cloud <appFile> <workspace>\n        // App file can be optional now\n        if (this::files.isInitialized) {\n            when (files.size) {\n                2 -> {\n                    appFile = files[0]\n                    flowsFile = files[1]\n                }\n                1 -> {\n                    flowsFile = files[0]\n                }\n            }\n        }\n\n        val hasWorkspace = this::flowsFile.isInitialized\n        val hasApp = appFile != null\n                || appBinaryId != null\n                || (this::flowsFile.isInitialized && this::flowsFile.get().isWebFlow())\n\n        if (!hasApp && !hasWorkspace) {\n            throw CommandLine.MissingParameterException(spec!!.commandLine(), spec!!.findOption(\"--flows\"), \"Missing required parameters: '--app-file', \" +\n                \"'--flows'. \" +\n                \"Example:\" +\n                \" maestro cloud --app-file <path> --flows <path>\")\n        }\n\n        if (!hasApp) throw CommandLine.MissingParameterException(spec!!.commandLine(), spec!!.findOption(\"--app-file\"), \"Missing required parameter for option '--app-file' or \" +\n            \"'--app-binary-id'\")\n        if (!hasWorkspace) throw CommandLine.MissingParameterException(spec!!.commandLine(), spec!!.findOption(\"--flows\"), \"Missing required parameter for option '--flows'\")\n\n    }\n\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/command/DownloadSamplesCommand.kt",
    "content": "package maestro.cli.command\n\nimport kotlinx.coroutines.runBlocking\nimport maestro.cli.DisableAnsiMixin\nimport maestro.cli.ShowHelpMixin\nimport maestro.cli.util.FileDownloader\nimport maestro.cli.util.PrintUtils.err\nimport maestro.cli.util.PrintUtils.message\nimport maestro.cli.view.ProgressBar\nimport org.rauschig.jarchivelib.ArchiverFactory\nimport picocli.CommandLine\nimport picocli.CommandLine.Option\nimport java.io.File\nimport java.util.concurrent.Callable\n\n@CommandLine.Command(\n    name = \"download-samples\",\n    description = [\n        \"Download sample apps and flows for trying out maestro without setting up your own app\"\n    ]\n)\nclass DownloadSamplesCommand : Callable<Int> {\n\n    @CommandLine.Mixin\n    var disableANSIMixin: DisableAnsiMixin? = null\n\n    @CommandLine.Mixin\n    var showHelpMixin: ShowHelpMixin? = null\n\n    @Option(names = [\"-o\", \"--output\"], description = [\"Output directory\"])\n    private var outputDirectory: File? = null\n\n    override fun call(): Int {\n        val folder = ensureSamplesFolder()\n        val samplesFile = File(\"maestro-samples.zip\")\n\n        return runBlocking {\n            try {\n                downloadSamplesZip(samplesFile)\n\n                val archiver = ArchiverFactory.createArchiver(samplesFile)\n                archiver.extract(samplesFile, folder)\n\n                message(\"✅ Samples downloaded to $folder/\")\n                return@runBlocking 0\n            } catch (e: Exception) {\n                err(e.message ?: \"Error downloading samples: $e\")\n                return@runBlocking 1\n            } finally {\n                samplesFile.delete()\n            }\n        }\n    }\n\n    private suspend fun downloadSamplesZip(file: File) {\n        val progressView = ProgressBar(20)\n\n        FileDownloader\n            .downloadFile(\n                SAMPLES_URL,\n                file\n            ).collect {\n                when (it) {\n                    is FileDownloader.DownloadResult.Success -> {\n                        // Do nothing\n                    }\n                    is FileDownloader.DownloadResult.Error -> {\n                        throw it.cause ?: error(it.message)\n                    }\n                    is FileDownloader.DownloadResult.Progress -> {\n                        progressView.set(it.progress)\n                    }\n                }\n            }\n    }\n\n    private fun ensureSamplesFolder(): File {\n        val outputDir = outputDirectory\n            ?: File(\"samples\")\n\n        if (!outputDir.exists()) {\n            outputDir.mkdirs()\n        }\n\n        return outputDir\n    }\n\n    companion object {\n\n        private const val SAMPLES_URL = \"https://storage.googleapis.com/mobile.dev/samples/samples.zip\"\n\n    }\n\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/command/DriverCommand.kt",
    "content": "package maestro.cli.command\n\nimport maestro.cli.driver.DriverBuilder\nimport maestro.cli.driver.RealIOSDeviceDriver\nimport picocli.CommandLine\nimport java.util.concurrent.Callable\n\n@CommandLine.Command(\n    name = \"driver-setup\",\n    description = [\n        \"Setup maestro drivers on your devices. Right now works for real iOS devices\"\n    ],\n    hidden = true\n)\nclass DriverCommand : Callable<Int> {\n\n    @CommandLine.Option(\n        names = [\"--apple-team-id\"],\n        description = [\"The Team ID is a unique 10-character string generated by Apple that is assigned to your team's apple account.\"],\n        hidden = true\n    )\n    private var appleTeamId: String? = null\n\n    @CommandLine.Option(\n        names = [\"--destination\"],\n        description = [\"Destination device to build the driver for. Defaults to generic/platform=iphoneos if not specified.\"],\n        hidden = true\n    )\n    private var destination: String? = null\n\n\n    override fun call(): Int {\n        val teamId = requireNotNull(appleTeamId) { \"Apple account team ID must be specified.\" }\n        val destination = destination ?: \"generic/platform=iphoneos\"\n\n        val driverBuilder = DriverBuilder()\n\n        RealIOSDeviceDriver(\n            teamId = teamId,\n            destination = destination,\n            driverBuilder = driverBuilder,\n        ).validateAndUpdateDriver(force = true)\n\n        return 0\n    }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/command/ListCloudDevicesCommand.kt",
    "content": "package maestro.cli.command\n\nimport maestro.cli.App\nimport maestro.cli.CliError\nimport maestro.cli.ShowHelpMixin\nimport maestro.cli.api.ApiClient\nimport maestro.cli.report.TestDebugReporter\nimport maestro.cli.util.EnvUtils\nimport maestro.cli.util.PrintUtils\nimport maestro.cli.view.bold\nimport maestro.cli.view.cyan\nimport maestro.device.Platform\nimport picocli.CommandLine\nimport java.util.concurrent.Callable\n\n@CommandLine.Command(\n    name = \"list-cloud-devices\",\n    description = [\"List devices available on Maestro Cloud, grouped by platform\"],\n)\nclass ListCloudDevicesCommand : Callable<Int> {\n\n    @CommandLine.Mixin\n    var showHelpMixin: ShowHelpMixin? = null\n\n    @CommandLine.ParentCommand\n    private val parent: App? = null\n\n    @CommandLine.Option(\n        names = [\"--platform\"],\n        description = [\"Filter by platform: android, ios, web\"],\n    )\n    private var platform: String? = null\n\n    override fun call(): Int {\n        TestDebugReporter.install(null, printToConsole = parent?.verbose == true)\n\n        val platformFilter = platform?.let { input ->\n            Platform.fromString(input)\n        }\n\n        val apiClient = ApiClient(EnvUtils.BASE_API_URL)\n\n        println()\n        PrintUtils.info(\"Cloud Devices\", bold = true)\n        println(\"─\".repeat(SEPARATOR_WIDTH))\n\n        val cloudDevices = try {\n            apiClient.listCloudDevices()\n        } catch (e: ApiClient.ApiException) {\n            if (e.statusCode == null) PrintUtils.err(\"Unable to reach Maestro Cloud. Please check your network connection and try again.\")\n            throw e\n        }\n\n        val platformOrder = listOf(Platform.IOS, Platform.ANDROID, Platform.WEB)\n        val platforms = if (platformFilter != null) listOf(platformFilter) else platformOrder\n\n        val sections = platforms.mapNotNull { p ->\n            val key = p.name.lowercase()\n            val raw = cloudDevices[key] ?: return@mapNotNull null\n            val groups = raw.map { (model, osList) -> DeviceGroup(model, osList) }\n            p to groups\n        }.filter { it.second.isNotEmpty() }\n\n        if (sections.isEmpty()) {\n            println(\"No cloud devices found\")\n            return 0\n        }\n\n        sections.forEachIndexed { idx, (p, groups) ->\n            if (idx > 0) println()\n            printSection(p, groups)\n        }\n\n        return 0\n    }\n\n    private data class DeviceGroup(\n        val model: String,\n        val osList: List<String>,\n    )\n\n    private fun printSection(platform: Platform, groups: List<DeviceGroup>) {\n        println(platform.description.bold())\n\n        val modelW = groups.maxOf { it.model.length }\n\n        for (g in groups) {\n            val osLine = g.osList.joinToString(\", \")\n            println(row(g.model.cyan().padEnd(modelW + ansiExtra(g.model.cyan())), osLine))\n        }\n    }\n\n    private fun row(vararg cols: String) = \"  \" + cols.joinToString(\"   \")\n\n    private fun ansiExtra(s: String) = s.length - s.replace(ANSI_RE, \"\").length\n\n    companion object {\n        private val ANSI_RE = Regex(\"\\u001B\\\\[[\\\\d;]*[^\\\\d;]\")\n        private const val SEPARATOR_WIDTH = 53\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/command/ListDevicesCommand.kt",
    "content": "package maestro.cli.command\n\nimport maestro.cli.App\nimport maestro.cli.CliError\nimport maestro.cli.ShowHelpMixin\nimport maestro.cli.report.TestDebugReporter\nimport maestro.cli.util.PrintUtils\nimport maestro.cli.view.bold\nimport maestro.cli.view.cyan\nimport maestro.cli.view.faint\nimport maestro.device.Device\nimport maestro.device.DeviceService\nimport maestro.device.Platform\nimport picocli.CommandLine\nimport java.util.concurrent.Callable\n\n@CommandLine.Command(\n    name = \"list-devices\",\n    description = [\"List local devices available, grouped by platform\"],\n)\nclass ListDevicesCommand : Callable<Int> {\n\n    @CommandLine.Mixin\n    var showHelpMixin: ShowHelpMixin? = null\n\n    @CommandLine.ParentCommand\n    private val parent: App? = null\n\n    @CommandLine.Option(\n        names = [\"--platform\"],\n        description = [\"Filter by platform: android, ios, web\"],\n    )\n    private var platform: String? = null\n\n    override fun call(): Int {\n        TestDebugReporter.install(null, printToConsole = parent?.verbose == true)\n\n        val platformFilter = platform?.let { input ->\n            Platform.fromString(input)\n        }\n\n        println(\"Showing local devices. Use 'maestro list-cloud-device' to list devices available on Maestro Cloud.\".faint())\n        println()\n\n        PrintUtils.info(\"Local Devices\", bold = true)\n        println(\"─\".repeat(SEPARATOR_WIDTH))\n\n        val devices = DeviceService.listDevices(includeWeb = true)\n        val platforms = if (platformFilter != null) listOf(platformFilter) else Platform.entries\n        val sections = platforms.map { p -> p to devices.filter { it.platform == p }.groupedByModel() }\n            .filter { it.second.isNotEmpty() }\n\n        if (sections.isEmpty()) {\n            println(\"No devices found\")\n            return 0\n        }\n\n        sections.forEachIndexed { idx, (p, groups) ->\n            if (idx > 0) println()\n            printSection(p, groups)\n        }\n\n        return 0\n    }\n\n    private data class DeviceGroup(\n        val model: String,\n        val osList: List<String>,\n    )\n\n    private fun List<Device>.groupedByModel(): List<DeviceGroup> {\n        val groups = LinkedHashMap<String, MutableList<String>>()\n        for (device in this) {\n            if (device.deviceSpec.model.isEmpty()) continue\n            val osList = groups.getOrPut(device.deviceSpec.model) { mutableListOf() }\n            if (device.deviceSpec.os.isNotEmpty() && device.deviceSpec.os !in osList) {\n                osList.add(device.deviceSpec.os)\n            }\n        }\n        return groups.map { (model, osList) -> DeviceGroup(model, osList) }\n    }\n\n    private fun printSection(platform: Platform, groups: List<DeviceGroup>) {\n        println(platform.description.bold())\n\n        val modelW = groups.maxOf { it.model.length }\n\n        for (g in groups) {\n            val osLine = g.osList.joinToString(\", \")\n            println(row(g.model.cyan().padEnd(modelW + ansiExtra(g.model.cyan())), osLine))\n        }\n    }\n\n    private fun row(vararg cols: String) = \"  \" + cols.joinToString(\"   \")\n\n    private fun ansiExtra(s: String) = s.length - s.replace(ANSI_RE, \"\").length\n\n    companion object {\n        private val ANSI_RE = Regex(\"\\u001B\\\\[[\\\\d;]*[^\\\\d;]\")\n        private const val SEPARATOR_WIDTH = 53\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/command/LoginCommand.kt",
    "content": "package maestro.cli.command\n\nimport maestro.auth.ApiKey\nimport maestro.cli.DisableAnsiMixin\nimport maestro.cli.ShowHelpMixin\nimport maestro.cli.analytics.Analytics\nimport maestro.cli.analytics.UserLoggedOutEvent\nimport maestro.cli.api.ApiClient\nimport maestro.cli.auth.Auth\nimport maestro.cli.util.PrintUtils.message\nimport picocli.CommandLine\nimport java.util.concurrent.Callable\nimport kotlin.io.path.absolutePathString\nimport maestro.cli.report.TestDebugReporter\nimport maestro.debuglog.LogConfig\nimport picocli.CommandLine.Option\n\n@CommandLine.Command(\n    name = \"login\",\n    description = [\n        \"Log into Maestro Cloud\"\n    ]\n)\nclass LoginCommand : Callable<Int> {\n\n    @CommandLine.Mixin\n    var disableANSIMixin: DisableAnsiMixin? = null\n\n    @CommandLine.Mixin\n    var showHelpMixin: ShowHelpMixin? = null\n\n    @Option(names = [\"--api-url\", \"--apiUrl\"], description = [\"API base URL\"])\n    private var apiUrl: String = \"https://api.copilot.mobile.dev\"\n\n    private val auth by lazy {\n        Auth(ApiClient(apiUrl))\n    }\n\n    override fun call(): Int {\n        Analytics.trackEvent(UserLoggedOutEvent())\n\n        LogConfig.configure(logFileName = null, printToConsole = false) // Disable all logs from Login\n        val token = auth.triggerSignInFlow()\n        println(token)\n\n        return 0\n    }\n\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/command/LogoutCommand.kt",
    "content": "package maestro.cli.command\n\nimport maestro.cli.DisableAnsiMixin\nimport maestro.cli.ShowHelpMixin\nimport maestro.cli.analytics.Analytics\nimport maestro.cli.analytics.UserLoggedOutEvent\nimport org.fusesource.jansi.Ansi\nimport picocli.CommandLine\nimport java.nio.file.Path\nimport java.nio.file.Paths\nimport java.util.concurrent.Callable\nimport kotlin.io.path.deleteIfExists\nimport maestro.cli.util.PrintUtils\nimport maestro.cli.util.PrintUtils.message\n\n@CommandLine.Command(\n    name = \"logout\",\n    description = [\n        \"Log out of Maestro Cloud\"\n    ]\n)\nclass LogoutCommand : Callable<Int> {\n\n    @CommandLine.Mixin\n    var disableANSIMixin: DisableAnsiMixin? = null\n\n    @CommandLine.Mixin\n    var showHelpMixin: ShowHelpMixin? = null\n\n    private val cachedAuthTokenFile: Path = Paths.get(System.getProperty(\"user.home\"), \".mobiledev\", \"authtoken\")\n\n    override fun call(): Int {\n        // Track logout event before deleting the token\n        Analytics.trackEvent(UserLoggedOutEvent())\n        \n        cachedAuthTokenFile.deleteIfExists()\n\n        message(\"Logged out.\")\n\n        return 0\n    }\n\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/command/McpCommand.kt",
    "content": "package maestro.cli.command\n\nimport picocli.CommandLine\nimport java.util.concurrent.Callable\nimport maestro.cli.mcp.runMaestroMcpServer\nimport java.io.File\nimport maestro.cli.util.WorkingDirectory\n\n@CommandLine.Command(\n    name = \"mcp\",\n    description = [\n        \"Starts the Maestro MCP server, exposing Maestro device and automation commands as Model Context Protocol (MCP) tools over STDIO for LLM agents and automation clients.\"\n    ],\n)\nclass McpCommand : Callable<Int> {\n    @CommandLine.Option(\n        names = [\"--working-dir\"],\n        description = [\"Base working directory for resolving files\"]\n    )\n    private var workingDir: File? = null\n\n    override fun call(): Int {\n        if (workingDir != null) {\n            WorkingDirectory.baseDir = workingDir!!.absoluteFile\n        }\n        runMaestroMcpServer()\n        return 0\n    }\n} "
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/command/PrintHierarchyCommand.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.cli.command\n\nimport com.fasterxml.jackson.annotation.JsonInclude\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport maestro.TreeNode\nimport maestro.cli.App\nimport maestro.cli.DisableAnsiMixin\nimport maestro.cli.ShowHelpMixin\nimport maestro.cli.analytics.Analytics\nimport maestro.cli.analytics.PrintHierarchyFinishedEvent\nimport maestro.cli.analytics.PrintHierarchyStartedEvent\nimport maestro.cli.report.TestDebugReporter\nimport maestro.cli.session.MaestroSessionManager\nimport maestro.cli.view.yellow\nimport maestro.utils.CliInsights\nimport maestro.utils.Insight\nimport maestro.utils.chunkStringByWordCount\nimport picocli.CommandLine\nimport picocli.CommandLine.Option\nimport java.lang.StringBuilder\n\n@CommandLine.Command(\n    name = \"hierarchy\",\n    description = [\n        \"Print out the view hierarchy of the connected device\"\n    ]\n)\nclass PrintHierarchyCommand : Runnable {\n\n    @CommandLine.Mixin\n    var disableANSIMixin: DisableAnsiMixin? = null\n\n    @CommandLine.Mixin\n    var showHelpMixin: ShowHelpMixin? = null\n\n    @CommandLine.ParentCommand\n    private val parent: App? = null\n\n    @CommandLine.Option(\n        names = [\"--android-webview-hierarchy\"],\n        description = [\"Set to \\\"devtools\\\" to use Chrome dev tools for Android WebView hierarchy\"],\n        hidden = true,\n    )\n    private var androidWebViewHierarchy: String? = null\n\n    @CommandLine.Option(\n        names = [\"--reinstall-driver\"],\n        description = [\"Reinstalls driver before running the test. On iOS, reinstalls xctestrunner driver. On Android, reinstalls both driver and server apps. Set to false to skip reinstallation.\"],\n        negatable = true,\n        defaultValue = \"true\",\n        fallbackValue = \"true\"        \n    )\n    private var reinstallDriver: Boolean = true\n\n    @Option(\n        names = [\"--apple-team-id\"],\n        description = [\"The Team ID is a unique 10-character string generated by Apple that is assigned to your team's apple account.\"],\n        hidden = true\n    )\n    private var appleTeamId: String? = null\n\n    @CommandLine.Option(\n        names = [\"--compact\"],\n        description = [\"Output in CSV format with element_num,depth,attributes,parent_num columns\"],\n        hidden = false\n    )\n    private var compact: Boolean = false\n\n    @CommandLine.Option(\n        names = [\"--device-index\"],\n        description = [\"The index of the device to run the test on\"],\n        hidden = true\n    )\n    private var deviceIndex: Int? = null\n\n    override fun run() {\n        TestDebugReporter.install(\n            debugOutputPathAsString = null,\n            flattenDebugOutput = false,\n            printToConsole = parent?.verbose == true,\n        )\n        \n        // Track print hierarchy start\n        val platform = parent?.platform ?: \"unknown\"\n        val startTime = System.currentTimeMillis()\n        Analytics.trackEvent(PrintHierarchyStartedEvent(platform = platform))\n        \n\n        MaestroSessionManager.newSession(\n            host = parent?.host,\n            port = parent?.port,\n            driverHostPort = null,\n            teamId = appleTeamId,\n            deviceId = parent?.deviceId,\n            platform = parent?.platform,\n            reinstallDriver = reinstallDriver,\n            deviceIndex = deviceIndex\n        ) { session ->\n            session.maestro.setAndroidChromeDevToolsEnabled(androidWebViewHierarchy == \"devtools\")\n            val callback: (Insight) -> Unit = {\n                val message = StringBuilder()\n                val level = it.level.toString().lowercase().replaceFirstChar(Char::uppercase)\n                message.append(level.yellow() + \": \")\n                it.message.chunkStringByWordCount(12).forEach { chunkedMessage ->\n                    message.append(\"$chunkedMessage \")\n                }\n                println(message.toString())\n            }\n            val insights = CliInsights\n\n            insights.onInsightsUpdated(callback)\n\n            val tree = session.maestro.viewHierarchy().root\n\n            insights.unregisterListener(callback)\n\n            if (compact) {\n                // Output in CSV format\n                println(\"element_num,depth,attributes,parent_num\")\n                val nodeToId = mutableMapOf<TreeNode, Int>()\n                val csv = StringBuilder()\n                \n                // Assign IDs to each node\n                var counter = 0\n                tree?.aggregate()?.forEach { node ->\n                    nodeToId[node] = counter++\n                }\n                \n                // Process tree recursively to generate CSV\n                processTreeToCSV(tree, 0, null, nodeToId, csv)\n                \n                println(csv.toString())\n            } else {\n                // Original JSON output format\n                val hierarchy = jacksonObjectMapper()\n                    .setSerializationInclusion(JsonInclude.Include.NON_NULL)\n                    .writerWithDefaultPrettyPrinter()\n                    .writeValueAsString(tree)\n                \n                println(hierarchy)\n            }\n        }\n        \n        // Track successful completion\n        val duration = System.currentTimeMillis() - startTime\n        Analytics.trackEvent(PrintHierarchyFinishedEvent(\n            platform = platform,\n            success = true,\n            durationMs = duration\n        ))\n        Analytics.flush()\n    }\n    \n    private fun processTreeToCSV(\n        node: TreeNode?, \n        depth: Int, \n        parentId: Int?, \n        nodeToId: Map<TreeNode, Int>,\n        csv: StringBuilder\n    ) {\n        if (node == null) return\n        \n        val nodeId = nodeToId[node] ?: return\n        \n        // Build attributes string\n        val attributesList = mutableListOf<String>()\n        \n        // Add normal attributes\n        node.attributes.forEach { (key, value) ->\n            if (value.isNotEmpty() && value != \"false\") {\n                attributesList.add(\"$key=$value\")\n            }\n        }\n        \n        // Add boolean properties if true\n        if (node.clickable == true) attributesList.add(\"clickable=true\")\n        if (node.enabled == true) attributesList.add(\"enabled=true\")\n        if (node.focused == true) attributesList.add(\"focused=true\")\n        if (node.checked == true) attributesList.add(\"checked=true\")\n        if (node.selected == true) attributesList.add(\"selected=true\")\n        \n        // Join all attributes with \"; \"\n        val attributesString = attributesList.joinToString(\"; \")\n        \n        // Escape quotes in the attributes string if needed\n        val escapedAttributes = attributesString.replace(\"\\\"\", \"\\\"\\\"\")\n        \n        // Add this node to CSV\n        csv.append(\"$nodeId,$depth,\\\"$escapedAttributes\\\",${parentId ?: \"\"}\\n\")\n        \n        // Process children\n        node.children.forEach { child ->\n            processTreeToCSV(child, depth + 1, nodeId, nodeToId, csv)\n        }\n    }\n\n    private fun removeEmptyValues(tree: TreeNode?): TreeNode? {\n        if (tree == null) {\n            return null\n        }\n\n        return TreeNode(\n            attributes = tree.attributes.filter {\n                it.value != \"\" && it.value.toString() != \"false\"\n            }.toMutableMap(),\n            children = tree.children.map { removeEmptyValues(it) }.filterNotNull(),\n            checked = if(tree.checked == true) true else null,\n            clickable = if(tree.clickable == true) true else null,\n            enabled = if(tree.enabled == true) true else null,\n            focused = if(tree.focused == true) true else null,\n            selected = if(tree.selected == true) true else null,\n        )\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/command/QueryCommand.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.cli.command\n\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport maestro.ElementFilter\nimport maestro.Filters\nimport maestro.cli.App\nimport maestro.cli.DisableAnsiMixin\nimport maestro.cli.ShowHelpMixin\nimport maestro.cli.session.MaestroSessionManager\nimport maestro.cli.view.red\nimport maestro.orchestra.Orchestra\nimport maestro.utils.StringUtils.toRegexSafe\nimport picocli.CommandLine\nimport picocli.CommandLine.Command\nimport picocli.CommandLine.Model\nimport picocli.CommandLine.Option\nimport picocli.CommandLine.Spec\n\n@Command(\n    name = \"query\",\n    description = [\n        \"Find elements in the view hierarchy of the connected device\"\n    ],\n    hidden = true\n)\nclass QueryCommand : Runnable {\n\n    @CommandLine.Mixin\n    var disableANSIMixin: DisableAnsiMixin? = null\n\n    @CommandLine.Mixin\n    var showHelpMixin: ShowHelpMixin? = null\n\n    @CommandLine.ParentCommand\n    private val parent: App? = null\n\n    @Option(names = [\"text\"])\n    private var text: String? = null\n\n    @Option(names = [\"id\"])\n    private var id: String? = null\n\n    @Spec\n    lateinit var commandSpec: Model.CommandSpec\n\n    @Option(\n        names = [\"--apple-team-id\"],\n        description = [\"The Team ID is a unique 10-character string generated by Apple that is assigned to your team's apple account.\"],\n        hidden = true\n    )\n    private var appleTeamId: String? = null\n\n    override fun run() {\n        MaestroSessionManager.newSession(\n            host = parent?.host,\n            port = parent?.port,\n            driverHostPort = null,\n            deviceId = parent?.deviceId,\n            platform = parent?.platform,\n            teamId = appleTeamId,\n        ) { session ->\n            val filters = mutableListOf<ElementFilter>()\n\n            text?.let {\n                filters += Filters.textMatches(it.toRegexSafe(Orchestra.REGEX_OPTIONS))\n            }\n\n            id?.let {\n                filters += Filters.idMatches(it.toRegexSafe(Orchestra.REGEX_OPTIONS))\n            }\n\n            if (filters.isEmpty()) {\n                throw CommandLine.ParameterException(\n                    commandSpec.commandLine(),\n                    \"Must specify at least one search criteria\"\n                )\n            }\n\n            val elements = session.maestro.allElementsMatching(\n                Filters.intersect(filters)\n            )\n\n            val mapper = jacksonObjectMapper()\n                .writerWithDefaultPrettyPrinter()\n\n            println(\"Matches: ${elements.size}\")\n            elements.forEach {\n                println(\n                    mapper.writeValueAsString(it)\n                )\n            }\n        }\n        System.err.println(\"This command is deprecated. Use \\\"maestro studio\\\" instead.\".red())\n    }\n\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/command/RecordCommand.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.cli.command\n\nimport maestro.cli.App\nimport maestro.cli.CliError\nimport maestro.cli.DisableAnsiMixin\nimport maestro.cli.ShowHelpMixin\nimport maestro.cli.analytics.Analytics\nimport maestro.cli.analytics.RecordFinishedEvent\nimport maestro.cli.analytics.RecordStartedEvent\nimport maestro.cli.graphics.LocalVideoRenderer\nimport maestro.cli.graphics.RemoteVideoRenderer\nimport maestro.cli.graphics.SkiaFrameRenderer\nimport maestro.cli.report.TestDebugReporter\nimport maestro.cli.runner.TestRunner\nimport maestro.cli.runner.resultview.AnsiResultView\nimport maestro.cli.session.MaestroSessionManager\nimport maestro.cli.util.FileUtils.isWebFlow\nimport maestro.orchestra.workspace.WorkspaceExecutionPlanner\nimport okio.sink\nimport picocli.CommandLine\nimport picocli.CommandLine.Option\nimport java.io.File\nimport java.util.concurrent.Callable\n\n@CommandLine.Command(\n    name = \"record\",\n    description = [\n        \"Render a beautiful video of your Flow - Great for demos and bug reports\"\n    ]\n)\nclass RecordCommand : Callable<Int> {\n\n    @CommandLine.Mixin\n    var disableANSIMixin: DisableAnsiMixin? = null\n\n    @CommandLine.Mixin\n    var showHelpMixin: ShowHelpMixin? = null\n\n    @CommandLine.ParentCommand\n    private val parent: App? = null\n\n    @CommandLine.Parameters(index = \"0\", description = [\"The Flow file to record.\"])\n    private lateinit var flowFile: File\n\n    @CommandLine.Parameters(description = [\"Output file for the rendered video. Only valid for local rendering (--local).\"], arity = \"0..1\", index = \"1\")\n    private var outputFile: File? = null\n\n    @Option(names = [\"--config\"], description = [\"Optional .yaml configuration file for Flows. If not provided, Maestro will look for a config.yaml file in the root directory.\"])\n    private var configFile: File? = null\n\n    @Option(names = [\"--local\"], description = [\"(Beta) Record using local rendering. This will become the default in a future Maestro release.\"])\n    private var local: Boolean = false\n\n    @Option(names = [\"-e\", \"--env\"])\n    private var env: Map<String, String> = emptyMap()\n\n    @Option(\n        names = [\"--apple-team-id\"],\n        description = [\"The Team ID is a unique 10-character string generated by Apple that is assigned to your team's apple account.\"]\n    )\n    private var appleTeamId: String? = null\n\n    @CommandLine.Spec\n    lateinit var commandSpec: CommandLine.Model.CommandSpec\n\n    @Option(\n        names = [\"--debug-output\"],\n        description = [\"Configures the debug output in this path, instead of default\"]\n    )\n    private var debugOutput: String? = null\n\n    override fun call(): Int {\n        // Track record start\n        val startTime = System.currentTimeMillis()\n        val platform = parent?.platform ?: \"unknown\"\n        Analytics.trackEvent(RecordStartedEvent(platform = platform))\n\n        if (!flowFile.exists()) {\n            throw CommandLine.ParameterException(\n                commandSpec.commandLine(),\n                \"File not found: $flowFile\"\n            )\n        }\n\n        if (!local && outputFile != null) {\n            throw CommandLine.ParameterException(\n                commandSpec.commandLine(),\n                \"The outputFile parameter is only valid for local rendering (--local).\",\n            )\n        }\n\n        if (configFile != null && configFile?.exists()?.not() == true) {\n            throw CliError(\"The config file ${configFile?.absolutePath} does not exist.\")\n        }\n        TestDebugReporter.install(debugOutputPathAsString = debugOutput, printToConsole = parent?.verbose == true)\n        val path = TestDebugReporter.getDebugOutputPath()\n\n        val deviceId = if (flowFile.isWebFlow()) {\n            throw CliError(\"'record' command does not support web flows yet.\")\n        } else {\n            parent?.deviceId\n        }\n\n        val plan = WorkspaceExecutionPlanner.plan(\n            input = setOf(flowFile.toPath()),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n            config = configFile?.toPath()\n        )\n\n        return MaestroSessionManager.newSession(\n            host = parent?.host,\n            port = parent?.port,\n            driverHostPort = null,\n            deviceId = deviceId,\n            teamId = appleTeamId,\n            platform = parent?.platform,\n            executionPlan = plan,\n            block = { session ->\n                val maestro = session.maestro\n                val device = session.device\n\n                if (flowFile.isDirectory) {\n                    throw CommandLine.ParameterException(\n                        commandSpec.commandLine(),\n                        \"Only single Flows are supported by \\\"maestro record\\\". $flowFile is a directory.\",\n                    )\n                }\n\n                val resultView = AnsiResultView()\n                val screenRecording = kotlin.io.path.createTempFile(suffix = \".mp4\").toFile()\n                val exitCode = screenRecording.sink().use { out ->\n                    maestro.startScreenRecording(out).use {\n                        TestRunner.runSingle(\n                            maestro,\n                            device,\n                            flowFile,\n                            env,\n                            resultView,\n                            path,\n                            testOutputDir = null,\n                            deviceId = parent?.deviceId,\n                        )\n                    }\n                }\n\n                val frames = resultView.getFrames()\n\n                val localOutputFile = outputFile ?: path.resolve(\"maestro-recording.mp4\").toFile()\n                val videoRenderer = if (local) LocalVideoRenderer(\n                    frameRenderer = SkiaFrameRenderer(),\n                    outputFile = localOutputFile,\n                    outputFPS = 25,\n                    outputWidthPx = 1920,\n                    outputHeightPx = 1080,\n                ) else RemoteVideoRenderer()\n                videoRenderer.render(screenRecording, frames)\n\n                TestDebugReporter.deleteOldFiles()\n\n                // Track record completion\n                val duration = System.currentTimeMillis() - startTime\n                Analytics.trackEvent(RecordFinishedEvent(platform = platform, durationMs = duration))\n                Analytics.flush()\n\n                exitCode\n            },\n        )\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/command/StartDeviceCommand.kt",
    "content": "package maestro.cli.command\n\nimport maestro.cli.App\nimport maestro.cli.CliError\nimport maestro.cli.ShowHelpMixin\nimport maestro.cli.device.DeviceCreateUtil\nimport maestro.device.DeviceService\nimport maestro.cli.report.TestDebugReporter\nimport maestro.cli.util.EnvUtils\nimport maestro.device.DeviceSpec\nimport maestro.device.DeviceSpecRequest\nimport maestro.device.Platform\nimport picocli.CommandLine\nimport java.util.concurrent.Callable\n\n@CommandLine.Command(\n    name = \"start-device\",\n    description = [\n        \"Starts or creates an iOS Simulator or Android Emulator similar to the ones on the cloud\",\n        \"Supported device types: iPhone11 (iOS), Pixel 6 (Android)\",\n    ]\n)\nclass StartDeviceCommand : Callable<Int> {\n\n    @CommandLine.Mixin\n    var showHelpMixin: ShowHelpMixin? = null\n\n    @CommandLine.ParentCommand\n    private val parent: App? = null\n\n    @CommandLine.Option(\n        order = 0,\n        names = [\"--platform\"],\n        required = true,\n        description = [\"Platforms: android, ios, web\"],\n    )\n    private lateinit var platform: String\n\n    @Deprecated(\"Use --device-os instead\")\n    @CommandLine.Option(\n        order = 1,\n        hidden = true,\n        names = [\"--os-version\"],\n        description = [\"OS version to use:\", \"iOS: 16, 17, 18\", \"Android: 28, 29, 30, 31, 33\"],\n    )\n    private var osVersion: String? = null\n\n    @CommandLine.Option(\n        order = 2,\n        names = [\"--device-locale\"],\n        description = [\"a combination of lowercase ISO-639-1 code and uppercase ISO-3166-1 code i.e. \\\"de_DE\\\" for Germany\"],\n    )\n    private var deviceLocale: String? = null\n\n    @CommandLine.Option(\n        order = 3,\n        names = [\"--device-model\"],\n        description = [\n            \"Device model to run against\",\n            \"iOS: iPhone-11, iPhone-11-Pro, etc. Run command: maestro list-devices\",\n            \"Android: pixel_6, pixel_7, etc. Run command: maestro list-devices\"\n        ],\n    )\n    private var deviceModel: String? = null\n\n    @CommandLine.Option(\n        order = 4,\n        names = [\"--device-os\"],\n        description = [\n            \"OS version to use:\",\n            \"iOS: iOS-16-2, iOS-17-5, iOS-18-2, etc. maestro list-devices\",\n            \"Android: android-33, android-34, etc. maestro list-devices\"\n        ],\n    )\n    private var deviceOs: String? = null\n\n    @CommandLine.Option(\n        order = 5,\n        names = [\"--force-create\"],\n        description = [\"Will override existing device if it already exists\"],\n    )\n    private var forceCreate: Boolean = false\n\n    override fun call(): Int {\n        TestDebugReporter.install(null, printToConsole = parent?.verbose == true)\n\n        if (EnvUtils.isWSL()) {\n            throw CliError(\"This command is not supported in Windows WSL. You can launch your emulator manually.\")\n        }\n\n        // Get the device configuration\n        val parsedPlatform = Platform.fromString(platform)\n        val maestroDeviceConfiguration = DeviceSpec.fromRequest(\n            when (parsedPlatform) {\n                Platform.ANDROID -> DeviceSpecRequest.Android(\n                    model = deviceModel,\n                    os = deviceOs ?: osVersion.let { \"android-$it\" },\n                    locale = deviceLocale,\n                    cpuArchitecture = EnvUtils.getMacOSArchitecture(),\n                )\n                Platform.IOS -> DeviceSpecRequest.Ios(\n                    model = deviceModel,\n                    os = deviceOs ?: osVersion.let { \"iOS-$it\" },\n                    locale = deviceLocale,\n                )\n                Platform.WEB -> DeviceSpecRequest.Web(\n                    model = deviceModel,\n                    os = deviceOs ?: osVersion,\n                    locale = deviceLocale,\n                )\n            }\n        )\n\n        // Get/Create the device\n        val device = DeviceCreateUtil.getOrCreateDevice(\n            maestroDeviceConfiguration,\n            forceCreate\n        )\n\n        // Start Device\n        DeviceService.startDevice(\n            device = device,\n            driverHostPort = parent?.port\n        )\n\n        return 0\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/command/StudioCommand.kt",
    "content": "package maestro.cli.command\n\nimport maestro.cli.App\nimport maestro.cli.DisableAnsiMixin\nimport maestro.cli.ShowHelpMixin\nimport maestro.cli.report.TestDebugReporter\nimport maestro.cli.session.MaestroSessionManager\nimport maestro.cli.view.blue\nimport maestro.cli.view.bold\nimport maestro.cli.view.box\nimport maestro.cli.view.faint\nimport maestro.studio.MaestroStudio\nimport picocli.CommandLine\nimport java.awt.Desktop\nimport java.net.URI\nimport java.util.concurrent.Callable\nimport maestro.cli.util.getFreePort\nimport picocli.CommandLine.Option\n\n@CommandLine.Command(\n    name = \"studio\",\n    hidden = true,\n    description = [\"Launch Maestro Studio\"],\n)\nclass StudioCommand : Callable<Int> {\n\n    @CommandLine.Mixin\n    var disableANSIMixin: DisableAnsiMixin? = null\n\n    @CommandLine.Mixin\n    var showHelpMixin: ShowHelpMixin? = null\n\n    @CommandLine.ParentCommand\n    private val parent: App? = null\n\n    @Option(\n        names = [\"--debug-output\"],\n        description = [\"Configures the debug output in this path, instead of default\"]\n    )\n    private var debugOutput: String? = null\n\n    @Option(\n        names = [\"--no-window\"],\n        description = [\"When set, a browser window will not be automatically opened\"]\n    )\n    private var noWindow: Boolean? = null\n\n    @Option(\n        names = [\"--android-webview-hierarchy\"],\n        description = [\"Set to \\\"devtools\\\" to use Chrome dev tools for Android WebView hierarchy\"],\n        hidden = true,\n    )\n    private var androidWebViewHierarchy: String? = null\n\n    @Option(\n        names = [\"--apple-team-id\"],\n        description = [\"The Team ID is a unique 10-character string generated by Apple that is assigned to your team's apple account.\"]\n    )\n    private var appleTeamId: String? = null\n\n    override fun call(): Int {\n        println()\n        println(\"\"\"\n        ╭────────────────────────────────────────────────────────────────────────────────╮\n        │                                                                                │\n        │          Download the new and improved Maestro Studio app today!               │\n        │                                                                                │\n        │ https://maestro.dev?utm_source=cli&utm_campaign=download_studio#maestro-studio │\n        │                                                                                │\n        ╰────────────────────────────────────────────────────────────────────────────────╯\"\"\".trimIndent().bold())\n        println()\n\n        TestDebugReporter.install(debugOutputPathAsString = debugOutput, printToConsole = parent?.verbose == true)\n\n        MaestroSessionManager.newSession(\n            host = parent?.host,\n            port = parent?.port,\n            driverHostPort = null,\n            teamId = appleTeamId,\n            deviceId = parent?.deviceId,\n            platform = parent?.platform,\n            isStudio = true,\n        ) { session ->\n            session.maestro.setAndroidChromeDevToolsEnabled(androidWebViewHierarchy == \"devtools\")\n\n            val port = getFreePort()\n            MaestroStudio.start(port, session.maestro)\n\n            val studioUrl = \"http://localhost:${port}\"\n            val message = (\"Maestro Studio\".bold() + \" is running at \" + studioUrl.blue()).box()\n            println()\n            println(message)\n            tryOpenUrl(studioUrl)\n\n\n            println()\n            println(\"Tip: Maestro Studio can now run simultaneously alongside other Maestro CLI commands!\")\n\n            println()\n            println(\"Navigate to $studioUrl in your browser to open Maestro Studio. Ctrl-C to exit.\".faint())\n\n            Thread.currentThread().join()\n        }\n\n        TestDebugReporter.deleteOldFiles()\n        return 0\n    }\n\n    private fun tryOpenUrl(studioUrl: String) {\n        try {\n            if (Desktop.isDesktopSupported() && noWindow != true) {\n                Desktop.getDesktop().browse(URI(studioUrl))\n            }\n        } catch (ignore: Exception) {\n            // Do nothing\n        }\n    }\n\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/command/TestCommand.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.cli.command\n\nimport kotlinx.coroutines.CoroutineName\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.async\nimport kotlinx.coroutines.awaitAll\nimport kotlinx.coroutines.runBlocking\nimport maestro.Maestro\nimport maestro.cli.App\nimport maestro.cli.CliError\nimport maestro.cli.DisableAnsiMixin\nimport maestro.cli.ShowHelpMixin\nimport maestro.cli.analytics.Analytics\nimport maestro.cli.analytics.TestRunFailedEvent\nimport maestro.cli.analytics.TestRunFinishedEvent\nimport maestro.cli.analytics.TestRunStartedEvent\nimport maestro.cli.analytics.WorkspaceRunFailedEvent\nimport maestro.cli.analytics.WorkspaceRunFinishedEvent\nimport maestro.cli.analytics.WorkspaceRunStartedEvent\nimport maestro.device.Device\nimport maestro.device.DeviceService\nimport maestro.cli.model.TestExecutionSummary\nimport maestro.cli.report.ReportFormat\nimport maestro.cli.report.ReporterFactory\nimport maestro.cli.report.TestDebugReporter\nimport maestro.cli.runner.TestRunner\nimport maestro.cli.runner.TestSuiteInteractor\nimport maestro.cli.runner.resultview.AnsiResultView\nimport maestro.cli.runner.resultview.PlainTextResultView\nimport maestro.cli.session.MaestroSessionManager\nimport maestro.cli.util.CiUtils\nimport maestro.cli.util.EnvUtils\nimport maestro.cli.util.FileUtils.isWebFlow\nimport maestro.cli.util.PrintUtils\nimport maestro.cli.insights.TestAnalysisManager\nimport maestro.cli.view.greenBox\nimport maestro.cli.view.box\nimport maestro.cli.view.green\nimport maestro.cli.api.ApiClient\nimport maestro.cli.auth.Auth\nimport maestro.cli.model.FlowStatus\nimport maestro.cli.view.cyan\nimport maestro.cli.promotion.PromotionStateManager\nimport maestro.orchestra.error.ValidationError\nimport maestro.orchestra.workspace.WorkspaceExecutionPlanner\nimport maestro.orchestra.workspace.WorkspaceExecutionPlanner.ExecutionPlan\nimport maestro.utils.isSingleFile\nimport okio.sink\nimport org.slf4j.LoggerFactory\nimport picocli.CommandLine\nimport picocli.CommandLine.Option\nimport java.io.File\nimport java.nio.file.Path\nimport java.time.LocalDate\nimport java.util.concurrent.Callable\nimport java.util.concurrent.ConcurrentHashMap\nimport kotlin.io.path.absolutePathString\nimport kotlin.math.roundToInt\nimport maestro.device.Platform\n\n@CommandLine.Command(\n    name = \"test\",\n    description = [\"Test a Flow or set of Flows on a local iOS Simulator or Android Emulator\"],\n)\nclass TestCommand : Callable<Int> {\n\n    @CommandLine.Mixin\n    var disableANSIMixin: DisableAnsiMixin? = null\n\n    @CommandLine.Mixin\n    var showHelpMixin: ShowHelpMixin? = null\n\n    @CommandLine.ParentCommand\n    private val parent: App? = null\n\n    @CommandLine.Parameters(description = [\"One or more flow files or folders containing flow files\"], arity = \"1..*\")\n    private var flowFiles: Set<File> = emptySet()\n\n    @Option(\n        names = [\"--config\"],\n        description = [\"Optional YAML configuration file for the workspace. If not provided, Maestro will look for a config.yaml file in the workspace's root directory.\"]\n    )\n    private var configFile: File? = null\n\n    @Option(\n        names = [\"-s\", \"--shards\"],\n        description = [\"Number of parallel shards to distribute tests across\"],\n    )\n    @Deprecated(\"Use --shard-split or --shard-all instead\")\n    private var legacyShardCount: Int? = null\n\n    @Option(\n        names = [\"--shard-split\"],\n        description = [\"Run the tests across N connected devices, splitting the tests evenly across them\"],\n    )\n    private var shardSplit: Int? = null\n\n    @Option(\n        names = [\"--shard-all\"],\n        description = [\"Run all the tests across N connected devices\"],\n    )\n    private var shardAll: Int? = null\n\n    @Option(names = [\"-c\", \"--continuous\"])\n    private var continuous: Boolean = false\n\n    @Option(names = [\"-e\", \"--env\"])\n    private var env: Map<String, String> = emptyMap()\n\n    @Option(\n        names = [\"--format\"],\n        description = [\"Test report format (default=\\${DEFAULT-VALUE}): \\${COMPLETION-CANDIDATES}\"],\n        converter = [ReportFormat.Converter::class]\n    )\n    private var format: ReportFormat = ReportFormat.NOOP\n\n    @Option(\n        names = [\"--test-suite-name\"],\n        description = [\"Test suite name\"],\n    )\n    private var testSuiteName: String? = null\n\n    @Option(names = [\"--output\"])\n    private var output: File? = null\n\n    @Option(\n        names = [\"--debug-output\"],\n        description = [\"Configures the debug output in this path, instead of default\"],\n    )\n    private var debugOutput: String? = null\n\n    @Option(\n        names = [\"--test-output-dir\"],\n        description = [\"Configures the test output directory for screenshots and other test artifacts (note: this does NOT include debug output)\"],\n    )\n    private var testOutputDir: String? = null\n\n    @Option(\n        names = [\"--flatten-debug-output\"],\n        description = [\"All file outputs from the test case are created in the folder without subfolders or timestamps for each run. It can be used with --debug-output. Useful for CI.\"]\n    )\n    private var flattenDebugOutput: Boolean = false\n\n    @Option(\n        names = [\"--include-tags\"],\n        description = [\"List of tags that will remove the Flows that does not have the provided tags\"],\n        split = \",\",\n    )\n    private var includeTags: List<String> = emptyList()\n\n    @Option(\n        names = [\"--exclude-tags\"],\n        description = [\"List of tags that will remove the Flows containing the provided tags\"],\n        split = \",\",\n    )\n    private var excludeTags: List<String> = emptyList()\n\n    @Option(\n        names = [\"--headless\"],\n        description = [\"(Web only) Run the tests in headless mode\"],\n    )\n    private var headless: Boolean = false\n\n    @Option(\n        names = [\"--screen-size\"],\n        description = [\"(Web only) Set the size of the headless browser. Use the format {Width}x{Height}. Usage is --screen-size 1920x1080\"],\n    )\n    private var screenSize: String? = null\n\n    @Option(\n        names = [\"--analyze\"],\n        description = [\"[Beta] Enhance the test output analysis with AI Insights\"],\n    )\n    private var analyze: Boolean = false\n\n    @Option(names = [\"--api-url\"], description = [\"[Beta] API base URL\"])\n    private var apiUrl: String = \"https://api.copilot.mobile.dev\"\n\n    @Option(names = [\"--api-key\"], description = [\"[Beta] API key\"])\n    private var apiKey: String? = null\n\n    private val client: ApiClient = ApiClient(baseUrl = apiUrl)\n    private val auth: Auth = Auth(client)\n    private val authToken: String? = auth.getAuthToken(apiKey, triggerSignIn = false)\n\n    @Option(\n        names = [\"--reinstall-driver\"],\n        description = [\"Reinstalls driver before running the test. On iOS, reinstalls xctestrunner driver. On Android, reinstalls both driver and server apps. Set to false to skip reinstallation.\"],\n        negatable = true,\n        defaultValue = \"true\",\n        fallbackValue = \"true\"\n    )\n    private var reinstallDriver: Boolean = true\n\n    @Option(\n        names = [\"--apple-team-id\"],\n        description = [\"The Team ID is a unique 10-character string generated by Apple that is assigned to your team's apple account.\"],\n        hidden = true\n    )\n    private var appleTeamId: String? = null\n\n    @Option(names = [\"-p\", \"--platform\"], description = [\"Select a platform to run on\"])\n    var platform: String? = null\n\n    @Option(\n        names = [\"--device\", \"--udid\"],\n        description = [\"Device ID to run on explicitly, can be a comma separated list of IDs: --device \\\"Emulator_1,Emulator_2\\\" \"],\n    )\n    var deviceId: String? = null\n    \n    @CommandLine.Spec\n    lateinit var commandSpec: CommandLine.Model.CommandSpec\n\n    private val usedPorts = ConcurrentHashMap<Int, Boolean>()\n    private val logger = LoggerFactory.getLogger(TestCommand::class.java)\n\n    internal fun executionPlanIncludesWebFlow(plan: ExecutionPlan): Boolean {\n        return plan.flowsToRun.any { it.toFile().isWebFlow() } ||\n               plan.sequence.flows.any { it.toFile().isWebFlow() }\n    }\n\n    internal fun allFlowsAreWebFlow(plan: ExecutionPlan): Boolean {\n        if(plan.flowsToRun.isEmpty() && plan.sequence.flows.isEmpty()) return false\n        return (plan.flowsToRun.all { it.toFile().isWebFlow() } && plan.sequence.flows.all { it.toFile().isWebFlow() })\n    }\n  \n    override fun call(): Int {\n        TestDebugReporter.install(\n            debugOutputPathAsString = debugOutput,\n            flattenDebugOutput = flattenDebugOutput,\n            printToConsole = parent?.verbose == true,\n        )\n\n        if (shardSplit != null && shardAll != null) {\n            throw CliError(\"Options --shard-split and --shard-all are mutually exclusive.\")\n        }\n\n        @Suppress(\"DEPRECATION\")\n        if (legacyShardCount != null) {\n            PrintUtils.warn(\"--shards option is deprecated and will be removed in the next Maestro version. Use --shard-split or --shard-all instead.\")\n            shardSplit = legacyShardCount\n        }\n\n        if (configFile != null && configFile?.exists()?.not() == true) {\n            throw CliError(\"The config file ${configFile?.absolutePath} does not exist.\")\n        }\n\n        if (screenSize != null && !screenSize!!.matches(Regex(\"\\\\d+x\\\\d+\"))) {\n            throw CliError(\"Invalid screen size format. Please use the format {Width}x{Height}, e.g. 1920x1080.\")\n        }\n\n        val executionPlan = try {\n            WorkspaceExecutionPlanner.plan(\n                input = flowFiles.map { it.toPath().toAbsolutePath() }.toSet(),\n                includeTags = includeTags,\n                excludeTags = excludeTags,\n                config = configFile?.toPath()?.toAbsolutePath(),\n            )\n        } catch (e: ValidationError) {\n            throw CliError(e.message)\n        }\n\n        val resolvedTestOutputDir = resolveTestOutputDir(executionPlan)\n\n        // Update TestDebugReporter with the resolved test output directory\n        TestDebugReporter.updateTestOutputDir(resolvedTestOutputDir)\n        val debugOutputPath = TestDebugReporter.getDebugOutputPath()\n\n        // Track test execution start\n        val flowCount = executionPlan.flowsToRun.size\n        val platform = parent?.platform ?: \"unknown\"\n        val deviceCount = getDeviceCount(executionPlan)\n\n        val result = try {\n            handleSessions(debugOutputPath, executionPlan, resolvedTestOutputDir)\n        } catch (e: Exception) {\n            // Track workspace failure for runtime errors\n            if (flowCount > 1) {\n                Analytics.trackEvent(WorkspaceRunFailedEvent(\n                    error = e.message ?: \"Unknown error occurred during workspace execution\",\n                    flowCount = flowCount,\n                    platform = platform,\n                    deviceCount = deviceCount,\n                ))\n            } else {\n                Analytics.trackEvent(TestRunFailedEvent(\n                    error = e.message ?: \"Unknown error occurred during workspace execution\",\n                    platform = platform,\n                ))\n            }\n            throw e\n        }\n\n        // Flush analytics events immediately after tracking the upload finished event\n        Analytics.flush()\n\n        return result\n    }\n\n    /**\n     * Get the actual number of devices that will be used for test execution\n     */\n    private fun getDeviceCount(plan: ExecutionPlan): Int {\n        val deviceIds = getDeviceIds(plan)\n        return deviceIds.size\n    }\n\n    /**\n     * Get the list of device IDs that will be used for test execution\n     */\n    private fun getDeviceIds(plan: ExecutionPlan): List<String> {\n        val includeWeb = executionPlanIncludesWebFlow(plan)\n        val connectedDevices = DeviceService.listConnectedDevices(\n            includeWeb = includeWeb,\n            host = parent?.host,\n            port = parent?.port,\n        )\n        val availableDevices = connectedDevices.map { it.instanceId }.toSet()\n        return getPassedOptionsDeviceIds(plan)\n            .filter { device -> device in availableDevices }\n            .ifEmpty { availableDevices }\n            .toList()\n    }\n\n    private fun resolveTestOutputDir(plan: ExecutionPlan): Path? {\n        // Command line flag takes precedence\n        testOutputDir?.let { return File(it).toPath() }\n        \n        // Then check workspace config\n        plan.workspaceConfig.testOutputDir?.let { return File(it).toPath() }\n        \n        // No test output directory configured\n        return null\n    }\n\n    private fun handleSessions(debugOutputPath: Path, plan: ExecutionPlan, testOutputDir: Path?): Int = runBlocking(Dispatchers.IO) {\n        val requestedShards = shardSplit ?: shardAll ?: 1\n        if (requestedShards > 1 && plan.sequence.flows.isNotEmpty()) {\n            error(\"Cannot run sharded tests with sequential execution\")\n        }\n\n        val onlySequenceFlows = plan.sequence.flows.isNotEmpty() && plan.flowsToRun.isEmpty() // An edge case\n        val includeWeb = executionPlanIncludesWebFlow(plan);\n\n        if (includeWeb) {\n          PrintUtils.warn(\"Web support is in Beta. We would appreciate your feedback!\\n\")\n        }\n\n        val connectedDevices = DeviceService.listConnectedDevices(\n            includeWeb = includeWeb,\n            host = parent?.host,\n            port = parent?.port,\n        )\n        val availableDevicesIds = connectedDevices.map { it.instanceId }.toSet()\n        val deviceIds = getPassedOptionsDeviceIds(plan)\n            .filter { device ->\n                if (device !in availableDevicesIds) {\n                    throw CliError(\"Device $device was requested, but it is not connected.\")\n                } else {\n                    true\n                }\n            }\n            .ifEmpty {\n                val platform = platform ?: parent?.platform\n                connectedDevices\n                    .filter { platform == null || it.platform == Platform.fromString(platform) }\n                    .map { it.instanceId }.toSet()\n            }\n            .toList()\n\n        val missingDevices = requestedShards - deviceIds.size\n        if (missingDevices > 0) {\n            PrintUtils.warn(\"You have ${deviceIds.size} devices connected, which is not enough to run $requestedShards shards. Missing $missingDevices device(s).\")\n            throw CliError(\"Not enough devices connected (${deviceIds.size}) to run the requested number of shards ($requestedShards).\")\n        }\n\n        val effectiveShards = when {\n\n            onlySequenceFlows -> 1\n\n            shardAll == null -> requestedShards.coerceAtMost(plan.flowsToRun.size)\n\n            shardSplit == null -> requestedShards.coerceAtMost(deviceIds.size)\n\n            else -> 1\n        }\n\n        val warning = \"Requested $requestedShards shards, \" +\n                \"but it cannot be higher than the number of flows (${plan.flowsToRun.size}). \" +\n                \"Will use $effectiveShards shards instead.\"\n        if (shardAll == null && requestedShards > plan.flowsToRun.size) PrintUtils.warn(warning)\n\n        val chunkPlans = makeChunkPlans(plan, effectiveShards, onlySequenceFlows)\n\n        val flowCount = if (onlySequenceFlows) plan.sequence.flows.size else plan.flowsToRun.size\n        val message = when {\n            shardAll != null -> \"Will run $effectiveShards shards, with all $flowCount flows in each shard\"\n            shardSplit != null -> {\n                val flowsPerShard = (flowCount.toFloat() / effectiveShards).roundToInt()\n                val isApprox = flowCount % effectiveShards != 0\n                val prefix = if (isApprox) \"approx. \" else \"\"\n                \"Will split $flowCount flows across $effectiveShards shards (${prefix}$flowsPerShard flows per shard)\"\n            }\n\n            else -> null\n        }\n        message?.let { PrintUtils.info(it) }\n\n        // Show cloud promotion message if there are more than 5 tests (at most once per day)\n        if (flowCount > 5) {\n            showCloudFasterResultsPromotionMessageIfNeeded()\n        }\n\n        val results = (0 until effectiveShards).map { shardIndex ->\n            async(Dispatchers.IO + CoroutineName(\"shard-$shardIndex\")) {\n                runShardSuite(\n                    effectiveShards = effectiveShards,\n                    deviceIds = deviceIds,\n                    shardIndex = shardIndex,\n                    chunkPlans = chunkPlans,\n                    debugOutputPath = debugOutputPath,\n                    testOutputDir = testOutputDir,\n                )\n            }\n        }.awaitAll()\n\n        val passed = results.sumOf { it.first ?: 0 }\n        val total = results.sumOf { it.second ?: 0 }\n        val suites = results.mapNotNull { it.third }\n\n        // Show cloud debug promotion message if there are failures\n        if (passed != total) {\n            showCloudDebugPromotionMessageIfNeeded()\n        }\n\n        suites.mergeSummaries()?.saveReport()\n\n        if (effectiveShards > 1) printShardsMessage(passed, total, suites)\n        if (analyze) TestAnalysisManager(apiUrl = apiUrl, apiKey = apiKey).runAnalysis(debugOutputPath)\n        if (passed == total) 0 else 1\n    }\n\n    private fun runShardSuite(\n        effectiveShards: Int,\n        deviceIds: List<String>,\n        shardIndex: Int,\n        chunkPlans: List<ExecutionPlan>,\n        debugOutputPath: Path,\n        testOutputDir: Path?,\n    ): Triple<Int?, Int?, TestExecutionSummary?> {\n        val driverHostPort = selectPort(effectiveShards)\n        val deviceId = deviceIds[shardIndex]\n        val executionPlan = chunkPlans[shardIndex]\n\n        logger.info(\"[shard ${shardIndex + 1}] Selected device $deviceId using port $driverHostPort with execution plan $executionPlan\")\n\n        return MaestroSessionManager.newSession(\n            host = parent?.host,\n            port = parent?.port,\n            teamId = appleTeamId,\n            driverHostPort = driverHostPort,\n            deviceId = deviceId,\n            platform = platform ?: parent?.platform,\n            isHeadless = headless,\n            screenSize = screenSize,\n            reinstallDriver = reinstallDriver,\n            executionPlan = executionPlan\n        ) { session ->\n            val maestro = session.maestro\n            val device = session.device\n\n            val isReplicatingSingleFile = shardAll != null && effectiveShards > 1 && flowFiles.isSingleFile\n            val isMultipleFiles = flowFiles.isSingleFile.not()\n            val isAskingForReport = format != ReportFormat.NOOP\n            if (isMultipleFiles || isAskingForReport || isReplicatingSingleFile) {\n                if (continuous) {\n                    throw CommandLine.ParameterException(\n                        commandSpec.commandLine(),\n                        \"Continuous mode is not supported when running multiple flows. (${flowFiles.joinToString(\", \")})\",\n                    )\n                }\n                runBlocking {\n                    runMultipleFlows(\n                        maestro,\n                        device,\n                        chunkPlans,\n                        shardIndex,\n                        debugOutputPath,\n                        testOutputDir,\n                        deviceId,\n                    )\n                }\n            } else {\n                val flowFile = flowFiles.first()\n                if (continuous) {\n                    if (!flattenDebugOutput) {\n                        TestDebugReporter.deleteOldFiles()\n                    }\n                    TestRunner.runContinuous(\n                        maestro,\n                        device,\n                        flowFile,\n                        env,\n                        analyze,\n                        authToken,\n                        testOutputDir,\n                        deviceId,\n                    )\n                } else {\n                    runSingleFlow(maestro, device, flowFile, debugOutputPath, testOutputDir, deviceId)\n                }\n            }\n        }\n    }\n\n    private fun selectPort(effectiveShards: Int): Int =\n        if (effectiveShards == 1) 7001\n        else (7001..7128).shuffled().find { port ->\n            usedPorts.putIfAbsent(port, true) == null\n        } ?: error(\"No available ports found\")\n\n    private fun runSingleFlow(\n        maestro: Maestro,\n        device: Device?,\n        flowFile: File,\n        debugOutputPath: Path,\n        testOutputDir: Path?,\n        deviceId: String?,\n    ): Triple<Int, Int, Nothing?> {\n        val resultView =\n            if (DisableAnsiMixin.ansiEnabled) {\n                AnsiResultView(useEmojis = !EnvUtils.isWindows())\n            } else {\n                PlainTextResultView()\n            }\n\n        val startTime = System.currentTimeMillis()\n        Analytics.trackEvent(TestRunStartedEvent(\n            platform = device?.platform.toString()\n        ))\n\n        val resultSingle = TestRunner.runSingle(\n            maestro = maestro,\n            device = device,\n            flowFile = flowFile,\n            env = env,\n            resultView = resultView,\n            debugOutputPath = debugOutputPath,\n            analyze = analyze,\n            apiKey = authToken,\n            testOutputDir = testOutputDir,\n            deviceId = deviceId,\n        )\n        val duration = System.currentTimeMillis() - startTime\n\n        if (resultSingle == 1) {\n            printExitDebugMessage()\n        }\n\n\n        Analytics.trackEvent(\n            TestRunFinishedEvent(\n                status = if (resultSingle == 0) FlowStatus.SUCCESS else FlowStatus.ERROR,\n                platform = device?.platform.toString(),\n                durationMs = duration\n            )\n        )\n\n        if (!flattenDebugOutput) {\n            TestDebugReporter.deleteOldFiles()\n        }\n\n        val result = if (resultSingle == 0) 1 else 0\n        return Triple(result, 1, null)\n    }\n\n    private suspend fun runMultipleFlows(\n        maestro: Maestro,\n        device: Device?,\n        chunkPlans: List<ExecutionPlan>,\n        shardIndex: Int,\n        debugOutputPath: Path,\n        testOutputDir: Path?,\n        deviceId: String?,\n    ): Triple<Int?, Int?, TestExecutionSummary> {\n        val startTime = System.currentTimeMillis()\n        val totalFlowCount = chunkPlans.sumOf { it.flowsToRun.size }\n        Analytics.trackEvent(WorkspaceRunStartedEvent(\n            flowCount = totalFlowCount,\n            platform = parent?.platform.toString(),\n            deviceCount = chunkPlans.size\n        ))\n\n        val suiteResult = TestSuiteInteractor(\n            maestro = maestro,\n            device = device,\n            shardIndex = if (chunkPlans.size == 1) null else shardIndex,\n            reporter = ReporterFactory.buildReporter(format, testSuiteName),\n            captureSteps = format == ReportFormat.HTML_DETAILED,\n        ).runTestSuite(\n            executionPlan = chunkPlans[shardIndex],\n            env = env,\n            reportOut = null,\n            debugOutputPath = debugOutputPath,\n            testOutputDir = testOutputDir,\n            deviceId = deviceId,\n        )\n\n        val duration = System.currentTimeMillis() - startTime\n\n\n        if (!flattenDebugOutput) {\n            TestDebugReporter.deleteOldFiles()\n        }\n\n        Analytics.trackEvent(\n            WorkspaceRunFinishedEvent(\n                flowCount = totalFlowCount,\n                deviceCount = chunkPlans.size,\n                platform = parent?.platform.toString(),\n                durationMs = duration\n            )\n        )\n        return Triple(suiteResult.passedCount, suiteResult.totalTests, suiteResult)\n    }\n\n    private fun makeChunkPlans(\n        plan: ExecutionPlan,\n        effectiveShards: Int,\n        onlySequenceFlows: Boolean,\n    ) = when {\n        onlySequenceFlows -> listOf(plan) // We only want to run sequential flows in this case.\n        shardAll != null -> (0 until effectiveShards).reversed().map { plan.copy() }\n        else -> plan.flowsToRun\n            .withIndex()\n            .groupBy { it.index % effectiveShards }\n            .map { (_, files) ->\n                val flowsToRun = files.map { it.value }\n                ExecutionPlan(flowsToRun, plan.sequence, plan.workspaceConfig)\n            }\n    }\n\n    private fun getPassedOptionsDeviceIds(plan: ExecutionPlan): List<String> {\n      val arguments = if (allFlowsAreWebFlow(plan)) {\n        \"chromium\"\n      } else deviceId ?: parent?.deviceId\n      val deviceIds = arguments\n        .orEmpty()\n        .split(\",\")\n        .map { it.trim() }\n        .filter { it.isNotBlank() }\n      return deviceIds\n    }\n\n    private fun printExitDebugMessage() {\n        println()\n        println(\"==== Debug output (logs & screenshots) ====\")\n        PrintUtils.message(TestDebugReporter.getDebugOutputPath().absolutePathString())\n    }\n\n    private fun printShardsMessage(passedTests: Int, totalTests: Int, shardResults: List<TestExecutionSummary>) {\n        val lines = listOf(\"Passed: $passedTests/$totalTests\") +\n                shardResults.mapIndexed { _, result ->\n                    \"[ ${result.suites.first().deviceName} ] - ${result.passedCount ?: 0}/${result.totalTests ?: 0}\"\n                }\n        PrintUtils.message(lines.joinToString(\"\\n\").box())\n    }\n\n    private fun TestExecutionSummary.saveReport() {\n        val reporter = ReporterFactory.buildReporter(format, testSuiteName)\n\n        format.fileExtension?.let { extension ->\n            (output ?: File(\"report$extension\")).sink()\n        }?.also { sink ->\n            reporter.report(this, sink)\n        }\n    }\n\n    private fun List<TestExecutionSummary>.mergeSummaries(): TestExecutionSummary? = reduceOrNull { acc, summary ->\n        TestExecutionSummary(\n            passed = acc.passed && summary.passed,\n            suites = acc.suites + summary.suites,\n            passedCount = sumOf { it.passedCount ?: 0 },\n            totalTests = sumOf { it.totalTests ?: 0 }\n        )\n    }\n\n    private fun showCloudFasterResultsPromotionMessageIfNeeded() {\n        // Don't show in CI environments\n        if (CiUtils.getCiProvider() != null) {\n            return\n        }\n        \n        val promotionStateManager = PromotionStateManager()\n        val today = LocalDate.now().toString()\n        \n        // Don't show if already shown today\n        if (promotionStateManager.getLastShownDate(\"fasterResults\") == today) {\n            return\n        }\n        \n        // Don't show if user has used cloud command within last 3 days\n        if (promotionStateManager.wasCloudCommandUsedWithinDays(3)) {\n            return\n        }\n        \n        val command = \"maestro cloud app_file flows_folder/\"\n        val message = \"Get results faster by ${\"executing flows in parallel\".cyan()} on Maestro Cloud virtual devices. Run: \\n${command.green()}\"\n        PrintUtils.info(message.greenBox())\n        promotionStateManager.setLastShownDate(\"fasterResults\", today)\n    }\n\n    private fun showCloudDebugPromotionMessageIfNeeded() {\n        // Don't show in CI environments\n        if (CiUtils.getCiProvider() != null) {\n            return\n        }\n        \n        val promotionStateManager = PromotionStateManager()\n        val today = LocalDate.now().toString()\n\n        // Don't show if already shown today\n        if (promotionStateManager.getLastShownDate(\"debug\") == today) {\n          return\n        }\n\n        // Don't show if user has used cloud command within last 3 days\n        if (promotionStateManager.wasCloudCommandUsedWithinDays(3)) {\n          return\n        }\n        \n        val command = \"maestro cloud app_file flows_folder/\"\n        val message = \"Debug tests faster by easy access to ${\"test recordings, maestro logs, screenshots, and more\".cyan()}.\\n\\nRun your flows on Maestro Cloud:\\n${command.green()}\"\n        PrintUtils.info(message.greenBox())\n        promotionStateManager.setLastShownDate(\"debug\", today)\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/db/KeyValueStore.kt",
    "content": "package maestro.cli.db\n\nimport java.io.File\nimport java.util.concurrent.locks.ReentrantReadWriteLock\nimport kotlin.concurrent.read\nimport kotlin.concurrent.write\n\nclass KeyValueStore(private val dbFile: File) {\n    private val lock = ReentrantReadWriteLock()\n\n    init {\n        dbFile.createNewFile()\n    }\n\n    fun get(key: String): String? = lock.read { getCurrentDB()[key] }\n\n    fun set(key: String, value: String) = lock.write {\n        val db = getCurrentDB()\n        db[key] = value\n        commit(db)\n    }\n\n    fun delete(key: String) = lock.write {\n        val db = getCurrentDB()\n        db.remove(key)\n        commit(db)\n    }\n\n    fun keys(): List<String> = lock.read { getCurrentDB().keys.toList() }\n\n    private fun getCurrentDB(): MutableMap<String, String> {\n        return dbFile\n            .readLines()\n            .associate { line ->\n                val (key, value) = line.split(\"=\", limit = 2)\n                key to value\n            }\n            .toMutableMap()\n    }\n\n    private fun commit(db: MutableMap<String, String>) {\n        dbFile.writeText(\n            db.map { (key, value) -> \"$key=$value\" }\n                .joinToString(\"\\n\")\n        )\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/device/DeviceCreateUtil.kt",
    "content": "package maestro.cli.device\n\nimport maestro.device.DeviceService\nimport maestro.device.Device\nimport maestro.device.Platform\nimport maestro.cli.CliError\nimport maestro.cli.util.*\nimport maestro.device.DeviceSpec\n\nobject DeviceCreateUtil {\n\n    fun getOrCreateDevice(\n        deviceSpec: DeviceSpec,\n        forceCreate: Boolean = false,\n        shardIndex: Int? = null,\n    ): Device.AvailableForLaunch = when (deviceSpec) {\n        is DeviceSpec.Android -> getOrCreateAndroidDevice(deviceSpec, forceCreate, shardIndex)\n        is DeviceSpec.Ios     -> getOrCreateIosDevice(deviceSpec, forceCreate, shardIndex)\n        is DeviceSpec.Web     -> Device.AvailableForLaunch(\n            platform = Platform.WEB,\n            description = \"Chromium Desktop Browser (Experimental)\",\n            modelId = deviceSpec.model,\n            deviceType = Device.DeviceType.BROWSER,\n            deviceSpec = deviceSpec,\n        )\n    }\n\n    fun getOrCreateIosDevice(\n        deviceSpec: DeviceSpec.Ios, forceCreate: Boolean, shardIndex: Int? = null\n    ): Device.AvailableForLaunch {\n        // check connected device\n        if (DeviceService.isDeviceConnected(deviceSpec.deviceName, Platform.IOS) != null && shardIndex == null && !forceCreate) {\n            throw CliError(\"A device with name ${deviceSpec.deviceName} is already connected\")\n        }\n\n        // check existing device\n        val existingDeviceId = DeviceService.isDeviceAvailableToLaunch(deviceSpec.deviceName, Platform.IOS)?.let {\n            if (forceCreate) {\n                DeviceService.deleteIosDevice(it.modelId)\n                null\n            } else it.modelId\n        }\n\n        if (existingDeviceId != null) PrintUtils.message(\"Using existing device ${deviceSpec.deviceName} (${existingDeviceId}).\")\n        else PrintUtils.message(\"Attempting to create iOS simulator: ${deviceSpec.deviceName} \")\n\n        val deviceUUID = existingDeviceId ?: try {\n            // To find the closest matching os: \"iOS-18\" -> \"iOS-18-2\", \"iOS-17\" -> \"iOS-17-5\"\n            val closestInstalledRuntime = DeviceService.listIOSDevices().firstOrNull {\n                it.deviceSpec.os.startsWith(deviceSpec.os)\n            }?.deviceSpec?.os ?: deviceSpec.os\n\n            //  Start the device\n            DeviceService.createIosDevice(deviceSpec.deviceName, deviceSpec.model, closestInstalledRuntime).toString()\n        } catch (e: IllegalStateException) {\n            val error = e.message ?: \"\"\n            if (error.contains(\"Invalid runtime\")) {\n                val msg = \"\"\"\n                    Required runtime to create the simulator is not installed: ${deviceSpec.os}\n\n                    To install additional iOS runtimes checkout this guide:\n                    * https://developer.apple.com/documentation/xcode/installing-additional-simulator-runtimes\n                \"\"\".trimIndent()\n                throw CliError(msg)\n            } else if (error.contains(\"xcrun: error: unable to find utility \\\"simctl\\\"\")) {\n                val msg = \"\"\"\n                    The xcode-select CLI tools are not installed, install with xcode-select --install\n\n                    If the xcode-select CLI tools are already installed, the path may be broken. Try\n                    running sudo xcode-select -r to repair the path and re-run this command\n                \"\"\".trimIndent()\n                throw CliError(msg)\n            } else if (error.contains(\"Invalid device type\")) {\n                throw CliError(\"Device type ${deviceSpec.model} is either not supported or not found.\")\n            } else {\n                throw CliError(error)\n            }\n        }\n\n        if (existingDeviceId == null) PrintUtils.message(\"Created simulator with name ${deviceSpec.deviceName} and UUID $deviceUUID\")\n\n        return Device.AvailableForLaunch(\n            modelId = deviceUUID,\n            description = deviceSpec.deviceName,\n            platform = Platform.IOS,\n            deviceType = Device.DeviceType.SIMULATOR,\n            deviceSpec = deviceSpec,\n        )\n    }\n\n    fun getOrCreateAndroidDevice(\n        deviceSpec: DeviceSpec.Android, forceCreate: Boolean, shardIndex: Int? = null\n    ): Device.AvailableForLaunch {\n        val systemImage = deviceSpec.emulatorImage\n        // check connected device\n        if (DeviceService.isDeviceConnected(deviceSpec.deviceName, Platform.ANDROID) != null && shardIndex == null && !forceCreate)\n            throw CliError(\"A device with name ${deviceSpec.deviceName} is already connected\")\n\n        // existing device\n        val existingDevice =\n            if (forceCreate) null\n            else DeviceService.isDeviceAvailableToLaunch(deviceSpec.deviceName, Platform.ANDROID)?.modelId\n\n        // dependencies\n        if (existingDevice == null && !DeviceService.isAndroidSystemImageInstalled(systemImage)) {\n            PrintUtils.err(\"The required system image $systemImage is not installed.\")\n\n            PrintUtils.message(\"Would you like to install it? y/n\")\n            val r = readlnOrNull()?.lowercase()\n            if (r == \"y\" || r == \"yes\") {\n                PrintUtils.message(\"Attempting to install $systemImage via Android SDK Manager...\\n\")\n                if (!DeviceService.installAndroidSystemImage(systemImage)) {\n                    val message = \"\"\"\n                        Unable to install required dependencies. You can install the system image manually by running this command:\n                        ${DeviceService.getAndroidSystemImageInstallCommand(systemImage)}\n                    \"\"\".trimIndent()\n                    throw CliError(message)\n                }\n            } else {\n                val message = \"\"\"\n                    To install the system image manually, you can run this command:\n                    ${DeviceService.getAndroidSystemImageInstallCommand(systemImage)}\n                \"\"\".trimIndent()\n                throw CliError(message)\n            }\n        }\n\n        if (existingDevice != null) PrintUtils.message(\"Using existing device ${deviceSpec.deviceName}.\")\n        else PrintUtils.message(\"Attempting to create Android emulator: ${deviceSpec.deviceName} \")\n\n        val deviceLaunchId = try {\n            existingDevice ?: DeviceService.createAndroidDevice(\n                deviceName = deviceSpec.deviceName,\n                device = deviceSpec.model,\n                systemImage = systemImage,\n                tag = deviceSpec.tag,\n                abi = deviceSpec.cpuArchitecture.value,\n                force = forceCreate,\n            )\n        } catch (e: IllegalStateException) {\n            throw CliError(\"${e.message}\")\n        }\n\n        if (existingDevice == null) PrintUtils.message(\"Created Android emulator: ${deviceSpec.deviceName} ($systemImage)\")\n\n        return Device.AvailableForLaunch(\n            modelId = deviceLaunchId,\n            description = deviceLaunchId,\n            platform = Platform.ANDROID,\n            deviceType = Device.DeviceType.EMULATOR,\n            deviceSpec = deviceSpec,\n        )\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/device/PickDeviceInteractor.kt",
    "content": "package maestro.cli.device\n\nimport maestro.cli.CliError\nimport maestro.device.DeviceService\nimport maestro.device.DeviceService.withPlatform\nimport maestro.device.Device\nimport maestro.device.Platform\nimport maestro.cli.util.EnvUtils\nimport maestro.cli.util.PrintUtils\n\nobject PickDeviceInteractor {\n\n    fun pickDevice(\n        deviceId: String? = null,\n        driverHostPort: Int? = null,\n        platform: Platform? = null,\n        deviceIndex: Int? = null,\n    ): Device.Connected {\n        if (deviceId != null) {\n            return DeviceService.listConnectedDevices()\n                .find {\n                    it.instanceId.equals(deviceId, ignoreCase = true)\n                } ?: throw CliError(\"Device with id $deviceId is not connected\")\n        }\n\n        return pickDeviceInternal(platform, deviceIndex)\n            .let { pickedDevice ->\n                var result: Device = pickedDevice\n\n                if (result is Device.AvailableForLaunch) {\n                    when (result.deviceSpec.platform) {\n                        Platform.ANDROID -> PrintUtils.message(\"Launching Android emulator...\")\n                        Platform.IOS -> PrintUtils.message(\"Launching iOS simulator...\")\n                        Platform.WEB -> PrintUtils.message(\"Launching ${result.description}\")\n                    }\n\n                    result = DeviceService.startDevice(result, driverHostPort)\n                }\n\n                if (result !is Device.Connected) {\n                    error(\"Device $result is not connected\")\n                }\n\n                result\n            }\n    }\n\n    private fun pickDeviceInternal(platform: Platform?, selectedIndex: Int? = null): Device {\n        val connectedDevices = DeviceService.listConnectedDevices().withPlatform(platform)\n\n        val selected = if(selectedIndex != null) {\n            selectedIndex\n        } else if (connectedDevices.size == 1) {\n            0\n        } else {\n            null\n        }\n\n        if (selected != null) {\n            val device = connectedDevices[selected]\n\n            PickDeviceView.showRunOnDevice(device)\n\n            return device\n        }\n\n        if (connectedDevices.isEmpty()) {\n            return startDevice(platform)\n        }\n\n        return pickRunningDevice(connectedDevices)\n    }\n\n    private fun startDevice(platform: Platform?): Device {\n        if (EnvUtils.isWSL()) {\n            throw CliError(\"No running emulator found. Start an emulator manually and try again.\\nFor setup info checkout: https://maestro.mobile.dev/getting-started/installing-maestro/windows\")\n        }\n\n        PrintUtils.message(\"No running devices found. Launch a device manually or select a number from the options below:\\n\")\n        PrintUtils.message(\"[1] Start or create a Maestro recommended device\\n[2] List existing devices\\n[3] Quit\")\n        val input = readlnOrNull()?.lowercase()?.trim()\n\n        when(input) {\n            \"1\" -> {\n                PrintUtils.clearConsole()\n                val maestroDeviceConfiguration = PickDeviceView.requestDeviceOptions(platform)\n                return DeviceCreateUtil.getOrCreateDevice(maestroDeviceConfiguration, false)\n            }\n            \"2\" -> {\n                PrintUtils.clearConsole()\n                val availableDevices = DeviceService.listAvailableForLaunchDevices().withPlatform(platform)\n                if (availableDevices.isEmpty()) {\n                    throw CliError(\"No devices available. To proceed, either install Android SDK or Xcode.\")\n                }\n\n                return PickDeviceView.pickDeviceToStart(availableDevices)\n            }\n            else -> {\n                throw CliError(\"Please either start a device manually or via running maestro start-device to proceed running your flows\")\n            }\n        }\n    }\n\n    private fun pickRunningDevice(devices: List<Device>): Device {\n        return PickDeviceView.pickRunningDevice(devices)\n    }\n\n}\n\nfun main() {\n    println(PickDeviceInteractor.pickDevice())\n\n    println(\"Ready\")\n    while (!Thread.interrupted()) {\n        Thread.sleep(1000)\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/device/PickDeviceView.kt",
    "content": "package maestro.cli.device\n\nimport maestro.cli.CliError\nimport maestro.cli.util.PrintUtils\nimport maestro.device.Device\nimport maestro.device.DeviceSpecRequest\nimport maestro.device.DeviceSpec\nimport maestro.device.Platform\nimport org.fusesource.jansi.Ansi.ansi\n\nobject PickDeviceView {\n\n    fun showRunOnDevice(device: Device) {\n        println(\"Running on ${device.description}\")\n    }\n\n    fun pickDeviceToStart(devices: List<Device>): Device {\n        printIndexedDevices(devices)\n\n        println(\"Choose a device to boot and run on.\")\n        printEnterNumberPrompt()\n\n        return pickIndex(devices)\n    }\n\n    fun requestDeviceOptions(platform: Platform? = null): DeviceSpec {\n        PrintUtils.message(\"Please specify a device platform [android, ios, web]:\")\n        val selectedPlatform = platform\n            ?: (readlnOrNull()?.lowercase()?.let {\n                Platform.fromString(it)\n            } ?: throw CliError(\"Please specify a platform\"))\n\n        val spec = DeviceSpec.fromRequest(\n            when (selectedPlatform) {\n                Platform.ANDROID -> DeviceSpecRequest.Android()\n                Platform.IOS -> DeviceSpecRequest.Ios()\n                Platform.WEB -> DeviceSpecRequest.Web()\n            }\n        )\n\n        return spec\n    }\n\n    fun pickRunningDevice(devices: List<Device>): Device {\n        printIndexedDevices(devices)\n\n        println(\"Multiple running devices detected. Choose a device to run on.\")\n        printEnterNumberPrompt()\n\n        return pickIndex(devices)\n    }\n\n    private fun <T> pickIndex(data: List<T>): T {\n        println()\n        while (!Thread.interrupted()) {\n            val index = readlnOrNull()?.toIntOrNull() ?: 0\n\n            if (index < 1 || index > data.size) {\n                printEnterNumberPrompt()\n                continue\n            }\n\n            return data[index - 1]\n        }\n\n        error(\"Interrupted\")\n    }\n\n    private fun printEnterNumberPrompt() {\n        println()\n        println(\"Enter a number from the list above:\")\n    }\n\n    private fun printIndexedDevices(devices: List<Device>) {\n        val devicesByPlatform = devices.groupBy {\n            it.platform\n        }\n\n        var index = 0\n\n        devicesByPlatform.forEach { (platform, devices) ->\n            println(platform.description)\n            println()\n            devices.forEach { device ->\n                println(\n                    ansi()\n                        .render(\"[\")\n                        .fgCyan()\n                        .render(\"${++index}\")\n                        .fgDefault()\n                        .render(\"] ${device.description}\")\n                )\n            }\n            println()\n        }\n    }\n\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/driver/DriverBuildConfig.kt",
    "content": "package maestro.cli.driver\n\nimport maestro.cli.api.CliVersion\n\ndata class DriverBuildConfig(\n    val teamId: String,\n    val derivedDataPath: String,\n    val sourceCodePath: String = \"driver/ios\",\n    val sourceCodeRoot: String = System.getProperty(\"user.home\"),\n    val destination: String = \"generic/platform=iphoneos\",\n    val architectures: String = \"arm64\",\n    val configuration: String = \"Debug\",\n    val cliVersion: CliVersion?\n)"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/driver/DriverBuilder.kt",
    "content": "package maestro.cli.driver\n\nimport maestro.MaestroException\nimport java.io.File\nimport java.nio.file.*\nimport java.util.*\nimport java.util.concurrent.TimeUnit\nimport kotlin.io.path.pathString\n\nclass DriverBuilder(private val processBuilderFactory: XcodeBuildProcessBuilderFactory = XcodeBuildProcessBuilderFactory()) {\n    private val waitTime: Long by lazy {\n        System.getenv(\"MAESTRO_XCODEBUILD_WAIT_TIME\")?.toLongOrNull() ?: DEFAULT_XCODEBUILD_WAIT_TIME\n    }\n\n    /**\n     * Builds the iOS driver for real iOS devices by extracting the driver source, copying it to a temporary build\n     * directory, and executing the Xcode build process. The resulting build products are placed in the specified\n     * derived data path.\n     *\n     * @param config A configuration object containing details like team ID, derived data path, destination platform,\n     *               architectures, and other parameters required for building the driver.\n     * @return The path to the directory containing build products.\n     * @throws RuntimeException if the build process fails.\n     *\n     * Directory Structure:\n     *   1. workingDirectory (Path): Root working directory for Maestro stored in the user's home directory.\n     *      .maestro\n     *      |_ maestro-iphoneos-driver-build\n     *         |_ driver-iphoneos: Consists the build products to setup iOS driver: maestro-driver-*.xctestrun,\n     *            Debug-iphoneos/maestro-driver-iosUITests-Runner.app, and Debug-iphoneos/maestro-driver-ios.app\n     *         |_ output.log: In case of errors output.log would be there to help debug\n     *\n     *   2. xcodebuildOutput (Path): A temporary directory created to store the output logs of the xcodebuild process and source code.\n     *      It exists only for the duration of the build operation.\n     *      e.g., $TMPDIR/maestro-xcodebuild-outputXXXXXX\n     */\n    fun buildDriver(config: DriverBuildConfig): Path {\n        // Get driver source from resources\n        val driverSourcePath = getDriverSourceFromResources(config)\n\n        // Create temporary build directory\n        val workingDirectory = Paths.get(config.sourceCodeRoot, \".maestro\")\n        val buildDir = Files.createDirectories(workingDirectory.resolve(\"maestro-iphoneos-driver-build\")).apply {\n            // Cleanup directory before we execute the build\n            toFile().deleteRecursively()\n        }\n        val xcodebuildOutput = Files.createTempDirectory(\"maestro-xcodebuild-output\")\n        val outputFile = File(xcodebuildOutput.pathString + \"/output.log\")\n\n        try {\n            // Copy driver source to build directory\n            Files.walk(driverSourcePath).use { paths ->\n                paths.filter { Files.isRegularFile(it) }.forEach { path ->\n                    val targetPath = xcodebuildOutput.resolve(driverSourcePath.relativize(path).toString())\n                    Files.createDirectories(targetPath.parent)\n                    Files.copy(path, targetPath, StandardCopyOption.REPLACE_EXISTING)\n                }\n            }\n\n            // Create derived data path\n            val derivedDataPath = buildDir.resolve(config.derivedDataPath)\n            Files.createDirectories(derivedDataPath)\n\n            // Build command\n            val process = processBuilderFactory.createProcess(\n                commands = listOf(\n                    \"xcodebuild\",\n                    \"clean\",\n                    \"build-for-testing\",\n                    \"-project\", \"${xcodebuildOutput.pathString}/maestro-driver-ios.xcodeproj\",\n                    \"-scheme\", \"maestro-driver-ios\",\n                    \"-destination\", config.destination,\n                    \"-allowProvisioningUpdates\",\n                    \"-derivedDataPath\", derivedDataPath.toString(),\n                    \"DEVELOPMENT_TEAM=${config.teamId}\",\n                    \"ARCHS=${config.architectures}\",\n                    \"CODE_SIGN_IDENTITY=Apple Development\",\n                ), workingDirectory = workingDirectory.toFile(), outputFile = outputFile\n            )\n\n            process.waitFor(waitTime, TimeUnit.SECONDS)\n\n            if (process.exitValue() != 0) {\n                // copy the error log inside driver output\n                val targetErrorFile = File(buildDir.toFile(), outputFile.name)\n                outputFile.copyTo(targetErrorFile, overwrite = true)\n                throw MaestroException.IOSDeviceDriverSetupException(\n                    \"\"\"\n                        \n                        Failed to build iOS driver for connected iOS device.\n                        \n                        Error details:\n                        - Build log: ${targetErrorFile.path}\n                    \"\"\".trimIndent()\n                )\n            }\n\n            // Return path to build products\n            return derivedDataPath.resolve(\"Build/Products\")\n        } finally {\n            File(buildDir.toFile(), \"version.properties\").writer().use {\n                val p = Properties()\n                p[\"version\"] = config.cliVersion.toString()\n                p.store(it, null)\n            }\n            xcodebuildOutput.toFile().deleteRecursively()\n        }\n    }\n\n    fun getDriverSourceFromResources(config: DriverBuildConfig): Path {\n        val resourcePath = config.sourceCodePath\n        val resourceUrl = DriverBuilder::class.java.classLoader.getResource(resourcePath)\n            ?: throw IllegalArgumentException(\"Resource not found: $resourcePath\")\n        val uri = resourceUrl.toURI()\n\n        val path = if (uri.scheme == \"jar\") {\n            val fs = try {\n                FileSystems.getFileSystem(uri)\n            } catch (e: FileSystemNotFoundException) {\n                FileSystems.newFileSystem(uri, emptyMap<String, Any>())\n            }\n            fs.getPath(\"/$resourcePath\")\n        } else {\n            Paths.get(uri)\n        }\n        return path\n    }\n\n    companion object {\n        private const val DEFAULT_XCODEBUILD_WAIT_TIME: Long = 120\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/driver/RealIOSDeviceDriver.kt",
    "content": "package maestro.cli.driver\n\nimport maestro.MaestroException\nimport maestro.cli.api.CliVersion\nimport maestro.cli.util.EnvUtils\nimport maestro.cli.util.PrintUtils.message\nimport java.nio.file.Files\nimport java.nio.file.Path\nimport java.nio.file.Paths\nimport java.util.Properties\n\nclass RealIOSDeviceDriver(private val teamId: String?, private val destination: String, private val driverBuilder: DriverBuilder) {\n\n    fun validateAndUpdateDriver(driverRootDirectory: Path = getDefaultVersionPropertiesFile(), force: Boolean = false) {\n        val driverDirectory = driverRootDirectory.resolve(\"maestro-iphoneos-driver-build\")\n        val versionPropertiesFile = driverDirectory.resolve(\"version.properties\")\n\n        val currentCliVersion = EnvUtils.CLI_VERSION ?: throw IllegalStateException(\"CLI version is unavailable.\")\n\n        if (force) {\n            buildDriver(driverDirectory, message = \"Building iOS driver for $destination...\")\n            return\n        }\n\n        if (Files.exists(versionPropertiesFile)) {\n            val properties = Properties().apply {\n                Files.newBufferedReader(versionPropertiesFile).use(this::load)\n            }\n            val localVersion = properties.getProperty(\"version\")?.let { CliVersion.parse(it) }\n                ?: throw IllegalStateException(\"Invalid or missing version in version.properties.\")\n\n            val products = driverDirectory.resolve(\"driver-iphoneos\").resolve(\"Build\").resolve(\"Products\")\n            val xctestRun = products.toFile().walk().find { it.extension == \"xctestrun\" }\n            if (currentCliVersion > localVersion) {\n                message(\"Local version $localVersion of iOS driver is outdated. Updating to latest.\")\n                buildDriver(driverDirectory, message = \"Validating and updating iOS driver for real iOS device: $destination...\")\n            } else if (xctestRun?.exists() == false || xctestRun == null) {\n                message(\"Drivers for $destination not found, building the drivers.\")\n                buildDriver(driverDirectory, message = \"Building the drivers for $destination\")\n            }\n        } else {\n            buildDriver(driverDirectory, \"Building iOS driver for $destination...\")\n        }\n    }\n\n    private fun buildDriver(driverDirectory: Path, message: String) {\n        val spinner = Spinner(message).apply {\n            start()\n        }\n        // Build the new driver\n        val teamId = try {\n            requireNotNull(teamId) { \"Apple account team ID must be specified.\" }\n        } catch (e: IllegalArgumentException) {\n            throw MaestroException.MissingAppleTeamId(\n                \"Apple account team ID must be specified to build drivers for connected iPhone.\"\n            )\n        }\n\n        // Cleanup old driver files if necessary\n        if (Files.exists(driverDirectory)) {\n            message(\"Cleaning up old driver files...\")\n            driverDirectory.toFile().deleteRecursively()\n        }\n\n        driverBuilder.buildDriver(\n            DriverBuildConfig(\n                teamId = teamId,\n                derivedDataPath = \"driver-iphoneos\",\n                destination = destination,\n                sourceCodePath = \"driver/ios\",\n                cliVersion = EnvUtils.CLI_VERSION\n            )\n        )\n\n        spinner.stop()\n        message(\"✅ Drivers successfully set up for destination $destination\")\n    }\n\n    private fun getDefaultVersionPropertiesFile(): Path {\n        val maestroDirectory = Paths.get(System.getProperty(\"user.home\"), \".maestro\")\n        return maestroDirectory\n    }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/driver/Spinner.kt",
    "content": "package maestro.cli.driver\n\nclass Spinner(private val message: String = \"Processing\") {\n    private val frames = listOf(\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\")\n    private var active = true\n    private lateinit var thread: Thread\n\n    fun start() {\n        thread = Thread {\n            var i = 0\n            while (active) {\n                print(\"\\r${frames[i % frames.size]} $message\")\n                Thread.sleep(100)\n                i++\n            }\n        }\n        thread.start()\n    }\n\n    fun stop() {\n        active = false\n        thread.join()\n        print(\"\\r✅ $message\\n\")\n    }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/driver/XcodeBuildProcessBuilderFactory.kt",
    "content": "package maestro.cli.driver\n\nimport java.io.File\n\nclass XcodeBuildProcessBuilderFactory {\n\n    fun createProcess(commands: List<String>, workingDirectory: File, outputFile: File): Process {\n        return ProcessBuilder(commands).directory(workingDirectory).redirectOutput(outputFile)\n            .redirectError(outputFile)\n            .start()\n    }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/graphics/AWTUtils.kt",
    "content": "package maestro.cli.graphics\n\nimport org.jcodec.api.FrameGrab\nimport org.jcodec.api.awt.AWTSequenceEncoder\nimport org.jcodec.common.io.NIOUtils\nimport java.awt.Graphics2D\nimport java.io.File\n\nfun Graphics2D.use(block: (g: Graphics2D) -> Unit) {\n    try {\n        block(this)\n    } finally {\n        dispose()\n    }\n}\n\nfun AWTSequenceEncoder.use(block: (encoder: AWTSequenceEncoder) -> Unit) {\n    try {\n        block(this)\n    } finally {\n        finish()\n    }\n}\n\nfun useFrameGrab(file: File, block: (grab: FrameGrab) -> Unit) {\n    NIOUtils.readableChannel(file).use { channelIn ->\n        val grab = FrameGrab.createFrameGrab(channelIn)\n        block(grab)\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/graphics/LocalVideoRenderer.kt",
    "content": "package maestro.cli.graphics\n\nimport maestro.cli.runner.resultview.AnsiResultView\nimport maestro.cli.view.ProgressBar\nimport maestro.cli.view.render\nimport okio.ByteString.Companion.decodeBase64\nimport org.jcodec.api.PictureWithMetadata\nimport org.jcodec.api.awt.AWTSequenceEncoder\nimport org.jcodec.common.io.NIOUtils\nimport org.jcodec.common.model.Rational\nimport org.jcodec.scale.AWTUtil\nimport java.awt.image.BufferedImage\nimport java.io.File\n\ninterface FrameRenderer {\n    fun render(\n        outputWidthPx: Int,\n        outputHeightPx: Int,\n        screen: BufferedImage,\n        text: String,\n    ): BufferedImage\n}\n\nclass LocalVideoRenderer(\n    private val frameRenderer: FrameRenderer,\n    private val outputFile: File,\n    private val outputFPS: Int,\n    private val outputWidthPx: Int,\n    private val outputHeightPx: Int,\n) : VideoRenderer {\n\n    override fun render(\n        screenRecording: File,\n        textFrames: List<AnsiResultView.Frame>,\n    ) {\n        System.err.println()\n        System.err.println(\"@|bold Rendering video - This may take some time...|@\".render())\n        System.err.println()\n        System.err.println(outputFile.absolutePath)\n\n        val uploadProgress = ProgressBar(50)\n        NIOUtils.writableFileChannel(outputFile.absolutePath).use { out ->\n            AWTSequenceEncoder(out, Rational.R(outputFPS, 1)).use { encoder ->\n                useFrameGrab(screenRecording) { grab ->\n                    val outputDurationSeconds = grab.videoTrack.meta.totalDuration\n                    val outputFrameCount = (outputDurationSeconds * outputFPS).toInt()\n                    var curFrame: PictureWithMetadata = grab.nativeFrameWithMetadata!!\n                    var nextFrame: PictureWithMetadata? = grab.nativeFrameWithMetadata\n                    (0..outputFrameCount).forEach { frameIndex ->\n                        val currentTimestampSeconds = frameIndex.toDouble() / outputFPS\n\n                        // !! Due to smart cast limitation: https://youtrack.jetbrains.com/issue/KT-7186\n                        @Suppress(\"UNNECESSARY_NOT_NULL_ASSERTION\")\n                        while (nextFrame != null && nextFrame!!.timestamp <= currentTimestampSeconds) {\n                            curFrame = nextFrame!!\n                            nextFrame = grab.nativeFrameWithMetadata\n                        }\n\n                        val curImage = AWTUtil.toBufferedImage(curFrame.picture)\n                        val curTextFrame = textFrames.lastOrNull { frame -> frame.timestamp.div(1000.0) <= currentTimestampSeconds } ?: textFrames.first()\n                        val curText = curTextFrame.content.decodeBase64()!!.string(Charsets.UTF_8).stripAnsiCodes()\n                        val outputImage = frameRenderer.render(outputWidthPx, outputHeightPx, curImage, curText)\n                        encoder.encodeImage(outputImage)\n\n                        uploadProgress.set(frameIndex / outputFrameCount.toFloat())\n                    }\n                }\n            }\n        }\n        System.err.println()\n        System.err.println()\n        System.err.println(\"Rendering complete! If you're sharing on Twitter be sure to tag us \\uD83D\\uDE04 @|bold @mobile__dev|@\".render())\n    }\n\n    private fun String.stripAnsiCodes(): String {\n        return replace(\"\\\\u001B\\\\[[;\\\\d]*[mH]\".toRegex(), \"\")\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/graphics/RemoteVideoRenderer.kt",
    "content": "package maestro.cli.graphics\n\nimport maestro.cli.api.ApiClient\nimport maestro.cli.runner.resultview.AnsiResultView\nimport maestro.cli.view.ProgressBar\nimport maestro.cli.view.render\nimport java.io.File\n\nclass RemoteVideoRenderer : VideoRenderer {\n\n    override fun render(\n        screenRecording: File,\n        textFrames: List<AnsiResultView.Frame>\n    ) {\n        val client = ApiClient(\"\")\n\n        System.err.println()\n        System.err.println(\"@|bold ⚠\\uFE0F DEPRECATION NOTICE ⚠\\uFE0F\\nThis method of recording will soon be deprecated and replaced with a local rendering implementation.\\nTo switch to (Beta) local rendering, use \\\"maestro record --local ...\\\". This will become the default behavior in a future Maestro release.|@\".render())\n        System.err.println()\n\n        val uploadProgress = ProgressBar(50)\n        System.err.println(\"Uploading raw files for render...\")\n        val id = client.render(screenRecording, textFrames) { totalBytes, bytesWritten ->\n            uploadProgress.set(bytesWritten.toFloat() / totalBytes)\n        }\n        System.err.println()\n\n        var renderProgress: ProgressBar? = null\n        var status: String? = null\n        var positionInQueue: Int? = null\n        while (true) {\n            val state = client.getRenderState(id)\n\n            // If new position or status, print header\n            if (state.status != status || state.positionInQueue != positionInQueue) {\n                status = state.status\n                positionInQueue = state.positionInQueue\n\n                if (renderProgress != null) {\n                    renderProgress.set(1f)\n                    System.err.println()\n                }\n\n                System.err.println()\n\n                System.err.println(\"Status : ${styledStatus(state.status)}\")\n                if (state.positionInQueue != null) {\n                    System.err.println(\"Position In Queue : ${state.positionInQueue}\")\n                }\n            }\n\n            // Add ticks to progress bar\n            if (state.currentTaskProgress != null) {\n                if (renderProgress == null) renderProgress = ProgressBar(50)\n                renderProgress.set(state.currentTaskProgress)\n            }\n\n            // Print download url or error and return\n            if (state.downloadUrl != null || state.error != null) {\n                System.err.println()\n                if (state.downloadUrl != null) {\n                    System.err.println(\"@|bold Signed Download URL:|@\".render())\n                    System.err.println()\n                    print(\"@|cyan,bold ${state.downloadUrl}|@\".render())\n                    System.err.println()\n                    System.err.println()\n                    System.err.println(\"Open the link above to download your video. If you're sharing on Twitter be sure to tag us @|bold @mobile__dev|@!\".render())\n                } else {\n                    System.err.println(\"@|bold Render encountered during rendering:|@\".render())\n                    System.err.println(state.error)\n                }\n                break\n            }\n\n            Thread.sleep(2000)\n        }\n    }\n\n    private fun styledStatus(status: String): String {\n        val style = when (status) {\n            \"PENDING\" -> \"yellow,bold\"\n            \"RENDERING\" -> \"blue,bold\"\n            \"SUCCESS\" -> \"green,bold\"\n            else -> \"bold\"\n        }\n        return \"@|$style $status|@\".render()\n    }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/graphics/SkiaFrameRenderer.kt",
    "content": "package maestro.cli.graphics\n\nimport org.jetbrains.skia.Canvas\nimport org.jetbrains.skia.Color\nimport org.jetbrains.skia.Font\nimport org.jetbrains.skia.Paint\nimport org.jetbrains.skia.Rect\nimport org.jetbrains.skia.Surface\nimport org.jetbrains.skiko.toImage\nimport java.awt.image.BufferedImage\nimport javax.imageio.ImageIO\n\nclass SkiaFrameRenderer : FrameRenderer {\n\n    private val backgroundImage = ImageIO.read(SkiaFrameRenderer::class.java.getResource(\"/record-background.jpg\")!!).toImage()\n\n    private val shadowColor = Color.makeARGB(100, 0, 0, 0)\n\n    private val scenePadding = 40f\n    private val sceneGap = 40f\n\n    private val headerBgColor = Color.makeARGB(50, 255, 255, 255)\n    private val headerHeight = 60f\n    private val headerFont = Font(SkiaFonts.SANS_SERIF_TYPEFACE, 22f)\n    private val headerTextColor = Color.makeARGB(200, 0, 0, 0)\n    private val headerText = \"Record your own using ${'$'} maestro record YourFlow.yaml\"\n\n    private val headerButtonColor = Color.makeARGB(50, 255, 255, 255)\n    private val headerButtonSize = 20f\n    private val headerButtonGap = 10f\n    private val headerButtonMx = 20f\n\n    private val footerBgColor = Color.makeARGB(50, 255, 255, 255)\n    private val footerHeight = 60f\n    private val footerFont = Font(SkiaFonts.SANS_SERIF_TYPEFACE, 22f)\n    private val footerTextColor = Color.makeARGB(200, 0, 0, 0)\n    private val footerText = \"maestro.mobile.dev\"\n\n    private val terminalBgColor = Color.makeARGB(220, 0, 0, 0)\n    private val terminalContentPadding = 40f\n\n    private val textClipper = SkiaTextClipper()\n\n    override fun render(\n        outputWidthPx: Int,\n        outputHeightPx: Int,\n        screen: BufferedImage,\n        text: String\n    ): BufferedImage {\n        return Surface.makeRasterN32Premul(outputWidthPx, outputHeightPx).use { surface ->\n            drawScene(surface.canvas, outputWidthPx.toFloat(), outputHeightPx.toFloat(), screen, text)\n            surface.makeImageSnapshot().toBufferedImage()\n        }\n    }\n\n    private fun drawScene(canvas: Canvas, outputWidthPx: Float, outputHeightPx: Float, screen: BufferedImage, text: String) {\n        val fullScreenRect = Rect(0f, 0f, outputWidthPx, outputHeightPx)\n        canvas.drawImageRect(backgroundImage, fullScreenRect)\n\n        val paddedScreenRect = fullScreenRect.inflate(-scenePadding)\n\n        drawContent(canvas, paddedScreenRect, screen, text)\n    }\n\n    private fun drawContent(canvas: Canvas, containerRect: Rect, screen: BufferedImage, text: String) {\n        val imageRect = drawDevice(canvas, containerRect, screen)\n        drawTerminal(canvas, containerRect, imageRect, text)\n    }\n\n    private fun drawDevice(canvas: Canvas, containerRect: Rect, screen: BufferedImage): Rect {\n        val cornerRadius = 20f\n        val deviceImageScale = containerRect.height / screen.height.toFloat()\n        var deviceImageRect = Rect(0f, 0f, screen.width.toFloat(), screen.height.toFloat()).scale(deviceImageScale)\n        deviceImageRect = deviceImageRect.offset(containerRect.right - deviceImageRect.right, containerRect.top)\n        val deviceImageRectRounded = deviceImageRect.toRRect(cornerRadius)\n        canvas.save()\n        canvas.clipRRect(deviceImageRectRounded, true)\n        canvas.drawImageRect(screen.toImage(), deviceImageRect)\n        canvas.restore()\n        canvas.drawRectShadow(deviceImageRectRounded, 0f, 0f, 20f, 0.5f, shadowColor)\n        return deviceImageRect\n    }\n\n    private fun drawTerminal(canvas: Canvas, containerRect: Rect, imageRect: Rect, text: String) {\n        val terminalRect = Rect(containerRect.left, containerRect.top, imageRect.left - sceneGap, containerRect.bottom)\n        val terminalRectRounded = terminalRect.toRRect(20f)\n        canvas.drawRectShadow(terminalRectRounded, 0f, 0f, 20f, 0.5f, shadowColor)\n\n        val headerRect = drawHeader(canvas, terminalRect)\n        val footerRect = drawFooter(canvas, terminalRect)\n        drawTerminalContent(canvas, terminalRect, headerRect, footerRect, text)\n    }\n\n    private fun drawFooter(canvas: Canvas, terminalRect: Rect): Rect {\n        val footerRect = Rect.makeXYWH(terminalRect.left, terminalRect.bottom - footerHeight, terminalRect.width, footerHeight)\n        val headerRectRounded = footerRect.toRRect(0f, 0f, 20f, 20f)\n        canvas.drawRRect(headerRectRounded, Paint().apply { color = footerBgColor })\n\n        drawFooterText(canvas, footerRect)\n\n        return footerRect\n    }\n\n    private fun drawFooterText(canvas: Canvas, footerRect: Rect) {\n        val paint = Paint().apply {\n            color = footerTextColor\n        }\n        val textRect = footerFont.measureText(footerText, paint)\n        val x = footerRect.left + (footerRect.width - textRect.width) / 2\n        val y = footerRect.top + footerRect.height / 2 + textRect.height / 3\n        canvas.drawString(footerText, x, y, footerFont, paint)\n    }\n\n    private fun drawHeader(canvas: Canvas, terminalRect: Rect): Rect {\n        val headerRect = Rect.makeXYWH(terminalRect.left, terminalRect.top, terminalRect.width, headerHeight)\n        val headerRectRounded = headerRect.toRRect(20f, 20f, 0f, 0f)\n        canvas.drawRRect(headerRectRounded, Paint().apply { color = headerBgColor })\n\n        drawHeaderButtons(canvas, headerRect)\n        drawHeaderText(canvas, headerRect)\n\n        return headerRect\n    }\n\n    private fun drawHeaderButtons(canvas: Canvas, headerRect: Rect) {\n        var centerX = headerRect.left + headerButtonMx + headerButtonSize / 2\n        val centerY = headerRect.top + headerRect.height / 2\n\n        repeat(3) {\n            canvas.drawCircle(centerX, centerY, headerButtonSize / 2, Paint().apply { color = headerButtonColor })\n            centerX += headerButtonSize + headerButtonGap\n        }\n    }\n\n    private fun drawHeaderText(canvas: Canvas, headerRect: Rect) {\n        val paint = Paint().apply {\n            color = headerTextColor\n        }\n        val textRect = headerFont.measureText(headerText, paint)\n        val x = headerRect.left + (headerRect.width - textRect.width) / 2\n        val y = headerRect.top + headerRect.height / 2 + textRect.height / 3\n        canvas.drawString(headerText, x, y, headerFont, paint)\n    }\n\n    private fun drawTerminalContent(canvas: Canvas, terminalRect: Rect, headerRect: Rect, footerRect: Rect, string: String) {\n        val contentRect = Rect.makeLTRB(terminalRect.left, headerRect.bottom, terminalRect.right, footerRect.top)\n        canvas.drawRect(contentRect, Paint().apply { color = terminalBgColor })\n\n        val paddedContentRect = Rect.makeLTRB(\n            l = contentRect.left + terminalContentPadding,\n            t = contentRect.top + terminalContentPadding,\n            r = contentRect.right - terminalContentPadding,\n            b = contentRect.bottom - terminalContentPadding / 4f,\n        )\n\n        val focusedLineIndex = getFocusedLineIndex(string)\n        val focusedLinePadding = 5\n        textClipper.renderClippedText(canvas, paddedContentRect, string, focusedLineIndex + focusedLinePadding)\n    }\n\n    private fun getFocusedLineIndex(text: String): Int {\n        val lines = text.lines()\n        val indexOfFirstPendingLine = lines.indexOfFirst { it.contains(\"\\uD83D\\uDD32\") }\n        if (indexOfFirstPendingLine != -1) return indexOfFirstPendingLine\n        val indexOfLastCheck = lines.indexOfLast { it.contains(\"✅\") }\n        if (indexOfLastCheck != -1) return indexOfLastCheck\n        return 0\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/graphics/SkiaTextClipper.kt",
    "content": "package maestro.cli.graphics\n\nimport org.jetbrains.skia.Canvas\nimport org.jetbrains.skia.Color\nimport org.jetbrains.skia.FontMgr\nimport org.jetbrains.skia.Rect\nimport org.jetbrains.skia.paragraph.FontCollection\nimport org.jetbrains.skia.paragraph.Paragraph\nimport org.jetbrains.skia.paragraph.ParagraphBuilder\nimport org.jetbrains.skia.paragraph.ParagraphStyle\nimport org.jetbrains.skia.paragraph.RectHeightMode\nimport org.jetbrains.skia.paragraph.RectWidthMode\nimport org.jetbrains.skia.paragraph.TextStyle\nimport kotlin.math.min\n\nclass SkiaTextClipper {\n\n    private val terminalTextStyle = TextStyle().apply {\n        fontFamilies = SkiaFonts.MONOSPACE_FONT_FAMILIES.toTypedArray()\n        fontSize = 24f\n        color = Color.WHITE\n    }\n\n    fun renderClippedText(canvas: Canvas, rect: Rect, text: String, focusedLine: Int) {\n        val p = createParagraph(text, rect.width)\n        val focusedLineRange = getRangeForLine(text, focusedLine)\n        val focusedLineBottom = p.getRectsForRange(\n            start = focusedLineRange.first,\n            end = focusedLineRange.second,\n            rectHeightMode = RectHeightMode.MAX,\n            rectWidthMode = RectWidthMode.MAX\n        ).maxOf { it.rect.bottom }\n        val offsetY = min(0f, rect.height - focusedLineBottom)\n        canvas.save()\n        canvas.clipRect(rect)\n        p.paint(canvas, rect.left, rect.top + offsetY)\n        canvas.restore()\n    }\n\n    private fun getRangeForLine(text: String, lineIndex: Int): Pair<Int, Int> {\n        var start = 0\n        var end = 0\n        var currentLine = 0\n        while (currentLine <= lineIndex) {\n            start = end\n            end = text.indexOf('\\n', start + 1)\n            if (end == -1) {\n                end = text.length\n                break\n            }\n            currentLine++\n        }\n        return Pair(start, end)\n    }\n\n    private fun createParagraph(text: String, width: Float): Paragraph {\n        val fontCollection = FontCollection().setDefaultFontManager(FontMgr.default)\n        return ParagraphBuilder(ParagraphStyle(), fontCollection)\n            .pushStyle(terminalTextStyle)\n            .addText(text)\n            .build()\n            .apply { layout(width) }\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/graphics/SkiaUtils.kt",
    "content": "package maestro.cli.graphics\n\nimport org.jetbrains.skia.Bitmap\nimport org.jetbrains.skia.Canvas\nimport org.jetbrains.skia.ColorAlphaType\nimport org.jetbrains.skia.FontMgr\nimport org.jetbrains.skia.FontStyle\nimport org.jetbrains.skia.Image\nimport org.jetbrains.skia.ImageInfo\nimport org.jetbrains.skia.RRect\nimport org.jetbrains.skia.Rect\nimport org.jetbrains.skia.Typeface\nimport java.awt.Transparency\nimport java.awt.color.ColorSpace\nimport java.awt.image.BufferedImage\nimport java.awt.image.ComponentColorModel\nimport java.awt.image.DataBuffer\nimport java.awt.image.DataBufferByte\nimport java.awt.image.Raster\n\nobject SkiaFonts {\n\n    val SANS_SERIF_FONT_FAMILIES = listOf(\"Inter\", \"Roboto\", \"Arial\", \"Avenir Next\", \"Avenir\", \"Helvetica Neue\", \"Helvetica\", \"Arial Nova\", \"Arimo\", \"Noto Sans\", \"Liberation Sans\", \"DejaVu Sans\", \"Nimbus Sans\", \"Clear Sans\", \"Lato\", \"Cantarell\", \"Arimo\", \"Ubuntu\")\n    val MONOSPACE_FONT_FAMILIES = listOf(\"Cascadia Code\", \"Source Code Pro\", \"Menlo\", \"Consolas\", \"Monaco\", \"Liberation Mono\", \"Ubuntu Mono\", \"Roboto Mono\", \"Lucida Console\", \"Monaco\", \"Courier New\", \"Courier\")\n\n    val SANS_SERIF_TYPEFACE: Typeface\n    val MONOSPACE_TYPEFACE: Typeface\n\n    init {\n        val sansSerifTypeface = FontMgr.default.matchFamiliesStyle(SANS_SERIF_FONT_FAMILIES.toTypedArray(), FontStyle.NORMAL)\n        if (sansSerifTypeface == null) {\n            System.err.println(\"Failed to find a sans-serif typeface.\")\n        }\n        SANS_SERIF_TYPEFACE = sansSerifTypeface ?: Typeface.makeEmpty()\n\n        val monospaceTypeface = FontMgr.default.matchFamiliesStyle(MONOSPACE_FONT_FAMILIES.toTypedArray(), FontStyle.NORMAL)\n        if (monospaceTypeface == null) {\n            System.err.println(\"Failed to find a monospace typeface.\")\n        }\n        MONOSPACE_TYPEFACE = monospaceTypeface ?: Typeface.makeEmpty()\n    }\n}\n\n// https://stackoverflow.com/a/70852824\nfun Image.toBufferedImage(): BufferedImage {\n    val storage = Bitmap()\n    storage.allocPixelsFlags(ImageInfo.makeS32(this.width, this.height, ColorAlphaType.PREMUL), false)\n    Canvas(storage).drawImage(this, 0f, 0f)\n\n    val bytes = storage.readPixels(storage.imageInfo, (this.width * 4), 0, 0)!!\n    val buffer = DataBufferByte(bytes, bytes.size)\n    val raster = Raster.createInterleavedRaster(\n        buffer,\n        this.width,\n        this.height,\n        this.width * 4, 4,\n        intArrayOf(2, 1, 0, 3),     // BGRA order\n        null\n    )\n    val colorModel = ComponentColorModel(\n        ColorSpace.getInstance(ColorSpace.CS_sRGB),\n        true,\n        false,\n        Transparency.TRANSLUCENT,\n        DataBuffer.TYPE_BYTE\n    )\n\n    return BufferedImage(colorModel, raster!!, false, null)\n}\n\nfun Rect.toRRect(radii: Float): RRect {\n    return RRect.makeLTRB(this.left, this.top, this.right, this.bottom, radii)\n}\n\nfun Rect.toRRect(tlRad: Float, trRad: Float, brRad: Float, blRad: Float): RRect {\n    return RRect.makeLTRB(this.left, this.top, this.right, this.bottom, tlRad, trRad, brRad, blRad)\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/graphics/VideoRenderer.kt",
    "content": "package maestro.cli.graphics\n\nimport maestro.cli.runner.resultview.AnsiResultView\nimport java.io.File\n\ninterface VideoRenderer {\n    fun render(\n        screenRecording: File,\n        textFrames: List<AnsiResultView.Frame>,\n    )\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/insights/TestAnalysisManager.kt",
    "content": "package maestro.cli.insights\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties\nimport com.fasterxml.jackson.databind.SerializationFeature\nimport com.fasterxml.jackson.datatype.jsr310.JavaTimeModule\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport com.fasterxml.jackson.module.kotlin.readValue\nimport com.github.romankh3.image.comparison.ImageComparisonUtil\nimport maestro.cli.api.ApiClient\nimport maestro.cli.cloud.CloudInteractor\nimport maestro.cli.util.CiUtils\nimport maestro.cli.util.EnvUtils\nimport maestro.cli.util.PrintUtils\nimport maestro.cli.view.box\nimport java.nio.file.Files\nimport java.nio.file.Path\nimport java.util.stream.Collectors\nimport kotlin.io.path.createDirectories\nimport kotlin.io.path.exists\nimport kotlin.io.path.readText\nimport kotlin.io.path.writeText\n\ndata class AnalysisScreenshot (\n    val data: ByteArray,\n    val path: Path,\n)\n\ndata class AnalysisLog (\n    val data: ByteArray,\n    val path: Path,\n)\n\ndata class AnalysisDebugFiles(\n    val screenshots: List<AnalysisScreenshot>,\n    val logs: List<AnalysisLog>,\n    val commands: List<AnalysisLog>,\n)\n\nclass TestAnalysisManager(private val apiUrl: String, private val apiKey: String?) {\n    private val apiClient by lazy {\n        ApiClient(apiUrl)\n    }\n\n    fun runAnalysis(debugOutputPath: Path): Int {\n        val debugFiles = processDebugFiles(debugOutputPath)\n        if (debugFiles == null) {\n            PrintUtils.warn(\"No screenshots or debug artifacts found for analysis.\")\n            return 0;\n        }\n\n        return CloudInteractor(\n            client = apiClient,\n            appFileValidator = { null },\n            workspaceValidator = maestro.orchestra.validation.WorkspaceValidator(),\n        ).analyze(\n            apiKey = apiKey,\n            debugFiles = debugFiles,\n            debugOutputPath = debugOutputPath\n        )\n    }\n\n    private fun processDebugFiles(outputPath: Path): AnalysisDebugFiles? {\n        val files = Files.walk(outputPath)\n            .filter(Files::isRegularFile)\n            .collect(Collectors.toList())\n\n        if (files.isEmpty()) {\n            return null\n        }\n\n        return getDebugFiles(files)\n    }\n\n    private fun getDebugFiles(files: List<Path>): AnalysisDebugFiles {\n        val logs = mutableListOf<AnalysisLog>()\n        val commands = mutableListOf<AnalysisLog>()\n        val screenshots = mutableListOf<AnalysisScreenshot>()\n\n        files.forEach { path ->\n            val data = Files.readAllBytes(path)\n            val fileName = path.fileName.toString().lowercase()\n\n            when {\n                fileName.endsWith(\".png\") || fileName.endsWith(\".jpg\") || fileName.endsWith(\".jpeg\") -> {\n                    screenshots.add(AnalysisScreenshot(data = data, path = path))\n                }\n\n                fileName.startsWith(\"commands\") -> {\n                    commands.add(AnalysisLog(data = data, path = path))\n                }\n\n                fileName == \"maestro.log\" -> {\n                    logs.add(AnalysisLog(data = data, path = path))\n                }\n            }\n        }\n\n        val filteredScreenshots = filterSimilarScreenshots(screenshots)\n\n        return AnalysisDebugFiles(\n            logs = logs,\n            commands = commands,\n            screenshots = filteredScreenshots,\n        )\n    }\n\n    private val screenshotsDifferenceThreshold = 5.0\n\n    private fun filterSimilarScreenshots(\n        screenshots: List<AnalysisScreenshot>\n    ): List<AnalysisScreenshot> {\n        val uniqueScreenshots = mutableListOf<AnalysisScreenshot>()\n\n        for (screenshot in screenshots) {\n            val isSimilar = uniqueScreenshots.any { existingScreenshot ->\n                val diffPercent = ImageComparisonUtil.getDifferencePercent(\n                    ImageComparisonUtil.readImageFromResources(existingScreenshot.path.toString()),\n                    ImageComparisonUtil.readImageFromResources(screenshot.path.toString())\n                )\n                diffPercent <= screenshotsDifferenceThreshold\n            }\n\n            if (!isSimilar) {\n                uniqueScreenshots.add(screenshot)\n            }\n        }\n\n        return uniqueScreenshots\n    }\n\n    /**\n     * The Notification system for Test Analysis.\n     *  - Uses configuration from $XDG_CONFIG_HOME/maestro/analyze-notification.json.\n     */\n    companion object AnalysisNotification {\n        private const val MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED = \"MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED\"\n        private val disabled: Boolean\n            get() = System.getenv(MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED) == \"true\"\n\n        private val notificationStatePath: Path = EnvUtils.xdgStateHome().resolve(\"analyze-notification.json\")\n\n        private val JSON = jacksonObjectMapper().apply {\n            registerModule(JavaTimeModule())\n            enable(SerializationFeature.INDENT_OUTPUT)\n        }\n        private val shouldNotNotify: Boolean\n            get() {\n                if (CiUtils.getCiProvider() != null) {\n                    return true\n                }\n\n                return disabled || (notificationStatePath.exists() && notificationState.acknowledged)\n            }\n\n        private val notificationState: AnalysisNotificationState\n            get() = JSON.readValue<AnalysisNotificationState>(notificationStatePath.readText())\n\n        fun maybeNotify() {\n            if (shouldNotNotify) return\n\n            println(\n                listOf(\n                    \"Try out our new Analyze with Ai feature.\\n\",\n                    \"See what's new:\",\n                    \"> https://maestro.mobile.dev/cli/test-suites-and-reports#analyze\",\n                    \"Analyze command:\",\n                    \"$ maestro test flow-file.yaml --analyze | bash\\n\",\n                    \"To disable this notification, set $MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED environment variable to \\\"true\\\" before running Maestro.\"\n                ).joinToString(\"\\n\").box()\n            )\n            ack();\n        }\n\n        private fun ack() {\n            val state = AnalysisNotificationState(\n                acknowledged = true\n            )\n\n            val stateJson = JSON.writeValueAsString(state)\n            notificationStatePath.parent.createDirectories()\n            notificationStatePath.writeText(stateJson + \"\\n\")\n        }\n    }\n}\n\n@JsonIgnoreProperties(ignoreUnknown = true)\ndata class AnalysisNotificationState(\n    val acknowledged: Boolean = false\n)\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/mcp/McpServer.kt",
    "content": "package maestro.cli.mcp\n\nimport io.ktor.utils.io.streams.*\nimport io.modelcontextprotocol.kotlin.sdk.*\nimport io.modelcontextprotocol.kotlin.sdk.server.Server\nimport io.modelcontextprotocol.kotlin.sdk.server.ServerOptions\nimport io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport\nimport kotlinx.coroutines.Job\nimport kotlinx.coroutines.runBlocking\nimport kotlinx.serialization.json.*\nimport kotlinx.io.*\nimport maestro.cli.session.MaestroSessionManager\nimport maestro.debuglog.LogConfig\nimport maestro.cli.mcp.tools.ListDevicesTool\nimport maestro.cli.mcp.tools.StartDeviceTool\nimport maestro.cli.mcp.tools.LaunchAppTool\nimport maestro.cli.mcp.tools.TakeScreenshotTool\nimport maestro.cli.mcp.tools.TapOnTool\nimport maestro.cli.mcp.tools.InputTextTool\nimport maestro.cli.mcp.tools.BackTool\nimport maestro.cli.mcp.tools.StopAppTool\nimport maestro.cli.mcp.tools.RunFlowTool\nimport maestro.cli.mcp.tools.RunFlowFilesTool\nimport maestro.cli.mcp.tools.CheckFlowSyntaxTool\nimport maestro.cli.mcp.tools.InspectViewHierarchyTool\nimport maestro.cli.mcp.tools.CheatSheetTool\nimport maestro.cli.mcp.tools.QueryDocsTool\nimport maestro.cli.util.WorkingDirectory\n\n// Main function to run the Maestro MCP server\nfun runMaestroMcpServer() {\n    // Disable all console logging to prevent interference with JSON-RPC communication\n    LogConfig.configure(logFileName = null, printToConsole = false)\n    \n    val sessionManager = MaestroSessionManager\n\n    // Create the MCP Server instance with Maestro implementation\n    val server = Server(\n        Implementation(\n            name = \"maestro\",\n            version = \"1.0.0\"\n        ),\n        ServerOptions(\n            capabilities = ServerCapabilities(\n                tools = ServerCapabilities.Tools(listChanged = true)\n            )\n        )\n    )\n\n    // Register tools\n    server.addTools(listOf(\n        ListDevicesTool.create(),\n        StartDeviceTool.create(),\n        LaunchAppTool.create(sessionManager),\n        TakeScreenshotTool.create(sessionManager),\n        TapOnTool.create(sessionManager),\n        InputTextTool.create(sessionManager),\n        BackTool.create(sessionManager),\n        StopAppTool.create(sessionManager),\n        RunFlowTool.create(sessionManager),\n        RunFlowFilesTool.create(sessionManager),\n        CheckFlowSyntaxTool.create(),\n        InspectViewHierarchyTool.create(sessionManager),\n        CheatSheetTool.create(),\n        QueryDocsTool.create()\n    ))\n\n\n    // Create a transport using standard IO for server communication\n    val transport = StdioServerTransport(\n        System.`in`.asSource().buffered(),\n        System.out.asSink().buffered()\n    )\n\n    System.err.println(\"MCP Server: Started. Waiting for messages. Working directory: ${WorkingDirectory.baseDir}\")\n\n    runBlocking {\n        server.connect(transport)\n        val done = Job()\n        server.onClose {\n            done.complete()\n        }\n        done.join()\n    }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/mcp/README.md",
    "content": "# Maestro MCP Server\n\n## Overview\n\nThe Maestro MCP (Model Context Protocol) server enables LLM-driven automation and orchestration of Maestro commands and device management. It serves two primary functions for calling LLMs:\n- enables LLMs to directly control and interact with devices using Maestro device capabilities\n- enables LLMs to write, validate, and run Maestro code (flows)\n\nThe MCP server is designed to be extensible, maintainable, and easy to run as part of the Maestro CLI. It supports real-time device management, app automation, and more, all via a standardized protocol.\n\n## Features\n\n- Exposes Maestro device and automation commands as MCP tools\n- Supports listing, launching, and interacting with devices\n- Supports running flow yaml or files and checking the flow file syntax\n- Easily extensible: add new tools with minimal boilerplate\n- Includes a test script and config for automated validation\n\n## Running the MCP Server\n\nTo use the MCP server as an end user, after following the maestro install instructions run:\n\n```\nmaestro mcp\n```\n\nThis launches the MCP server via the Maestro CLI, exposing Maestro tools over STDIO for LLM agents and other clients.\n\n## Developing\n\n## Extending the MCP Server\n\nTo add a new tool:\n1. Create a new file in `maestro-cli/src/main/java/maestro/cli/mcp/tools/` following the same patterns as the other tools.\n2. Add your tool to the `addTools` call in `McpServer.kt`\n3. Build the CLI with `./gradlew :maestro-cli:installDist`\n4. Test your tool by running `./maestro-cli/src/test/mcp/test-single-mcp-tool.sh` with appropriate args for your tool\n\n## Evals testing\n\nWhen testing a Maestro MCP tool, it's important to test not only that it works correctly but that LLMs can call it correctly and use the output appropriately. This happens less frequently than is expected. Make sure to add relevant test cases to our evals framework in `./maestro-cli/src/test/mcp/maestro-evals.yaml`, and then run the eval test suite with:\n\n```\nANTHROPIC_API_KEY=<your_key> ./maestro-cli/src/test/mcp/run-mcp-server-evals.sh\n```\n\n## Implementation Notes & Rationale\n\n### Using forked version of official kotlin MCP SDK\n\nThe [official MCP Kotlin SDK](https://github.com/modelcontextprotocol/kotlin-sdk) can't be used directly because it requires Java 21 and Kotlin 2.x, while Maestro is built on Java 8 and Kotlin 1.8.x for broad compatibility. However, we want to be able to benefit from features added to the SDK since the MCP spec is changing rapidly. So we created a fork that \"downgrades\" the reference SDK to Java 8 and Kotlin 1.8.22.\n\n\n### Why Integrate MCP Server Directly Into `maestro-cli`?\n\n- **Dependency Management:** The MCP server needs access to abstractions like `MaestroSessionManager` and other CLI internals. Placing it in a separate module (e.g., `maestro-mcp`) would create a circular dependency between `maestro-cli` and the new module.\n- **Simplicity:** Keeping all MCP logic within `maestro-cli` avoids complex build configurations and makes the integration easier to maintain and review.\n- **Extensibility:** This approach allows new tools to be added with minimal boilerplate and direct access to CLI features.\n\n### Potential Future Improvements\n\n- **Shared Abstractions:** If more MCP-related code or other integrations are needed, consider extracting shared abstractions (e.g., session management, tool interfaces) into a `common` or `core` module. This would allow for a clean separation and potentially enable a standalone `maestro-mcp` module.\n- **Streamable HTTP:** This MCP server currently only uses STDIO for communication.\n\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/mcp/tools/BackTool.kt",
    "content": "package maestro.cli.mcp.tools\n\nimport io.modelcontextprotocol.kotlin.sdk.*\nimport io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool\nimport kotlinx.serialization.json.*\nimport maestro.cli.session.MaestroSessionManager\nimport maestro.orchestra.BackPressCommand\nimport maestro.orchestra.Orchestra\nimport maestro.orchestra.MaestroCommand\nimport kotlinx.coroutines.runBlocking\n\nobject BackTool {\n    fun create(sessionManager: MaestroSessionManager): RegisteredTool {\n        return RegisteredTool(\n            Tool(\n                name = \"back\",\n                description = \"Press the back button on the device\",\n                inputSchema = Tool.Input(\n                    properties = buildJsonObject {\n                        putJsonObject(\"device_id\") {\n                            put(\"type\", \"string\")\n                            put(\"description\", \"The ID of the device to press back on\")\n                        }\n                    },\n                    required = listOf(\"device_id\")\n                )\n            )\n        ) { request ->\n            try {\n                val deviceId = request.arguments[\"device_id\"]?.jsonPrimitive?.content\n                \n                if (deviceId == null) {\n                    return@RegisteredTool CallToolResult(\n                        content = listOf(TextContent(\"device_id is required\")),\n                        isError = true\n                    )\n                }\n                \n                val result = sessionManager.newSession(\n                    host = null,\n                    port = null,\n                    driverHostPort = null,\n                    deviceId = deviceId,\n                    platform = null\n                ) { session ->\n                    val command = BackPressCommand(\n                        label = null,\n                        optional = false\n                    )\n                    \n                    val orchestra = Orchestra(session.maestro)\n                    runBlocking {\n                        orchestra.runFlow(listOf(MaestroCommand(command = command)))\n                    }\n                    \n                    buildJsonObject {\n                        put(\"success\", true)\n                        put(\"device_id\", deviceId)\n                        put(\"message\", \"Back button pressed successfully\")\n                    }.toString()\n                }\n                \n                CallToolResult(content = listOf(TextContent(result)))\n            } catch (e: Exception) {\n                CallToolResult(\n                    content = listOf(TextContent(\"Failed to press back: ${e.message}\")),\n                    isError = true\n                )\n            }\n        }\n    }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/mcp/tools/CheatSheetTool.kt",
    "content": "package maestro.cli.mcp.tools\n\nimport io.modelcontextprotocol.kotlin.sdk.*\nimport io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool\nimport kotlinx.serialization.json.*\nimport maestro.auth.ApiKey\nimport maestro.utils.HttpClient\nimport okhttp3.Request\nimport kotlin.time.Duration.Companion.minutes\n\nobject CheatSheetTool {\n    fun create(): RegisteredTool {\n        return RegisteredTool(\n            Tool(\n                name = \"cheat_sheet\",\n                description = \"Get the Maestro cheat sheet with common commands and syntax examples. \" +\n                    \"Returns comprehensive documentation on Maestro flow syntax, commands, and best practices.\",\n                inputSchema = Tool.Input(\n                    properties = buildJsonObject {},\n                    required = emptyList()\n                )\n            )\n        ) { _ ->\n            try {\n                val apiKey = ApiKey.getToken()\n                if (apiKey.isNullOrBlank()) {\n                    return@RegisteredTool CallToolResult(\n                        content = listOf(TextContent(\"MAESTRO_CLOUD_API_KEY environment variable is required\")),\n                        isError = true\n                    )\n                }\n                \n                val client = HttpClient.build(\n                    name = \"CheatSheetTool\",\n                    readTimeout = 2.minutes\n                )\n                \n                // Make GET request to cheat sheet endpoint\n                val httpRequest = Request.Builder()\n                    .url(\"https://api.copilot.mobile.dev/v2/bot/maestro-cheat-sheet\")\n                    .header(\"Authorization\", \"Bearer $apiKey\")\n                    .get()\n                    .build()\n                \n                val response = client.newCall(httpRequest).execute()\n                \n                response.use {\n                    if (!response.isSuccessful) {\n                        val errorMessage = response.body?.string().takeIf { it?.isNotEmpty() == true } ?: \"Unknown error\"\n                        return@RegisteredTool CallToolResult(\n                            content = listOf(TextContent(\"Failed to get cheat sheet (${response.code}): $errorMessage\")),\n                            isError = true\n                        )\n                    }\n                    \n                    val cheatSheetContent = response.body?.string() ?: \"\"\n                    \n                    CallToolResult(content = listOf(TextContent(cheatSheetContent)))\n                }\n            } catch (e: Exception) {\n                CallToolResult(\n                    content = listOf(TextContent(\"Failed to get cheat sheet: ${e.message}\")),\n                    isError = true\n                )\n            }\n        }\n    }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/mcp/tools/CheckFlowSyntaxTool.kt",
    "content": "package maestro.cli.mcp.tools\n\nimport io.modelcontextprotocol.kotlin.sdk.*\nimport io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool\nimport kotlinx.serialization.json.*\nimport maestro.orchestra.yaml.YamlCommandReader\n\nobject CheckFlowSyntaxTool {\n    fun create(): RegisteredTool {\n        return RegisteredTool(\n            Tool(\n                name = \"check_flow_syntax\",\n                description = \"Validates the syntax of a block of Maestro code. Valid maestro code must be well-formatted YAML.\",\n                inputSchema = Tool.Input(\n                    properties = buildJsonObject {\n                        putJsonObject(\"flow_yaml\") {\n                            put(\"type\", \"string\")\n                            put(\"description\", \"YAML-formatted Maestro flow content to validate\")\n                        }\n                    },\n                    required = listOf(\"flow_yaml\")\n                )\n            )\n        ) { request ->\n            try {\n                val flowYaml = request.arguments[\"flow_yaml\"]?.jsonPrimitive?.content\n                \n                if (flowYaml == null) {\n                    return@RegisteredTool CallToolResult(\n                        content = listOf(TextContent(\"flow_yaml is required\")),\n                        isError = true\n                    )\n                }\n                \n                val result = try {\n                    YamlCommandReader.checkSyntax(flowYaml)\n                    buildJsonObject {\n                        put(\"valid\", true)\n                        put(\"message\", \"Flow syntax is valid\")\n                    }.toString()\n                } catch (e: Exception) {\n                    buildJsonObject {\n                        put(\"valid\", false)\n                        put(\"error\", e.message ?: \"Unknown parsing error\")\n                        put(\"message\", \"Syntax check failed\")\n                    }.toString()\n                }\n                \n                CallToolResult(content = listOf(TextContent(result)))\n            } catch (e: Exception) {\n                CallToolResult(\n                    content = listOf(TextContent(\"Failed to check flow syntax: ${e.message}\")),\n                    isError = true\n                )\n            }\n        }\n    }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/mcp/tools/InputTextTool.kt",
    "content": "package maestro.cli.mcp.tools\n\nimport io.modelcontextprotocol.kotlin.sdk.*\nimport io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool\nimport kotlinx.serialization.json.*\nimport maestro.cli.session.MaestroSessionManager\nimport maestro.orchestra.InputTextCommand\nimport maestro.orchestra.Orchestra\nimport maestro.orchestra.MaestroCommand\nimport kotlinx.coroutines.runBlocking\n\nobject InputTextTool {\n    fun create(sessionManager: MaestroSessionManager): RegisteredTool {\n        return RegisteredTool(\n            Tool(\n                name = \"input_text\",\n                description = \"Input text into the currently focused text field\",\n                inputSchema = Tool.Input(\n                    properties = buildJsonObject {\n                        putJsonObject(\"device_id\") {\n                            put(\"type\", \"string\")\n                            put(\"description\", \"The ID of the device to input text on\")\n                        }\n                        putJsonObject(\"text\") {\n                            put(\"type\", \"string\")\n                            put(\"description\", \"The text to input\")\n                        }\n                    },\n                    required = listOf(\"device_id\", \"text\")\n                )\n            )\n        ) { request ->\n            try {\n                val deviceId = request.arguments[\"device_id\"]?.jsonPrimitive?.content\n                val text = request.arguments[\"text\"]?.jsonPrimitive?.content\n                \n                if (deviceId == null || text == null) {\n                    return@RegisteredTool CallToolResult(\n                        content = listOf(TextContent(\"Both device_id and text are required\")),\n                        isError = true\n                    )\n                }\n                \n                val result = sessionManager.newSession(\n                    host = null,\n                    port = null,\n                    driverHostPort = null,\n                    deviceId = deviceId,\n                    platform = null\n                ) { session ->\n                    val command = InputTextCommand(\n                        text = text,\n                        label = null,\n                        optional = false\n                    )\n                    \n                    val orchestra = Orchestra(session.maestro)\n                    runBlocking {\n                        orchestra.runFlow(listOf(MaestroCommand(command = command)))\n                    }\n                    \n                    buildJsonObject {\n                        put(\"success\", true)\n                        put(\"device_id\", deviceId)\n                        put(\"text\", text)\n                        put(\"message\", \"Text input successful\")\n                    }.toString()\n                }\n                \n                CallToolResult(content = listOf(TextContent(result)))\n            } catch (e: Exception) {\n                CallToolResult(\n                    content = listOf(TextContent(\"Failed to input text: ${e.message}\")),\n                    isError = true\n                )\n            }\n        }\n    }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/mcp/tools/InspectViewHierarchyTool.kt",
    "content": "package maestro.cli.mcp.tools\n\nimport io.modelcontextprotocol.kotlin.sdk.*\nimport io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool\nimport kotlinx.serialization.json.*\nimport maestro.cli.session.MaestroSessionManager\nimport maestro.TreeNode\nimport kotlinx.coroutines.runBlocking\n\nobject InspectViewHierarchyTool {\n    fun create(sessionManager: MaestroSessionManager): RegisteredTool {\n        return RegisteredTool(\n            Tool(\n                name = \"inspect_view_hierarchy\",\n                description = \"Get the nested view hierarchy of the current screen in CSV format. Returns UI elements \" +\n                    \"with bounds coordinates for interaction. Use this to understand screen layout, find specific elements \" +\n                    \"by text/id, or locate interactive components. Elements include bounds (x,y,width,height), text content, \" +\n                    \"resource IDs, and interaction states (clickable, enabled, checked).\",\n                inputSchema = Tool.Input(\n                    properties = buildJsonObject {\n                        putJsonObject(\"device_id\") {\n                            put(\"type\", \"string\")\n                            put(\"description\", \"The ID of the device to get hierarchy from\")\n                        }\n                    },\n                    required = listOf(\"device_id\")\n                )\n            )\n        ) { request ->\n            try {\n                val deviceId = request.arguments[\"device_id\"]?.jsonPrimitive?.content\n                \n                if (deviceId == null) {\n                    return@RegisteredTool CallToolResult(\n                        content = listOf(TextContent(\"device_id is required\")),\n                        isError = true\n                    )\n                }\n                \n                val result = sessionManager.newSession(\n                    host = null,\n                    port = null,\n                    driverHostPort = null,\n                    deviceId = deviceId,\n                    platform = null\n                ) { session ->\n                    val maestro = session.maestro\n                    val viewHierarchy = maestro.viewHierarchy()\n                    val tree = viewHierarchy.root\n                    \n                    // Return CSV format (original format for compatibility)\n                    ViewHierarchyFormatters.extractCsvOutput(tree)\n                }\n                \n                CallToolResult(content = listOf(TextContent(result)))\n            } catch (e: Exception) {\n                CallToolResult(\n                    content = listOf(TextContent(\"Failed to inspect UI: ${e.message}\")),\n                    isError = true\n                )\n            }\n        }\n    }\n    \n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/mcp/tools/LaunchAppTool.kt",
    "content": "package maestro.cli.mcp.tools\n\nimport io.modelcontextprotocol.kotlin.sdk.*\nimport io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool\nimport kotlinx.serialization.json.*\nimport maestro.cli.session.MaestroSessionManager\nimport maestro.orchestra.LaunchAppCommand\nimport maestro.orchestra.Orchestra\nimport maestro.orchestra.MaestroCommand\nimport kotlinx.coroutines.runBlocking\n\nobject LaunchAppTool {\n    fun create(sessionManager: MaestroSessionManager): RegisteredTool {\n        return RegisteredTool(\n            Tool(\n                name = \"launch_app\",\n                description = \"Launch an application on the connected device\",\n                inputSchema = Tool.Input(\n                    properties = buildJsonObject {\n                        putJsonObject(\"device_id\") {\n                            put(\"type\", \"string\")\n                            put(\"description\", \"The ID of the device to launch the app on\")\n                        }\n                        putJsonObject(\"appId\") {\n                            put(\"type\", \"string\")\n                            put(\"description\", \"Bundle ID or app ID to launch\")\n                        }\n                    },\n                    required = listOf(\"device_id\", \"appId\")\n                )\n            )\n        ) { request ->\n            try {\n                val deviceId = request.arguments[\"device_id\"]?.jsonPrimitive?.content\n                val appId = request.arguments[\"appId\"]?.jsonPrimitive?.content\n                \n                if (deviceId == null || appId == null) {\n                    return@RegisteredTool CallToolResult(\n                        content = listOf(TextContent(\"Both device_id and appId are required\")),\n                        isError = true\n                    )\n                }\n                \n                val result = sessionManager.newSession(\n                    host = null,\n                    port = null,\n                    driverHostPort = null,\n                    deviceId = deviceId,\n                    platform = null\n                ) { session ->\n                    val command = LaunchAppCommand(\n                        appId = appId,\n                        clearState = null,\n                        clearKeychain = null,\n                        stopApp = null,\n                        permissions = null,\n                        launchArguments = null,\n                        label = null,\n                        optional = false\n                    )\n                    \n                    val orchestra = Orchestra(session.maestro)\n                    runBlocking {\n                        orchestra.runFlow(listOf(MaestroCommand(command = command)))\n                    }\n                    \n                    buildJsonObject {\n                        put(\"success\", true)\n                        put(\"device_id\", deviceId)\n                        put(\"app_id\", appId)\n                        put(\"message\", \"App launched successfully\")\n                    }.toString()\n                }\n                \n                CallToolResult(content = listOf(TextContent(result)))\n            } catch (e: Exception) {\n                CallToolResult(\n                    content = listOf(TextContent(\"Failed to launch app: ${e.message}\")),\n                    isError = true\n                )\n            }\n        }\n    }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/mcp/tools/ListDevicesTool.kt",
    "content": "package maestro.cli.mcp.tools\n\nimport io.modelcontextprotocol.kotlin.sdk.*\nimport io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool\nimport kotlinx.serialization.json.*\nimport maestro.device.DeviceService\n\nobject ListDevicesTool {\n    fun create(): RegisteredTool {\n        return RegisteredTool(\n            Tool(\n                name = \"list_devices\",\n                description = \"List all available devices that can be launched for automation.\",\n                inputSchema = Tool.Input(\n                    properties = buildJsonObject { },\n                    required = emptyList()\n                )\n            )\n        ) { _ ->\n            try {\n                val availableDevices = DeviceService.listAvailableForLaunchDevices(includeWeb = true)\n                val connectedDevices = DeviceService.listConnectedDevices()\n                \n                val allDevices = buildJsonArray {\n                    // Add connected devices\n                    connectedDevices.forEach { device ->\n                        addJsonObject {\n                            put(\"device_id\", device.instanceId)\n                            put(\"name\", device.description)\n                            put(\"platform\", device.platform.name.lowercase())\n                            put(\"type\", device.deviceType.name.lowercase())\n                            put(\"connected\", true)\n                        }\n                    }\n                    \n                    // Add available devices that aren't already connected\n                    availableDevices.forEach { device ->\n                        val alreadyConnected = connectedDevices.any { it.instanceId == device.modelId }\n                        if (!alreadyConnected) {\n                            addJsonObject {\n                                put(\"device_id\", device.modelId)\n                                put(\"name\", device.description)\n                                put(\"platform\", device.platform.name.lowercase())\n                                put(\"type\", device.deviceType.name.lowercase()) \n                                put(\"connected\", false)\n                            }\n                        }\n                    }\n                }\n                \n                val result = buildJsonObject {\n                    put(\"devices\", allDevices)\n                }\n                \n                CallToolResult(content = listOf(TextContent(result.toString())))\n            } catch (e: Exception) {\n                CallToolResult(\n                    content = listOf(TextContent(\"Failed to list devices: ${e.message}\")),\n                    isError = true\n                )\n            }\n        }\n    }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/mcp/tools/QueryDocsTool.kt",
    "content": "package maestro.cli.mcp.tools\n\nimport io.modelcontextprotocol.kotlin.sdk.*\nimport io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool\nimport kotlinx.serialization.json.*\nimport maestro.auth.ApiKey\nimport maestro.utils.HttpClient\nimport okhttp3.MediaType.Companion.toMediaType\nimport okhttp3.Request\nimport okhttp3.RequestBody.Companion.toRequestBody\nimport kotlin.time.Duration.Companion.minutes\n\nobject QueryDocsTool {\n    fun create(): RegisteredTool {\n        return RegisteredTool(\n            Tool(\n                name = \"query_docs\",\n                description = \"Query the Maestro documentation for specific information. \" +\n                    \"Ask questions about Maestro features, commands, best practices, and troubleshooting. \" +\n                    \"Returns relevant documentation content and examples.\",\n                inputSchema = Tool.Input(\n                    properties = buildJsonObject {\n                        putJsonObject(\"question\") {\n                            put(\"type\", \"string\")\n                            put(\"description\", \"The question to ask about Maestro documentation\")\n                        }\n                    },\n                    required = listOf(\"question\")\n                )\n            )\n        ) { request ->\n            try {\n                val question = request.arguments[\"question\"]?.jsonPrimitive?.content\n                if (question.isNullOrBlank()) {\n                    return@RegisteredTool CallToolResult(\n                        content = listOf(TextContent(\"question parameter is required\")),\n                        isError = true\n                    )\n                }\n                \n                val apiKey = ApiKey.getToken()\n                if (apiKey.isNullOrBlank()) {\n                    return@RegisteredTool CallToolResult(\n                        content = listOf(TextContent(\"MAESTRO_CLOUD_API_KEY environment variable is required\")),\n                        isError = true\n                    )\n                }\n                \n                val client = HttpClient.build(\n                    name = \"QueryDocsTool\",\n                    readTimeout = 2.minutes\n                )\n                \n                // Create JSON request body\n                val requestBody = buildJsonObject {\n                    put(\"question\", question)\n                }.toString()\n                \n                // Make POST request to query docs endpoint\n                val httpRequest = Request.Builder()\n                    .url(\"https://api.copilot.mobile.dev/v2/bot/query-docs\")\n                    .header(\"Authorization\", \"Bearer $apiKey\")\n                    .header(\"Content-Type\", \"application/json\")\n                    .post(requestBody.toRequestBody(\"application/json\".toMediaType()))\n                    .build()\n                \n                val response = client.newCall(httpRequest).execute()\n                \n                response.use {\n                    if (!response.isSuccessful) {\n                        val errorMessage = response.body?.string().takeIf { it?.isNotEmpty() == true } ?: \"Unknown error\"\n                        return@RegisteredTool CallToolResult(\n                            content = listOf(TextContent(\"Failed to query docs (${response.code}): $errorMessage\")),\n                            isError = true\n                        )\n                    }\n                    \n                    val responseBody = response.body?.string() ?: \"\"\n                    \n                    try {\n                        val jsonResponse = Json.parseToJsonElement(responseBody).jsonObject\n                        val answer = jsonResponse[\"answer\"]?.jsonPrimitive?.content ?: responseBody\n                        CallToolResult(content = listOf(TextContent(answer)))\n                    } catch (e: Exception) {\n                        // If JSON parsing fails, return the raw response\n                        CallToolResult(content = listOf(TextContent(responseBody)))\n                    }\n                }\n            } catch (e: Exception) {\n                CallToolResult(\n                    content = listOf(TextContent(\"Failed to query docs: ${e.message}\")),\n                    isError = true\n                )\n            }\n        }\n    }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/mcp/tools/RunFlowFilesTool.kt",
    "content": "package maestro.cli.mcp.tools\n\nimport io.modelcontextprotocol.kotlin.sdk.*\nimport io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool\nimport kotlinx.serialization.json.*\nimport maestro.cli.session.MaestroSessionManager\nimport maestro.orchestra.Orchestra\nimport maestro.orchestra.yaml.YamlCommandReader\nimport maestro.orchestra.util.Env.withEnv\nimport maestro.orchestra.util.Env.withInjectedShellEnvVars\nimport maestro.orchestra.util.Env.withDefaultEnvVars\nimport kotlinx.coroutines.runBlocking\nimport java.io.File\nimport java.nio.file.Paths\nimport maestro.cli.util.WorkingDirectory\n\nobject RunFlowFilesTool {\n    fun create(sessionManager: MaestroSessionManager): RegisteredTool {\n        return RegisteredTool(\n            Tool(\n                name = \"run_flow_files\",\n                description = \"Run one or more full Maestro test files. If no device is running, you'll need to start a device first. If the command fails using a relative path, try using an absolute path.\",\n                inputSchema = Tool.Input(\n                    properties = buildJsonObject {\n                        putJsonObject(\"device_id\") {\n                            put(\"type\", \"string\")\n                            put(\"description\", \"The ID of the device to run the flows on\")\n                        }\n                        putJsonObject(\"flow_files\") {\n                            put(\"type\", \"string\")\n                            put(\"description\", \"Comma-separated file paths to YAML flow files to execute (e.g., 'flow1.yaml,flow2.yaml')\")\n                        }\n                        putJsonObject(\"env\") {\n                            put(\"type\", \"object\")\n                            put(\"description\", \"Optional environment variables to inject into the flows (e.g., {\\\"APP_ID\\\": \\\"com.example.app\\\", \\\"LANGUAGE\\\": \\\"tr\\\", \\\"COUNTRY\\\": \\\"TR\\\"})\")\n                            putJsonObject(\"additionalProperties\") {\n                                put(\"type\", \"string\")\n                            }\n                        }\n                    },\n                    required = listOf(\"device_id\", \"flow_files\")\n                )\n            )\n        ) { request ->\n            try {\n                val deviceId = request.arguments[\"device_id\"]?.jsonPrimitive?.content\n                val flowFilesString = request.arguments[\"flow_files\"]?.jsonPrimitive?.content\n                val envParam = request.arguments[\"env\"]?.jsonObject\n                \n                if (deviceId == null || flowFilesString == null) {\n                    return@RegisteredTool CallToolResult(\n                        content = listOf(TextContent(\"Both device_id and flow_files are required\")),\n                        isError = true\n                    )\n                }\n                \n                val flowFiles = flowFilesString.split(\",\").map { it.trim() }\n                \n                if (flowFiles.isEmpty()) {\n                    return@RegisteredTool CallToolResult(\n                        content = listOf(TextContent(\"At least one flow file must be provided\")),\n                        isError = true\n                    )\n                }\n                \n                // Parse environment variables from JSON object\n                val env = envParam?.mapValues { it.value.jsonPrimitive.content } ?: emptyMap()\n                \n                // Resolve all flow files to File objects once\n                val resolvedFiles = flowFiles.map { WorkingDirectory.resolve(it) }\n                // Validate all files exist before executing\n                val missingFiles = resolvedFiles.filter { !it.exists() }\n                if (missingFiles.isNotEmpty()) {\n                    return@RegisteredTool CallToolResult(\n                        content = listOf(TextContent(\"Files not found: ${missingFiles.joinToString(\", \") { it.absolutePath }}\")),\n                        isError = true\n                    )\n                }\n                \n                val result = sessionManager.newSession(\n                    host = null,\n                    port = null,\n                    driverHostPort = null,\n                    deviceId = deviceId,\n                    platform = null\n                ) { session ->\n                    val orchestra = Orchestra(session.maestro)\n                    val results = mutableListOf<Map<String, Any>>()\n                    var totalCommands = 0\n                    \n                    for (fileObj in resolvedFiles) {\n                        try {\n                            val commands = YamlCommandReader.readCommands(fileObj.toPath())\n                            val finalEnv = env\n                                .withInjectedShellEnvVars()\n                                .withDefaultEnvVars(fileObj, deviceId)\n                            val commandsWithEnv = commands.withEnv(finalEnv)\n                            \n                            runBlocking {\n                                orchestra.runFlow(commandsWithEnv)\n                            }\n                            results.add(mapOf(\n                                \"file\" to fileObj.absolutePath,\n                                \"success\" to true,\n                                \"commands_executed\" to commands.size,\n                                \"message\" to \"Flow executed successfully\"\n                            ))\n                            totalCommands += commands.size\n                        } catch (e: Exception) {\n                            results.add(mapOf(\n                                \"file\" to fileObj.absolutePath,\n                                \"success\" to false,\n                                \"error\" to (e.message ?: \"Unknown error\"),\n                                \"message\" to \"Flow execution failed\"\n                            ))\n                        }\n                    }\n                    \n                    val finalEnv = env\n                        .withInjectedShellEnvVars()\n                        .withDefaultEnvVars(deviceId = deviceId)\n                    \n                    buildJsonObject {\n                        put(\"success\", results.all { (it[\"success\"] as Boolean) })\n                        put(\"device_id\", deviceId)\n                        put(\"total_files\", flowFiles.size)\n                        put(\"total_commands_executed\", totalCommands)\n                        putJsonArray(\"results\") {\n                            results.forEach { result ->\n                                addJsonObject {\n                                    result.forEach { (key, value) ->\n                                        when (value) {\n                                            is String -> put(key, value)\n                                            is Boolean -> put(key, value)\n                                            is Int -> put(key, value)\n                                            else -> put(key, value.toString())\n                                        }\n                                    }\n                                }\n                            }\n                        }\n                        if (finalEnv.isNotEmpty()) {\n                            putJsonObject(\"env_vars\") {\n                                finalEnv.forEach { (key, value) ->\n                                    put(key, value)\n                                }\n                            }\n                        }\n                        put(\"message\", if (results.all { (it[\"success\"] as Boolean) }) \n                            \"All flows executed successfully\" \n                        else \n                            \"Some flows failed to execute\")\n                    }.toString()\n                }\n                \n                // Check if any flows failed and return isError accordingly\n                val anyFlowsFailed = result.contains(\"\\\"success\\\":false\")                \n                CallToolResult(\n                    content = listOf(TextContent(result)),\n                    isError = anyFlowsFailed\n                )\n            } catch (e: Exception) {\n                CallToolResult(\n                    content = listOf(TextContent(\"Failed to run flow files: ${e.message}\")),\n                    isError = true\n                )\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/mcp/tools/RunFlowTool.kt",
    "content": "package maestro.cli.mcp.tools\n\nimport io.modelcontextprotocol.kotlin.sdk.*\nimport io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool\nimport kotlinx.serialization.json.*\nimport maestro.cli.session.MaestroSessionManager\nimport maestro.orchestra.Orchestra\nimport maestro.orchestra.yaml.YamlCommandReader\nimport maestro.orchestra.util.Env.withEnv\nimport maestro.orchestra.util.Env.withInjectedShellEnvVars\nimport maestro.orchestra.util.Env.withDefaultEnvVars\nimport kotlinx.coroutines.runBlocking\nimport java.io.File\nimport java.nio.file.Files\n\nobject RunFlowTool {\n    fun create(sessionManager: MaestroSessionManager): RegisteredTool {\n        return RegisteredTool(\n            Tool(\n                name = \"run_flow\",\n                description = \"\"\"\n                    Use this when interacting with a device and running adhoc commands, preferably one at a time.\n\n                    Whenever you're exploring an app, testing out commands or debugging, prefer using this tool over creating temp files and using run_flow_files.\n\n                    Run a set of Maestro commands (one or more). This can be a full maestro script (including headers), a set of commands (one per line) or simply a single command (eg '- tapOn: 123').\n\n                    If this fails due to no device running, please ask the user to start a device!\n\n                    If you don't have an up-to-date view hierarchy or screenshot on which to execute the commands, please call inspect_view_hierarchy first, instead of blindly guessing.\n\n                    *** You don't need to call check_syntax before executing this, as syntax will be checked as part of the execution flow. ***\n\n                    Use the `inspect_view_hierarchy` tool to retrieve the current view hierarchy and use it to execute commands on the device.\n                    Use the `cheat_sheet` tool to retrieve a summary of Maestro's flow syntax before using any of the other tools.\n\n                    Examples of valid inputs:\n                    ```\n                    - tapOn: 123\n                    ```\n\n                    ```\n                    appId: any\n                    ---\n                    - tapOn: 123\n                    ```\n\n                    ```\n                    appId: any\n                    # other headers here\n                    ---\n                    - tapOn: 456\n                    - scroll\n                    # other commands here\n                    ```\n                \"\"\".trimIndent(),\n                inputSchema = Tool.Input(\n                    properties = buildJsonObject {\n                        putJsonObject(\"device_id\") {\n                            put(\"type\", \"string\")\n                            put(\"description\", \"The ID of the device to run the flow on\")\n                        }\n                        putJsonObject(\"flow_yaml\") {\n                            put(\"type\", \"string\")\n                            put(\"description\", \"YAML-formatted Maestro flow content to execute\")\n                        }\n                        putJsonObject(\"env\") {\n                            put(\"type\", \"object\")\n                            put(\"description\", \"Optional environment variables to inject into the flow (e.g., {\\\"APP_ID\\\": \\\"com.example.app\\\", \\\"LANGUAGE\\\": \\\"en\\\"})\")\n                            putJsonObject(\"additionalProperties\") {\n                                put(\"type\", \"string\")\n                            }\n                        }\n                    },\n                    required = listOf(\"device_id\", \"flow_yaml\")\n                )\n            )\n        ) { request ->\n            try {\n                val deviceId = request.arguments[\"device_id\"]?.jsonPrimitive?.content\n                val flowYaml = request.arguments[\"flow_yaml\"]?.jsonPrimitive?.content\n                val envParam = request.arguments[\"env\"]?.jsonObject\n                \n                if (deviceId == null || flowYaml == null) {\n                    return@RegisteredTool CallToolResult(\n                        content = listOf(TextContent(\"Both device_id and flow_yaml are required\")),\n                        isError = true\n                    )\n                }\n                \n                // Parse environment variables from JSON object\n                val env = envParam?.mapValues { it.value.jsonPrimitive.content } ?: emptyMap()\n                \n                val result = sessionManager.newSession(\n                    host = null,\n                    port = null,\n                    driverHostPort = null,\n                    deviceId = deviceId,\n                    platform = null\n                ) { session ->\n                    // Create a temporary file with the YAML content\n                    val tempFile = Files.createTempFile(\"maestro-flow\", \".yaml\").toFile()\n                    try {\n                        tempFile.writeText(flowYaml)\n                        \n                        // Parse and execute the flow with environment variables\n                        val commands = YamlCommandReader.readCommands(tempFile.toPath())\n                        val finalEnv = env\n                            .withInjectedShellEnvVars()\n                            .withDefaultEnvVars(tempFile, deviceId)\n                        val commandsWithEnv = commands.withEnv(finalEnv)\n                        \n                        val orchestra = Orchestra(session.maestro)\n                        \n                        runBlocking {\n                            orchestra.runFlow(commandsWithEnv)\n                        }\n                        \n                        buildJsonObject {\n                            put(\"success\", true)\n                            put(\"device_id\", deviceId)\n                            put(\"commands_executed\", commands.size)\n                            put(\"message\", \"Flow executed successfully\")\n                            if (finalEnv.isNotEmpty()) {\n                                putJsonObject(\"env_vars\") {\n                                    finalEnv.forEach { (key, value) ->\n                                        put(key, value)\n                                    }\n                                }\n                            }\n                        }.toString()\n                    } finally {\n                        // Clean up the temporary file\n                        tempFile.delete()\n                    }\n                }\n                \n                CallToolResult(content = listOf(TextContent(result)))\n            } catch (e: Exception) {\n                CallToolResult(\n                    content = listOf(TextContent(\"Failed to run flow: ${e.message}\")),\n                    isError = true\n                )\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/mcp/tools/StartDeviceTool.kt",
    "content": "package maestro.cli.mcp.tools\n\nimport io.modelcontextprotocol.kotlin.sdk.*\nimport io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool\nimport kotlinx.serialization.json.*\nimport maestro.device.DeviceService\nimport maestro.device.Platform\n\nobject StartDeviceTool {\n    fun create(): RegisteredTool {\n        return RegisteredTool(\n            Tool(\n                name = \"start_device\",\n                description = \"Start a device (simulator/emulator) and return its device ID. \" +\n                    \"You must provide either a device_id (from list_devices) or a platform (ios or android). \" +\n                    \"If device_id is provided, starts that device. If platform is provided, starts any available device for that platform. \" +\n                    \"If neither is provided, defaults to platform = ios.\",\n                inputSchema = Tool.Input(\n                    properties = buildJsonObject {\n                        putJsonObject(\"device_id\") {\n                            put(\"type\", \"string\")\n                            put(\"description\", \"ID of the device to start (from list_devices). Optional.\")\n                        }\n                        putJsonObject(\"platform\") {\n                            put(\"type\", \"string\") \n                            put(\"description\", \"Platform to start (ios or android). Optional. Default: ios.\")\n                        }\n                    },\n                    required = emptyList()\n                )\n            )\n        ) { request ->\n            try {\n                val deviceId = request.arguments[\"device_id\"]?.jsonPrimitive?.content\n                val platformStr = request.arguments[\"platform\"]?.jsonPrimitive?.content ?: \"ios\"\n                \n                // Get all connected and available devices\n                val availableDevices = DeviceService.listAvailableForLaunchDevices(includeWeb = true)\n                val connectedDevices = DeviceService.listConnectedDevices()\n\n                // Helper to build result\n                fun buildResult(device: maestro.device.Device.Connected, alreadyRunning: Boolean): String {\n                    return buildJsonObject {\n                        put(\"device_id\", device.instanceId)\n                        put(\"name\", device.description)\n                        put(\"platform\", device.platform.name.lowercase())\n                        put(\"type\", device.deviceType.name.lowercase())\n                        put(\"already_running\", alreadyRunning)\n                    }.toString()\n                }\n\n                if (deviceId != null) {\n                    // Check for a connected device with this instanceId\n                    val connected = connectedDevices.find { it.instanceId == deviceId }\n                    if (connected != null) {\n                        return@RegisteredTool CallToolResult(content = listOf(TextContent(buildResult(connected, true))))\n                    }\n                    // Check for an available device with this modelId\n                    val available = availableDevices.find { it.modelId == deviceId }\n                    if (available != null) {\n                        val connectedDevice = DeviceService.startDevice(\n                            device = available,\n                            driverHostPort = null\n                        )\n                        return@RegisteredTool CallToolResult(content = listOf(TextContent(buildResult(connectedDevice, false))))\n                    }\n                    return@RegisteredTool CallToolResult(\n                        content = listOf(TextContent(\"No device found with device_id: $deviceId\")),\n                        isError = true\n                    )\n                }\n\n                // No device_id provided: use platform\n                val platform = Platform.fromString(platformStr)\n                // Check for a connected device matching the platform\n                val connected = connectedDevices.find { it.platform == platform }\n                if (connected != null) {\n                    return@RegisteredTool CallToolResult(content = listOf(TextContent(buildResult(connected, true))))\n                }\n                // Check for an available device matching the platform\n                val available = availableDevices.find { it.platform == platform }\n                if (available != null) {\n                    val connectedDevice = DeviceService.startDevice(\n                        device = available,\n                        driverHostPort = null\n                    )\n                    return@RegisteredTool CallToolResult(content = listOf(TextContent(buildResult(connectedDevice, false))))\n                }\n                return@RegisteredTool CallToolResult(\n                    content = listOf(TextContent(\"No available or connected device found for platform: $platformStr\")),\n                    isError = true\n                )\n            } catch (e: Exception) {\n                CallToolResult(\n                    content = listOf(TextContent(\"Failed to start device: ${e.message}\")),\n                    isError = true\n                )\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/mcp/tools/StopAppTool.kt",
    "content": "package maestro.cli.mcp.tools\n\nimport io.modelcontextprotocol.kotlin.sdk.*\nimport io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool\nimport kotlinx.serialization.json.*\nimport maestro.cli.session.MaestroSessionManager\nimport maestro.orchestra.StopAppCommand\nimport maestro.orchestra.Orchestra\nimport maestro.orchestra.MaestroCommand\nimport kotlinx.coroutines.runBlocking\n\nobject StopAppTool {\n    fun create(sessionManager: MaestroSessionManager): RegisteredTool {\n        return RegisteredTool(\n            Tool(\n                name = \"stop_app\",\n                description = \"Stop an application on the connected device\",\n                inputSchema = Tool.Input(\n                    properties = buildJsonObject {\n                        putJsonObject(\"device_id\") {\n                            put(\"type\", \"string\")\n                            put(\"description\", \"The ID of the device to stop the app on\")\n                        }\n                        putJsonObject(\"appId\") {\n                            put(\"type\", \"string\")\n                            put(\"description\", \"Bundle ID or app ID to stop\")\n                        }\n                    },\n                    required = listOf(\"device_id\", \"appId\")\n                )\n            )\n        ) { request ->\n            try {\n                val deviceId = request.arguments[\"device_id\"]?.jsonPrimitive?.content\n                val appId = request.arguments[\"appId\"]?.jsonPrimitive?.content\n                \n                if (deviceId == null || appId == null) {\n                    return@RegisteredTool CallToolResult(\n                        content = listOf(TextContent(\"Both device_id and appId are required\")),\n                        isError = true\n                    )\n                }\n                \n                val result = sessionManager.newSession(\n                    host = null,\n                    port = null,\n                    driverHostPort = null,\n                    deviceId = deviceId,\n                    platform = null\n                ) { session ->\n                    val command = StopAppCommand(\n                        appId = appId,\n                        label = null,\n                        optional = false\n                    )\n                    \n                    val orchestra = Orchestra(session.maestro)\n                    runBlocking {\n                        orchestra.runFlow(listOf(MaestroCommand(command = command)))\n                    }\n                    \n                    buildJsonObject {\n                        put(\"success\", true)\n                        put(\"device_id\", deviceId)\n                        put(\"app_id\", appId)\n                        put(\"message\", \"App stopped successfully\")\n                    }.toString()\n                }\n                \n                CallToolResult(content = listOf(TextContent(result)))\n            } catch (e: Exception) {\n                CallToolResult(\n                    content = listOf(TextContent(\"Failed to stop app: ${e.message}\")),\n                    isError = true\n                )\n            }\n        }\n    }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/mcp/tools/TakeScreenshotTool.kt",
    "content": "package maestro.cli.mcp.tools\n\nimport io.modelcontextprotocol.kotlin.sdk.*\nimport io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool\nimport kotlinx.serialization.json.*\nimport maestro.cli.session.MaestroSessionManager\nimport okio.Buffer\nimport java.util.Base64\nimport java.io.ByteArrayInputStream\nimport java.io.ByteArrayOutputStream\nimport javax.imageio.ImageIO\n\nobject TakeScreenshotTool {\n    fun create(sessionManager: MaestroSessionManager): RegisteredTool {\n        return RegisteredTool(\n            Tool(\n                name = \"take_screenshot\",\n                description = \"Take a screenshot of the current device screen\",\n                inputSchema = Tool.Input(\n                    properties = buildJsonObject {\n                        putJsonObject(\"device_id\") {\n                            put(\"type\", \"string\")\n                            put(\"description\", \"The ID of the device to take a screenshot from\")\n                        }\n                    },\n                    required = listOf(\"device_id\")\n                )\n            )\n        ) { request ->\n            try {\n                val deviceId = request.arguments[\"device_id\"]?.jsonPrimitive?.content\n                \n                if (deviceId == null) {\n                    return@RegisteredTool CallToolResult(\n                        content = listOf(TextContent(\"device_id is required\")),\n                        isError = true\n                    )\n                }\n                \n                val result = sessionManager.newSession(\n                    host = null,\n                    port = null,\n                    driverHostPort = null,\n                    deviceId = deviceId,\n                    platform = null\n                ) { session ->\n                    val buffer = Buffer()\n                    session.maestro.takeScreenshot(buffer, true)\n                    val pngBytes = buffer.readByteArray()\n                    \n                    // Convert PNG to JPEG\n                    val pngImage = ImageIO.read(ByteArrayInputStream(pngBytes))\n                    val jpegOutput = ByteArrayOutputStream()\n                    ImageIO.write(pngImage, \"JPEG\", jpegOutput)\n                    val jpegBytes = jpegOutput.toByteArray()\n                    \n                    val base64 = Base64.getEncoder().encodeToString(jpegBytes)\n                    base64\n                }\n                \n                val imageContent = ImageContent(\n                    data = result,\n                    mimeType = \"image/jpeg\"\n                )\n                \n                CallToolResult(content = listOf(imageContent))\n            } catch (e: Exception) {\n                CallToolResult(\n                    content = listOf(TextContent(\"Failed to take screenshot: ${e.message}\")),\n                    isError = true\n                )\n            }\n        }\n    }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/mcp/tools/TapOnTool.kt",
    "content": "package maestro.cli.mcp.tools\n\nimport io.modelcontextprotocol.kotlin.sdk.*\nimport io.modelcontextprotocol.kotlin.sdk.server.RegisteredTool\nimport kotlinx.serialization.json.*\nimport maestro.cli.session.MaestroSessionManager\nimport maestro.orchestra.ElementSelector\nimport maestro.orchestra.TapOnElementCommand\nimport maestro.orchestra.Orchestra\nimport maestro.orchestra.MaestroCommand\nimport kotlinx.coroutines.runBlocking\n\nobject TapOnTool {\n    fun create(sessionManager: MaestroSessionManager): RegisteredTool {\n        return RegisteredTool(\n            Tool(\n                name = \"tap_on\",\n                description = \"Tap on a UI element by selector or description\",\n                inputSchema = Tool.Input(\n                    properties = buildJsonObject {\n                        putJsonObject(\"device_id\") {\n                            put(\"type\", \"string\")\n                            put(\"description\", \"The ID of the device to tap on\")\n                        }\n                        putJsonObject(\"text\") {\n                            put(\"type\", \"string\")\n                            put(\"description\", \"Text content to match (from 'text' field in inspect_ui output)\")\n                        }\n                        putJsonObject(\"id\") {\n                            put(\"type\", \"string\")\n                            put(\"description\", \"Element ID to match (from 'id' field in inspect_ui output)\")\n                        }\n                        putJsonObject(\"index\") {\n                            put(\"type\", \"integer\")\n                            put(\"description\", \"0-based index if multiple elements match the same criteria\")\n                        }\n                        putJsonObject(\"use_fuzzy_matching\") {\n                            put(\"type\", \"boolean\")\n                            put(\"description\", \"Whether to use fuzzy/partial text matching (true, default) or exact regex matching (false)\")\n                        }\n                        putJsonObject(\"enabled\") {\n                            put(\"type\", \"boolean\")\n                            put(\"description\", \"If true, only match enabled elements. If false, only match disabled elements. Omit this field to match regardless of enabled state.\")\n                        }\n                        putJsonObject(\"checked\") {\n                            put(\"type\", \"boolean\")\n                            put(\"description\", \"If true, only match checked elements. If false, only match unchecked elements. Omit this field to match regardless of checked state.\")\n                        }\n                        putJsonObject(\"focused\") {\n                            put(\"type\", \"boolean\")\n                            put(\"description\", \"If true, only match focused elements. If false, only match unfocused elements. Omit this field to match regardless of focus state.\")\n                        }\n                        putJsonObject(\"selected\") {\n                            put(\"type\", \"boolean\")\n                            put(\"description\", \"If true, only match selected elements. If false, only match unselected elements. Omit this field to match regardless of selection state.\")\n                        }\n                    },\n                    required = listOf(\"device_id\")\n                )\n            )\n        ) { request ->\n            try {\n                val deviceId = request.arguments[\"device_id\"]?.jsonPrimitive?.content\n                val text = request.arguments[\"text\"]?.jsonPrimitive?.content\n                val id = request.arguments[\"id\"]?.jsonPrimitive?.content\n                val index = request.arguments[\"index\"]?.jsonPrimitive?.intOrNull\n                val useFuzzyMatching = request.arguments[\"use_fuzzy_matching\"]?.jsonPrimitive?.booleanOrNull ?: true\n                val enabled = request.arguments[\"enabled\"]?.jsonPrimitive?.booleanOrNull\n                val checked = request.arguments[\"checked\"]?.jsonPrimitive?.booleanOrNull\n                val focused = request.arguments[\"focused\"]?.jsonPrimitive?.booleanOrNull\n                val selected = request.arguments[\"selected\"]?.jsonPrimitive?.booleanOrNull\n                \n                if (deviceId == null) {\n                    return@RegisteredTool CallToolResult(\n                        content = listOf(TextContent(\"device_id is required\")),\n                        isError = true\n                    )\n                }\n                \n                // Validate that at least one selector is provided\n                if (text == null && id == null) {\n                    return@RegisteredTool CallToolResult(\n                        content = listOf(TextContent(\"Either 'text' or 'id' parameter must be provided\")),\n                        isError = true\n                    )\n                }\n                \n                val result = sessionManager.newSession(\n                    host = null,\n                    port = null,\n                    driverHostPort = null,\n                    deviceId = deviceId,\n                    platform = null\n                ) { session ->\n                    // Escape special regex characters to prevent regex injection issues\n                    fun escapeRegex(input: String): String {\n                        return input.replace(Regex(\"[()\\\\[\\\\]{}+*?^$|.\\\\\\\\]\")) { \"\\\\${it.value}\" }\n                    }\n                    \n                    val elementSelector = ElementSelector(\n                        textRegex = if (useFuzzyMatching && text != null) \".*${escapeRegex(text)}.*\" else text,\n                        idRegex = if (useFuzzyMatching && id != null) \".*${escapeRegex(id)}.*\" else id,\n                        index = index?.toString(),\n                        enabled = enabled,\n                        checked = checked,\n                        focused = focused,\n                        selected = selected\n                    )\n                    \n                    val command = TapOnElementCommand(\n                        selector = elementSelector,\n                        retryIfNoChange = true,\n                        waitUntilVisible = true\n                    )\n                    \n                    val orchestra = Orchestra(session.maestro)\n                    runBlocking {\n                        orchestra.runFlow(listOf(MaestroCommand(command = command)))\n                    }\n                    \n                    buildJsonObject {\n                        put(\"success\", true)\n                        put(\"device_id\", deviceId)\n                        put(\"message\", \"Tap executed successfully\")\n                    }.toString()\n                }\n                \n                CallToolResult(content = listOf(TextContent(result)))\n            } catch (e: Exception) {\n                CallToolResult(\n                    content = listOf(TextContent(\"Failed to tap element: ${e.message}\")),\n                    isError = true\n                )\n            }\n        }\n    }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/mcp/tools/ViewHierarchyFormatters.kt",
    "content": "package maestro.cli.mcp.tools\n\nimport com.fasterxml.jackson.annotation.JsonInclude\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport com.fasterxml.jackson.dataformat.yaml.YAMLMapper\nimport maestro.TreeNode\n\n/**\n * Various formatters for view hierarchy output.\n * Preserves different approaches for potential future use.\n */\nobject ViewHierarchyFormatters {\n    \n    /**\n     * Original CSV format - matches PrintHierarchyCommand.kt exactly\n     */\n    fun extractCsvOutput(node: TreeNode?): String {\n        if (node == null) return \"element_num,depth,bounds,attributes,parent_num\\n\"\n        \n        val csv = StringBuilder()\n        csv.appendLine(\"element_num,depth,bounds,attributes,parent_num\")\n        \n        val nodeToId = mutableMapOf<TreeNode, Int>()\n        \n        // Assign IDs to each node\n        var counter = 0\n        node.aggregate().forEach { treeNode ->\n            nodeToId[treeNode] = counter++\n        }\n        \n        // Process tree recursively to generate CSV\n        processTreeToCSV(node, 0, null, nodeToId, csv)\n        \n        return csv.toString()\n    }\n    \n    private fun processTreeToCSV(\n        node: TreeNode?, \n        depth: Int, \n        parentId: Int?, \n        nodeToId: Map<TreeNode, Int>,\n        csv: StringBuilder\n    ) {\n        if (node == null) return\n        \n        val nodeId = nodeToId[node] ?: return\n        \n        // Extract bounds as separate column\n        val bounds = node.attributes[\"bounds\"] ?: \"\"\n        val quotedBounds = if (bounds.isNotEmpty()) \"\\\"$bounds\\\"\" else \"\"\n        \n        // Build attributes string (exclude bounds since it's now a separate column)\n        val attributesList = mutableListOf<String>()\n        \n        // Add normal attributes (skip boolean properties and bounds that are handled separately)\n        val excludedProperties = setOf(\"clickable\", \"enabled\", \"focused\", \"checked\", \"selected\", \"bounds\")\n        node.attributes.forEach { (key, value) ->\n            if (value.isNotEmpty() && value != \"false\" && !excludedProperties.contains(key)) {\n                attributesList.add(\"$key=$value\")\n            }\n        }\n        \n        // Add boolean properties if true\n        if (node.clickable == true) attributesList.add(\"clickable=true\")\n        if (node.enabled == true) attributesList.add(\"enabled=true\")\n        if (node.focused == true) attributesList.add(\"focused=true\")\n        if (node.checked == true) attributesList.add(\"checked=true\")\n        if (node.selected == true) attributesList.add(\"selected=true\")\n        \n        // Join all attributes with \"; \"\n        val attributesString = attributesList.joinToString(\"; \")\n        \n        // Escape quotes in the attributes string if needed\n        val escapedAttributes = attributesString.replace(\"\\\"\", \"\\\"\\\"\")\n        \n        // Add this node to CSV\n        csv.append(\"$nodeId,$depth,$quotedBounds,\\\"$escapedAttributes\\\",${parentId ?: \"\"}\\n\")\n        \n        // Process children\n        node.children.forEach { child ->\n            processTreeToCSV(child, depth + 1, nodeId, nodeToId, csv)\n        }\n    }\n    \n    /**\n     * Compact CSV format with filtering and abbreviated columns\n     * \n     * Example output:\n     * id,depth,bounds,text,resource_id,accessibility,hint,class,value,scrollable,clickable,enabled,focused,selected,checked,parent_id\n     * 0,0,\"[9,22][402,874]\",,,\"Demo App\",,,,,1,,,,,\n     * 1,1,\"[63,93][347,128]\",,,\"Flutter Demo Home Page\",,,,,1,,,,,0\n     * 2,1,\"[131,139][279,187]\",,,\"Defects Test\",,,,,1,,,,,0\n     * 3,1,\"[330,768][386,824]\",,\"fabAddIcon\",\"Increment\",,,,,1,,,,,0\n     */\n    fun extractCompactCsvOutput(node: TreeNode, platform: String): String {\n        val csv = StringBuilder()\n        csv.appendLine(\"id,depth,bounds,text,resource_id,accessibility,hint,class,value,scrollable,clickable,enabled,focused,selected,checked,parent_id\")\n        \n        val compactElements = compactTreeData(node, platform)\n        val flatElements = mutableListOf<Pair<Map<String, Any?>, Int>>() // element, depth\n        \n        // Flatten the nested structure while preserving depth\n        fun flattenElements(elements: List<Map<String, Any?>>, depth: Int) {\n            elements.forEach { element ->\n                flatElements.add(element to depth)\n                @Suppress(\"UNCHECKED_CAST\")\n                val children = element[\"c\"] as? List<Map<String, Any?>>\n                if (children != null) {\n                    flattenElements(children, depth + 1)\n                }\n            }\n        }\n        \n        flattenElements(compactElements, 0)\n        \n        // Build CSV rows\n        flatElements.forEachIndexed { index, (element, depth) ->\n            val bounds = element[\"b\"] as? String ?: \"\"\n            val text = element[\"txt\"] as? String ?: \"\"\n            val resourceId = element[\"rid\"] as? String ?: \"\"\n            val accessibility = element[\"a11y\"] as? String ?: \"\"\n            val hint = element[\"hint\"] as? String ?: \"\"\n            val className = element[\"cls\"] as? String ?: \"\"\n            val value = element[\"val\"] as? String ?: \"\"\n            val scrollable = if (element[\"scroll\"] == true) \"1\" else \"\"\n            val clickable = if (element[\"clickable\"] == true) \"1\" else \"\"\n            val enabled = if (element[\"enabled\"] == false) \"0\" else \"1\"\n            val focused = if (element[\"focused\"] == true) \"1\" else \"\"\n            val selected = if (element[\"selected\"] == true) \"1\" else \"\"\n            val checked = if (element[\"checked\"] == true) \"1\" else \"\"\n            \n            // Find parent ID (previous element with lower depth)\n            var parentId = \"\"\n            for (i in index - 1 downTo 0) {\n                if (flatElements[i].second < depth) {\n                    parentId = i.toString()\n                    break\n                }\n            }\n            \n            // Quote strings only if they have content\n            val quotedBounds = quoteIfNotEmpty(bounds)\n            val quotedText = quoteIfNotEmpty(text)\n            val quotedA11y = quoteIfNotEmpty(accessibility)\n            val quotedHint = quoteIfNotEmpty(hint)\n            val quotedClass = quoteIfNotEmpty(className)\n            val quotedValue = quoteIfNotEmpty(value)\n            val quotedRid = quoteIfNotEmpty(resourceId)\n            \n            csv.append(\"$index,$depth,$quotedBounds,$quotedText,$quotedRid,$quotedA11y,$quotedHint,$quotedClass,$quotedValue,$scrollable,$clickable,$enabled,$focused,$selected,$checked,$parentId\\n\")\n        }\n        \n        return csv.toString()\n    }\n    \n    private fun quoteIfNotEmpty(value: String): String {\n        return if (value.isNotEmpty()) {\n            \"\\\"${value.replace(\"\\\"\", \"\\\"\\\"\")}\\\"\"\n        } else {\n            \"\"\n        }\n    }\n    \n    /**\n     * Compact JSON format with filtering and abbreviated keys\n     */\n    fun extractCompactJsonOutput(node: TreeNode, platform: String): String {\n        val compactData = createCompactWithSchema(node, platform)\n        return jacksonObjectMapper()\n            .setSerializationInclusion(JsonInclude.Include.NON_NULL)\n            .writeValueAsString(compactData)\n    }\n    \n    /**\n     * Compact YAML format with filtering and abbreviated keys\n     */\n    fun extractCompactYamlOutput(node: TreeNode, platform: String): String {\n        val yamlMapper = YAMLMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL)\n        val result = StringBuilder()\n        \n        // Get the full data structure\n        val compactData = createCompactWithSchema(node, platform)\n        \n        // Add document separator\n        result.appendLine(\"---\")\n        \n        // Serialize and add ui_schema section with comment\n        result.appendLine(\"# Schema definitions - explains abbreviations and default values used in elements\")\n        val schemaYaml = yamlMapper.writeValueAsString(mapOf(\"ui_schema\" to compactData[\"ui_schema\"]))\n            .removePrefix(\"---\\n\") // Remove extra document separator\n        result.append(schemaYaml)\n        \n        // Serialize and add elements section with comment  \n        result.appendLine(\"# UI Elements - the actual view hierarchy with abbreviated attribute names\")\n        val elementsYaml = yamlMapper.writeValueAsString(mapOf(\"elements\" to compactData[\"elements\"]))\n            .removePrefix(\"---\\n\") // Remove extra document separator\n        result.append(elementsYaml)\n        \n        return result.toString()\n    }\n    \n    private fun createCompactWithSchema(node: TreeNode, platform: String): Map<String, Any?> {\n        val result = mutableMapOf<String, Any?>()\n        \n        // Add platform-specific schema\n        result[\"ui_schema\"] = createPlatformSchema(platform)\n        \n        // Convert the tree to compact structure using existing logic\n        result[\"elements\"] = compactTreeData(node, platform)\n        \n        return result\n    }\n    \n    private fun createPlatformSchema(platform: String): Map<String, Any?> {\n        return when (platform) {\n            \"android\" -> createAndroidSchema()\n            \"ios\" -> createIOSSchema()\n            else -> createIOSSchema() // Default to iOS if unknown\n        }\n    }\n    \n    private fun createAndroidSchema(): Map<String, Any?> {\n        return mapOf(\n            \"platform\" to \"android\",\n            \"abbreviations\" to mapOf(\n                \"b\" to \"bounds\",\n                \"txt\" to \"text\",\n                \"rid\" to \"resource-id\",\n                \"a11y\" to \"content-desc\",\n                \"hint\" to \"hintText\",\n                \"cls\" to \"class\",\n                \"scroll\" to \"scrollable\",\n                \"c\" to \"children\"\n            ),\n            \"defaults\" to mapOf(\n                \"enabled\" to true,\n                \"clickable\" to false,\n                \"focused\" to false,\n                \"selected\" to false,\n                \"checked\" to false,\n                \"scrollable\" to false,\n                \"txt\" to \"\",\n                \"hint\" to \"\",\n                \"rid\" to \"\",\n                \"a11y\" to \"\",\n                \"cls\" to \"\"\n            )\n        )\n    }\n    \n    private fun createIOSSchema(): Map<String, Any?> {\n        return mapOf(\n            \"platform\" to \"ios\",\n            \"abbreviations\" to mapOf(\n                \"b\" to \"bounds\",\n                \"txt\" to \"text\",\n                \"rid\" to \"resource-id\",\n                \"a11y\" to \"accessibilityText\",\n                \"hint\" to \"hintText\",\n                \"val\" to \"value\",\n                \"c\" to \"children\"\n            ),\n            \"defaults\" to mapOf(\n                \"enabled\" to true,\n                \"focused\" to false,\n                \"selected\" to false,\n                \"checked\" to false,\n                \"txt\" to \"\",\n                \"hint\" to \"\",\n                \"rid\" to \"\",\n                \"val\" to \"\",\n                \"a11y\" to \"\"\n            )\n        )\n    }\n    \n    /**\n     * Recursively processes the UI tree to create a compact representation by:\n     * 1. Filtering out meaningless nodes (zero-size, empty containers)\n     * 2. Converting remaining nodes to abbreviated attribute maps\n     * 3. Flattening the hierarchy by removing wrapper containers\n     */\n    private fun compactTreeData(node: TreeNode, platform: String): List<Map<String, Any?>> {\n        // Skip zero-size elements (invisible/collapsed elements serve no automation purpose)\n        if (hasZeroSize(node)) {\n            return node.children.flatMap { compactTreeData(it, platform) }\n        }\n        \n        // Skip nodes with no meaningful content (empty containers that only provide structure)\n        if (!hasNonDefaultValues(node, platform)) {\n            return node.children.flatMap { compactTreeData(it, platform) }\n        }\n        \n        // Process this node - convert to compact representation with abbreviated attributes\n        val element = convertToCompactNode(node).toMutableMap()\n        \n        // Recursively process children with same filtering rules\n        val children = node.children.flatMap { compactTreeData(it, platform) }\n        \n        // Add children array only if there are meaningful child elements\n        if (children.isNotEmpty()) {\n            element[\"c\"] = children\n        }\n        \n        return listOf(element)\n    }\n    \n    private fun hasZeroSize(node: TreeNode): Boolean {\n        val bounds = node.attributes[\"bounds\"] ?: return false\n        val boundsPattern = Regex(\"\\\\[(\\\\d+),(\\\\d+)\\\\]\\\\[(\\\\d+),(\\\\d+)\\\\]\")\n        val matchResult = boundsPattern.find(bounds) ?: return false\n        val (x1, y1, x2, y2) = matchResult.destructured\n        val width = x2.toInt() - x1.toInt()\n        val height = y2.toInt() - y1.toInt()\n        return width == 0 || height == 0\n    }\n    \n    private fun hasNonDefaultValues(node: TreeNode, platform: String): Boolean {\n        // Check for non-default boolean states (TreeNode properties)\n        if (node.clickable == true) return true\n        if (node.checked == true) return true\n        if (node.enabled == false) return true  // False is non-default\n        if (node.focused == true) return true\n        if (node.selected == true) return true\n        \n        // Get platform-specific defaults from schema\n        val schema = createPlatformSchema(platform)\n        @Suppress(\"UNCHECKED_CAST\")\n        val defaults = schema[\"defaults\"] as Map<String, Any?>\n        \n        // Check all attributes against their defaults\n        for ((attr, value) in node.attributes) {\n            if (value.isNullOrBlank()) continue\n            \n            val defaultValue = defaults[attr]\n            val isNonDefault = when (defaultValue) {\n                is String -> value != defaultValue\n                is Boolean -> value.toBooleanStrictOrNull() != defaultValue\n                is Int -> value.toIntOrNull() != defaultValue\n                else -> !value.isNullOrBlank() // For attributes not in defaults, non-blank means meaningful\n            }\n            if (isNonDefault) return true\n        }\n        \n        return false\n    }\n    \n    /**\n     * Converts a TreeNode to a compact map representation by:\n     * 1. Inlining attributes directly into the output (no nested \"attributes\" object)\n     * 2. Using abbreviated keys (e.g., \"b\" for \"bounds\", \"txt\" for \"text\") \n     * 3. Including only non-default values to minimize output size\n     * 4. Flattening the structure for easier LLM consumption\n     */\n    private fun convertToCompactNode(node: TreeNode): Map<String, Any?> {\n        val result = mutableMapOf<String, Any?>()\n        \n        // Inline attributes with abbreviated keys (only if non-empty)\n        val bounds = node.attributes[\"bounds\"]\n        if (!bounds.isNullOrBlank()) result[\"b\"] = bounds\n        \n        val accessibilityText = node.attributes[\"accessibilityText\"]\n        if (!accessibilityText.isNullOrBlank()) result[\"a11y\"] = accessibilityText\n        \n        val text = node.attributes[\"text\"]\n        if (!text.isNullOrBlank()) result[\"txt\"] = text\n        \n        val value = node.attributes[\"value\"]\n        if (!value.isNullOrBlank()) result[\"val\"] = value\n        \n        val resourceId = node.attributes[\"resource-id\"]\n        if (!resourceId.isNullOrBlank()) result[\"rid\"] = resourceId\n        \n        val className = node.attributes[\"class\"]\n        if (!className.isNullOrBlank()) result[\"cls\"] = className\n        \n        // For Android, also check content-desc (maps to a11y in schema)\n        val contentDesc = node.attributes[\"content-desc\"]\n        if (!contentDesc.isNullOrBlank()) result[\"a11y\"] = contentDesc\n        \n        val hintText = node.attributes[\"hintText\"]\n        if (!hintText.isNullOrBlank()) result[\"hint\"] = hintText\n        \n        val scrollable = node.attributes[\"scrollable\"]\n        if (scrollable == \"true\") result[\"scroll\"] = true\n        \n        // Inline TreeNode boolean properties (only if non-default)\n        if (node.clickable == true) result[\"clickable\"] = true\n        if (node.checked == true) result[\"checked\"] = true\n        if (node.enabled == false) result[\"enabled\"] = false  // false is non-default\n        if (node.focused == true) result[\"focused\"] = true\n        if (node.selected == true) result[\"selected\"] = true\n        \n        return result\n    }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/model/FlowStatus.kt",
    "content": "package maestro.cli.model\n\nimport maestro.cli.api.UploadStatus\n\nenum class FlowStatus {\n    PENDING,\n    PREPARING,\n    INSTALLING,\n    RUNNING,\n    SUCCESS,\n    ERROR,\n    CANCELED,\n    STOPPED,\n    WARNING;\n\n    companion object {\n\n        fun from(status: UploadStatus.Status) = when (status) {\n            UploadStatus.Status.PENDING -> PENDING\n            UploadStatus.Status.PREPARING -> PREPARING\n            UploadStatus.Status.INSTALLING -> INSTALLING\n            UploadStatus.Status.RUNNING -> RUNNING\n            UploadStatus.Status.SUCCESS -> SUCCESS\n            UploadStatus.Status.ERROR -> ERROR\n            UploadStatus.Status.CANCELED -> CANCELED\n            UploadStatus.Status.WARNING -> WARNING\n            UploadStatus.Status.STOPPED -> STOPPED\n        }\n\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/model/RunningFlow.kt",
    "content": "package maestro.cli.model\n\nimport kotlin.time.Duration\n\ndata class RunningFlows(\n    val flows: List<RunningFlow>,\n    val duration: Duration?,\n    val startTime: Long?\n)\n\ndata class RunningFlow(\n    val name: String,\n    val status: FlowStatus,\n    val duration: Duration? = null,\n    val startTime: Long? = null,\n    val reported: Boolean = false\n)\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/model/TestExecutionSummary.kt",
    "content": "package maestro.cli.model\n\nimport kotlin.time.Duration\n\n// TODO: Some properties should be implemented as getters, but it's not possible.\n//  See https://github.com/Kotlin/kotlinx.serialization/issues/805\ndata class TestExecutionSummary(\n    val passed: Boolean,\n    val suites: List<SuiteResult>,\n    val passedCount: Int? = null,\n    val totalTests: Int? = null,\n) {\n\n    data class SuiteResult(\n        val passed: Boolean,\n        val flows: List<FlowResult>,\n        val duration: Duration? = null,\n        val startTime: Long? = null,\n        val deviceName: String? = null,\n    ) {\n        fun failures(): List<FlowResult> = flows.filter { it.status == FlowStatus.ERROR }\n    }\n\n    data class FlowResult(\n        val name: String,\n        val fileName: String?,\n        val status: FlowStatus,\n        val failure: Failure? = null,\n        val duration: Duration? = null,\n        val startTime: Long? = null,\n        val properties: Map<String, String>? = null,\n        val tags: List<String>? = null,\n        val steps: List<StepResult> = emptyList(),\n    )\n\n    data class StepResult(\n        val description: String,\n        val status: String,\n        val duration: String,\n    )\n\n    data class Failure(\n        val message: String,\n    )\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/promotion/PromotionStateManager.kt",
    "content": "package maestro.cli.promotion\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties\nimport com.fasterxml.jackson.databind.SerializationFeature\nimport com.fasterxml.jackson.datatype.jsr310.JavaTimeModule\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport com.fasterxml.jackson.module.kotlin.readValue\nimport maestro.cli.util.EnvUtils\nimport java.nio.file.Path\nimport java.time.LocalDate\nimport java.time.temporal.ChronoUnit\nimport kotlin.io.path.exists\nimport kotlin.io.path.readText\nimport kotlin.io.path.writeText\n\n@JsonIgnoreProperties(ignoreUnknown = true)\ndata class PromotionState(\n    val fasterResultsLastShown: String? = null,\n    val debugLastShown: String? = null,\n    val cloudCommandLastUsed: String? = null\n)\n\n/**\n * Manages promotion message state persistence.\n * Similar to AnalyticsStateManager, stores all promotion states in a single JSON file.\n */\nclass PromotionStateManager {\n    private val promotionStatePath: Path = EnvUtils.xdgStateHome().resolve(\"promotion-state.json\")\n    \n    private val JSON = jacksonObjectMapper().apply {\n        registerModule(JavaTimeModule())\n        enable(SerializationFeature.INDENT_OUTPUT)\n    }\n\n    private var _promotionState: PromotionState? = null\n\n    private fun getState(): PromotionState {\n        if (_promotionState == null) {\n            _promotionState = loadState()\n        }\n        return _promotionState!!\n    }\n\n    fun getLastShownDate(key: String): String? {\n        return when (key) {\n            \"fasterResults\" -> getState().fasterResultsLastShown\n            \"debug\" -> getState().debugLastShown\n            else -> null\n        }\n    }\n\n    fun setLastShownDate(key: String, date: String) {\n        val currentState = getState()\n        val updatedState = when (key) {\n            \"fasterResults\" -> currentState.copy(fasterResultsLastShown = date)\n            \"debug\" -> currentState.copy(debugLastShown = date)\n            else -> currentState\n        }\n        saveState(updatedState)\n    }\n\n    fun recordCloudCommandUsage() {\n        val today = LocalDate.now().toString()\n        val currentState = getState()\n        saveState(currentState.copy(cloudCommandLastUsed = today))\n    }\n\n    fun wasCloudCommandUsedWithinDays(days: Int): Boolean {\n        val lastUsed = getState().cloudCommandLastUsed ?: return false\n        val lastUsedDate = try {\n            LocalDate.parse(lastUsed)\n        } catch (e: Exception) {\n            return false\n        }\n        val today = LocalDate.now()\n        val daysSince = ChronoUnit.DAYS.between(lastUsedDate, today)\n        return daysSince < days\n    }\n\n    private fun saveState(state: PromotionState) {\n        val stateJson = JSON.writeValueAsString(state)\n        promotionStatePath.parent.toFile().mkdirs()\n        promotionStatePath.writeText(stateJson + \"\\n\")\n        _promotionState = state\n    }\n\n    private fun loadState(): PromotionState {\n        return try {\n            if (promotionStatePath.exists()) {\n                JSON.readValue(promotionStatePath.readText())\n            } else {\n                PromotionState()\n            }\n        } catch (e: Exception) {\n            PromotionState()\n        }\n    }\n}\n\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/report/HtmlAITestSuiteReporter.kt",
    "content": "package maestro.cli.report\n\nimport kotlinx.html.a\nimport kotlinx.html.body\nimport kotlinx.html.button\nimport kotlinx.html.div\nimport kotlinx.html.h1\nimport kotlinx.html.head\nimport kotlinx.html.html\nimport kotlinx.html.img\nimport kotlinx.html.lang\nimport kotlinx.html.main\nimport kotlinx.html.meta\nimport kotlinx.html.p\nimport kotlinx.html.script\nimport kotlinx.html.span\nimport kotlinx.html.stream.appendHTML\nimport kotlinx.html.style\nimport kotlinx.html.title\nimport kotlinx.html.unsafe\nimport readResourceAsText\nimport java.io.File\n\n// TODO(bartekpacia): Ideally, AI output would be in the same HTML file as \"normal test output\". There is no inherent reason\n//  to split those 2 streams of output (\"normal\" and \"AI\") into 2 separate HTML files.\n//  See issue #1973\nclass HtmlAITestSuiteReporter {\n\n    private val FlowAIOutput.htmlReportFilename\n        get() = \"ai-report-${flowName}.html\"\n\n    private val reportCss: String\n        get() = readResourceAsText(this::class, \"/ai_report.css\")\n\n    private val reportJs: String\n        get() = readResourceAsText(this::class, \"/tailwind.config.js\")\n\n    /**\n     * Creates HTML files in [outputDestination] for each flow in [outputs].\n     */\n    fun report(outputs: List<FlowAIOutput>, outputDestination: File) {\n        if (!outputDestination.isDirectory) throw IllegalArgumentException(\"Output destination must be a directory\")\n\n        outputs.forEachIndexed { index, output ->\n            val htmlContent = buildHtmlReport(outputs, index)\n            val file = File(outputDestination, output.htmlReportFilename)\n            file.writeText(htmlContent)\n        }\n    }\n\n    /**\n     * Build HTML report for a single flow.\n     *\n     * Information about other flows is needed to generate links to them.\n     */\n    private fun buildHtmlReport(outputs: List<FlowAIOutput>, index: Int): String {\n        val summary = outputs[index]\n\n        return buildString {\n            appendLine(\"<!DOCTYPE html>\")\n            appendHTML().html {\n                lang = \"en\"\n\n                head {\n                    meta { charset = \"UTF-8\" }\n                    meta { name = \"viewport\"; content = \"width=device-width, initial-scale=1.0\" }\n                    title { +\"Maestro Test Report\" }\n                    script { src = \"https://cdn.tailwindcss.com/3.4.5\" }\n\n                    script {\n                        unsafe { +reportJs }\n                    }\n\n                    style(type = \"text/tailwindcss\") { +reportCss }\n                }\n\n                body {\n                    div(classes = \"flex min-h-screen flex-col\") {\n\n                        // Header area\n                        div(classes = \"container mx-auto py-6 space-y-2\") {\n                            h1(classes = \"text-3xl\") {\n                                +\"AI suggestions for flow \"\n                                span(classes = \"text-gray-medium\") {\n                                    +summary.flowName\n                                }\n                            }\n\n                            // File chooser for different reports\n                            div(classes = \"group relative inline-block self-start\") {\n                                button(classes = \"btn\") { +\"→ Open other report\" }\n                                div(classes = \"absolute z-10 hidden min-w-32 group-hover:block\") {\n                                    outputs.forEachIndexed { outputIndex, outputFlow ->\n                                        val selected = outputIndex == index\n\n                                        a(classes = buildString {\n                                          append(\"toggle-link\")\n\n                                            if (selected) append(\" toggle-link-selected\")\n                                        } ) {\n                                            href = \"./\" + outputs[outputIndex].htmlReportFilename\n                                            val name = outputFlow.flowFile.nameWithoutExtension\n                                            +\"(${outputIndex + 1}) $name\"\n                                        }\n                                    }\n                                }\n                            }\n\n                            // Link to the flow file\n                            // FIXME(bartekpacia): This path will be broken when moved across machines\n                            p {\n                                a(\n                                    classes = \"btn\", href = summary.flowFile.absolutePath\n                                ) {\n                                    +\"→ Open flow file ( ${summary.flowFile.name} )\"\n                                }\n                            }\n                        }\n\n                        // Container for list of screenshots\n                        main(classes = \"container mx-auto flex flex-col gap-4\") {\n                            // Overall defect count for the flow\n                            p(classes = \"text-lg\") {\n                                val word = if (summary.defectCount == 1) \"defect\" else \"defects\"\n                                +\"${summary.defectCount} possible $word found\"\n                            }\n\n                            // List of screenshots within flow with defects founds\n                            summary.screenOutputs.forEachIndexed { screenIndex, screenSummary ->\n                                div(classes = \"screen-card\") {\n                                    img(classes = \"screenshot-image\") {\n                                        alt = \"Screenshot of the defect\"\n                                        // Use relative path, so when file is moved across machines, it still works\n                                        src = screenSummary.screenshotPath.name.toString()\n                                    }\n\n                                    // defect-card-container\n                                    div(classes = \"flex flex-col gap-4 flex-grow\") {\n                                        // Defect count for the screen\n                                        p(classes = \"text-lg\") {\n                                            val word = if (screenSummary.defects.size == 1) \"defect\" else \"defects\"\n                                            +\"${screenSummary.defects.size} possible $word\"\n                                        }\n\n                                        screenSummary.defects.forEachIndexed { i, defect ->\n                                            div(classes = \"defect-card\") {\n                                                p { +defect.reasoning }\n                                                div(classes = \"badge\") { +defect.category }\n                                            }\n                                        }\n                                    }\n                                }\n\n                                if (screenIndex != summary.screenOutputs.size - 1) {\n                                    div(classes = \"divider\")\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    private val FlowAIOutput.defectCount: Int\n        get() = screenOutputs.flatMap { it.defects }.size\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/report/HtmlInsightsAnalysisReporter.kt",
    "content": "package maestro.cli.report\n\nimport java.nio.file.Files\nimport java.nio.file.Path\n\nclass HtmlInsightsAnalysisReporter {\n\n    fun report(\n        html: String,\n        outputDestination: Path\n    ): Path {\n        if (!Files.isDirectory(outputDestination)) {\n            throw IllegalArgumentException(\"Output destination must be a directory\")\n        }\n\n        val fileName = \"insights-report.html\"\n        val filePath = outputDestination.resolve(fileName)\n\n        Files.write(filePath, html.toByteArray())\n\n        return filePath\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/report/HtmlTestSuiteReporter.kt",
    "content": "package maestro.cli.report\n\nimport kotlinx.html.*\nimport kotlinx.html.stream.appendHTML\nimport maestro.cli.model.TestExecutionSummary\nimport okio.Sink\nimport okio.buffer\n\nclass HtmlTestSuiteReporter(private val detailed: Boolean = false) : TestSuiteReporter {\n\n    companion object {\n        private fun loadPrettyCss(): String {\n            return HtmlTestSuiteReporter::class.java\n                .getResourceAsStream(\"/html-detailed.css\")\n                ?.bufferedReader()\n                ?.use { it.readText() }\n                ?: \"\"\n        }\n    }\n\n    override fun report(summary: TestExecutionSummary, out: Sink) {\n        val bufferedOut = out.buffer()\n        val htmlContent = buildHtmlReport(summary)\n        bufferedOut.writeUtf8(htmlContent)\n        bufferedOut.close()\n    }\n\n    private fun buildHtmlReport(summary: TestExecutionSummary): String {\n\n        return buildString {\n            appendHTML().html {\n                head {\n                    title { +\"Maestro Test Report\" }\n                    link(\n                        rel = \"stylesheet\",\n                        href = \"https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css\"\n                    ) {}\n                }\n                body {\n                    summary.suites.forEach { suite ->\n                        val failedTests = suite.failures()\n                        div(classes = \"card mb-4\") {\n                            div(classes = \"card-body\") {\n                                h1(classes = \"mt-5 text-center\") { +\"Flow Execution Summary\" }\n                                br {}\n                                +\"Test Result: ${if (suite.passed) \"PASSED\" else \"FAILED\"}\"\n                                br {}\n                                +\"Duration: ${suite.duration}\"\n                                br {}\n                                +\"Start Time: ${suite.startTime?.let { millisToCurrentLocalDateTime(it) }}\"\n                                br {}\n                                br {}\n                                div(classes = \"card-group mb-4\") {\n                                    div(classes = \"card\") {\n                                        div(classes = \"card-body\") {\n                                            h5(classes = \"card-title text-center\") { +\"Total number of Flows\" }\n                                            h3(classes = \"card-text text-center\") { +\"${suite.flows.size}\" }\n                                        }\n                                    }\n                                    div(classes = \"card text-white bg-danger\") {\n                                        div(classes = \"card-body\") {\n                                            h5(classes = \"card-title text-center\") { +\"Failed Flows\" }\n                                            h3(classes = \"card-text text-center\") { +\"${failedTests.size}\" }\n                                        }\n                                    }\n                                    div(classes = \"card text-white bg-success\") {\n                                        div(classes = \"card-body\") {\n                                            h5(classes = \"card-title text-center\") { +\"Successful Flows\" }\n                                            h3(classes = \"card-text text-center\") { +\"${suite.flows.size - failedTests.size}\" }\n                                        }\n                                    }\n                                }\n                                if (failedTests.isNotEmpty()) {\n                                    div(classes = \"card border-danger mb-3\") {\n                                        div(classes = \"card-body text-danger\") {\n                                            b { +\"Failed Flow\" }\n                                            br {}\n                                            p(classes = \"card-text\") {\n                                                failedTests.forEach { test ->\n                                                    +test.name\n                                                    br {}\n                                                }\n                                            }\n                                        }\n                                    }\n                                }\n                                suite.flows.forEachIndexed { index, flow ->\n                                    val buttonClass =\n                                        if (flow.status.toString() == \"ERROR\") \"btn btn-danger\" else \"btn btn-success\"\n                                    // Create a valid HTML ID by sanitizing the flow name\n                                    val flowId = \"flow-$index-${flow.name.replace(Regex(\"[^a-zA-Z0-9_-]\"), \"-\")}\"\n                                    div(classes = \"card mb-4\") {\n                                        div(classes = \"card-header\") {\n                                            h5(classes = \"mb-0\") {\n                                                button(classes = buttonClass) {\n                                                    attributes[\"type\"] = \"button\"\n                                                    attributes[\"data-bs-toggle\"] = \"collapse\"\n                                                    attributes[\"data-bs-target\"] = \"#$flowId\"\n                                                    attributes[\"aria-expanded\"] = \"false\"\n                                                    attributes[\"aria-controls\"] = flowId\n                                                    +\"${flow.name} : ${flow.status}\"\n                                                }\n                                            }\n                                        }\n                                        div(classes = \"collapse\") {\n                                            id = flowId\n                                            div(classes = \"card-body\") {\n                                                p(classes = \"card-text\") {\n                                                    +\"Status: ${flow.status}\"\n                                                    br {}\n                                                    +\"Duration: ${flow.duration}\"\n                                                    br {}\n                                                    +\"Start Time: ${\n                                                        flow.startTime?.let {\n                                                            millisToCurrentLocalDateTime(\n                                                                it\n                                                            )\n                                                        }\n                                                    }\"\n                                                    br {}\n                                                    if (flow.fileName != null) {\n                                                        +\"File Name: ${flow.fileName}\"\n                                                        br {}\n                                                    }\n                                                    \n                                                    // Display tags if present\n                                                    if (!flow.tags.isNullOrEmpty()) {\n                                                        +\"Tags: \"\n                                                        flow.tags.forEach { tag ->\n                                                            span(classes = \"badge bg-primary me-1\") {\n                                                                +tag\n                                                            }\n                                                        }\n                                                        br {}\n                                                    }\n                                                }\n                                                \n                                                // Display properties if present\n                                                if (!flow.properties.isNullOrEmpty()) {\n                                                    h6(classes = \"mt-3 mb-2\") { +\"Properties\" }\n                                                    div(classes = \"table-responsive\") {\n                                                        table(classes = \"table table-sm table-bordered\") {\n                                                            thead {\n                                                                tr {\n                                                                    th { +\"Property\" }\n                                                                    th { +\"Value\" }\n                                                                }\n                                                            }\n                                                            tbody {\n                                                                flow.properties.forEach { (key, value) ->\n                                                                    tr {\n                                                                        td { +key }\n                                                                        td { +value }\n                                                                    }\n                                                                }\n                                                            }\n                                                        }\n                                                    }\n                                                }\n                                                \n                                                if (flow.failure != null) {\n                                                    p(classes = \"card-text text-danger\") {\n                                                        +flow.failure.message\n                                                    }\n                                                }\n\n                                                // Show detailed steps when detailed mode is enabled\n                                                if (detailed && flow.steps.isNotEmpty()) {\n                                                    h6(classes = \"mt-3 mb-3\") { +\"Test Steps (${flow.steps.size})\" }\n\n                                                    flow.steps.forEach { step ->\n                                                        val statusIcon = when (step.status) {\n                                                            \"COMPLETED\" -> \"✅\"\n                                                            \"WARNED\" -> \"⚠️\"\n                                                            \"FAILED\" -> \"❌\"\n                                                            \"SKIPPED\" -> \"⏭️\"\n                                                            else -> \"⚪\"\n                                                        }\n\n                                                        div(classes = \"step-item mb-2\") {\n                                                            div(classes = \"step-header d-flex justify-content-between align-items-center\") {\n                                                                span {\n                                                                    +\"$statusIcon \"\n                                                                    span(classes = \"step-name\") { +step.description }\n                                                                }\n                                                                span(classes = \"badge bg-light text-dark\") {\n                                                                    +step.duration\n                                                                }\n                                                            }\n                                                        }\n                                                    }\n                                                }\n                                            }\n                                        }\n                                    }\n                                }\n                            }\n                            // Add styling for step items when detailed mode is enabled\n                            if (detailed) {\n                                style {\n                                    unsafe {\n                                        +loadPrettyCss()\n                                    }\n                                }\n                            }\n                            script(\n                                src = \"https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js\",\n                                content = \"\"\n                            )\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/report/JUnitTestSuiteReporter.kt",
    "content": "package maestro.cli.report\n\nimport com.fasterxml.jackson.annotation.JsonInclude\nimport com.fasterxml.jackson.annotation.JsonProperty\nimport com.fasterxml.jackson.databind.ObjectMapper\nimport com.fasterxml.jackson.dataformat.xml.XmlMapper\nimport com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper\nimport com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty\nimport com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement\nimport com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlText\nimport com.fasterxml.jackson.dataformat.xml.ser.ToXmlGenerator\nimport com.fasterxml.jackson.module.kotlin.KotlinModule\nimport maestro.cli.model.FlowStatus\nimport maestro.cli.model.TestExecutionSummary\nimport okio.Sink\nimport okio.buffer\nimport kotlin.time.DurationUnit\n\nclass JUnitTestSuiteReporter(\n    private val mapper: ObjectMapper,\n    private val testSuiteName: String?\n) : TestSuiteReporter {\n\n    private fun suiteResultToTestSuite(suite: TestExecutionSummary.SuiteResult) = TestSuite(\n        name = testSuiteName ?: \"Test Suite\",\n        device = suite.deviceName,\n        failures = suite.failures().size,\n        time = suite.duration?.toDouble(DurationUnit.SECONDS)?.toString(),\n        timestamp = suite.startTime?.let { millisToCurrentLocalDateTime(it) },\n        tests = suite.flows.size,\n        testCases = suite.flows\n            .map { flow ->\n                // Combine flow properties and tags into a single properties list\n                val allProperties = mutableListOf<TestCaseProperty>()\n                \n                // Add custom properties\n                flow.properties?.forEach { (key, value) ->\n                    allProperties.add(TestCaseProperty(key, value))\n                }\n                \n                // Add tags as a comma-separated property\n                flow.tags?.takeIf { it.isNotEmpty() }?.let { tags ->\n                    allProperties.add(TestCaseProperty(\"tags\", tags.joinToString(\", \")))\n                }\n                \n                TestCase(\n                    id = flow.name,\n                    name = flow.name,\n                    classname = flow.name,\n                    failure = flow.failure?.let { failure ->\n                        Failure(\n                            message = failure.message,\n                        )\n                    },\n                    time = flow.duration?.toDouble(DurationUnit.SECONDS)?.toString(),\n                    timestamp = flow.startTime?.let { millisToCurrentLocalDateTime(it) },\n                    status = flow.status,\n                    properties = allProperties.takeIf { it.isNotEmpty() }\n                )\n            }\n    )\n\n\n    override fun report(\n        summary: TestExecutionSummary,\n        out: Sink\n    ) {\n        mapper\n            .writerWithDefaultPrettyPrinter()\n            .writeValue(\n                out.buffer().outputStream(),\n                TestSuites(\n                    suites = summary\n                        .suites\n                        .map { suiteResultToTestSuite(it) }\n                )\n            )\n    }\n\n    @JacksonXmlRootElement(localName = \"testsuites\")\n    private data class TestSuites(\n        @JacksonXmlElementWrapper(useWrapping = false)\n        @JsonProperty(\"testsuite\")\n        val suites: List<TestSuite>,\n    )\n\n    @JacksonXmlRootElement(localName = \"testsuite\")\n    private data class TestSuite(\n        @JacksonXmlProperty(isAttribute = true) val name: String,\n        @JacksonXmlProperty(isAttribute = true) val device: String?,\n        @JacksonXmlProperty(isAttribute = true) val tests: Int,\n        @JacksonXmlProperty(isAttribute = true) val failures: Int,\n        @JacksonXmlProperty(isAttribute = true) val time: String? = null,\n        @JacksonXmlProperty(isAttribute = true) val timestamp: String? = null,\n        @JacksonXmlElementWrapper(useWrapping = false)\n        @JsonProperty(\"testcase\")\n        val testCases: List<TestCase>,\n    )\n\n    private data class TestCase(\n        @JacksonXmlProperty(isAttribute = true) val id: String,\n        @JacksonXmlProperty(isAttribute = true) val name: String,\n        @JacksonXmlProperty(isAttribute = true) val classname: String,\n        @JacksonXmlProperty(isAttribute = true) val time: String? = null,\n        @JacksonXmlProperty(isAttribute = true) val timestamp: String? = null,\n        @JacksonXmlProperty(isAttribute = true) val status: FlowStatus,\n        @JacksonXmlElementWrapper(localName = \"properties\")\n        @JacksonXmlProperty(localName = \"property\")\n        val properties: List<TestCaseProperty>? = null,\n        val failure: Failure? = null,\n    )\n\n    private data class Failure(\n        @JacksonXmlText val message: String,\n    )\n\n    private data class TestCaseProperty(\n        @JacksonXmlProperty(isAttribute = true) val name: String,\n        @JacksonXmlProperty(isAttribute = true) val value: String,\n    )\n\n    companion object {\n\n        fun xml(testSuiteName: String? = null) = JUnitTestSuiteReporter(\n            mapper = XmlMapper().apply {\n                registerModule(KotlinModule.Builder().build())\n                setSerializationInclusion(JsonInclude.Include.NON_NULL)\n                configure(ToXmlGenerator.Feature.WRITE_XML_DECLARATION, true)\n            },\n            testSuiteName = testSuiteName\n        )\n\n    }\n\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/report/ReportFormat.kt",
    "content": "package maestro.cli.report\n\nimport picocli.CommandLine\n\nenum class ReportFormat(\n    val fileExtension: String?,\n    private val displayName: String? = null\n) {\n\n    JUNIT(\".xml\"),\n    HTML(\".html\"),\n    HTML_DETAILED(\".html\", \"HTML-DETAILED\"),\n    NOOP(null);\n\n    override fun toString(): String {\n        return displayName ?: name\n    }\n\n    class Converter : CommandLine.ITypeConverter<ReportFormat> {\n        override fun convert(value: String): ReportFormat {\n            // Try to match by display name first, then by enum name\n            return values().find {\n                it.toString().equals(value, ignoreCase = true) ||\n                it.name.equals(value, ignoreCase = true)\n            } ?: throw IllegalArgumentException(\"Invalid format: $value. Valid options are: ${values().joinToString()}\")\n        }\n    }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/report/ReporterFactory.kt",
    "content": "package maestro.cli.report\n\nimport maestro.cli.model.TestExecutionSummary\nimport okio.BufferedSink\n\nobject ReporterFactory {\n\n    fun buildReporter(format: ReportFormat, testSuiteName: String?): TestSuiteReporter {\n        return when (format) {\n            ReportFormat.JUNIT -> JUnitTestSuiteReporter.xml(testSuiteName)\n            ReportFormat.NOOP -> TestSuiteReporter.NOOP\n            ReportFormat.HTML -> HtmlTestSuiteReporter(detailed = false)\n            ReportFormat.HTML_DETAILED -> HtmlTestSuiteReporter(detailed = true)\n        }\n    }\n\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/report/TestDebugReporter.kt",
    "content": "package maestro.cli.report\n\nimport com.fasterxml.jackson.annotation.JsonInclude\nimport com.fasterxml.jackson.annotation.JsonProperty\nimport com.fasterxml.jackson.databind.JsonMappingException\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport maestro.MaestroException\nimport maestro.TreeNode\nimport maestro.ai.cloud.Defect\nimport maestro.cli.runner.CommandStatus\nimport maestro.cli.util.CiUtils\nimport maestro.cli.util.EnvUtils\nimport maestro.cli.util.IOSEnvUtils\nimport maestro.debuglog.DebugLogStore\nimport maestro.debuglog.LogConfig\nimport maestro.orchestra.MaestroCommand\nimport org.apache.logging.log4j.Level\nimport org.apache.logging.log4j.LogManager\nimport org.apache.logging.log4j.core.LoggerContext\nimport org.apache.logging.log4j.core.config.Configurator\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\nimport java.io.File\nimport java.nio.file.Files\nimport java.nio.file.Path\nimport java.nio.file.Paths\nimport java.nio.file.attribute.FileTime\nimport java.time.Duration\nimport java.time.Instant\nimport java.time.LocalDateTime\nimport java.time.format.DateTimeFormatter\nimport java.time.temporal.ChronoUnit\nimport java.util.*\nimport kotlin.io.path.absolutePathString\nimport kotlin.io.path.exists\nimport kotlin.math.log\n\n\n// TODO(bartekpacia): Rename to TestOutputReporter, because it's not only for \"debug\" stuff\nobject TestDebugReporter {\n\n    private val logger = LogManager.getLogger(TestDebugReporter::class.java)\n    private val mapper = jacksonObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL)\n        .setSerializationInclusion(JsonInclude.Include.NON_EMPTY).writerWithDefaultPrettyPrinter()\n\n    private var debugOutputPath: Path? = null\n    private var debugOutputPathAsString: String? = null\n    private var flattenDebugOutput: Boolean = false\n    private var testOutputDir: Path? = null\n\n    // AI outputs must be saved separately at the end of the flow.\n    fun saveSuggestions(outputs: List<FlowAIOutput>, path: Path) {\n        // This mutates the output.\n        outputs.forEach { output ->\n            // Write AI screenshots. Paths need to be changed to the final ones.\n            val updatedOutputs = output.screenOutputs.map { newOutput ->\n                val screenshotFilename = newOutput.screenshotPath.name\n                val screenshotFile = File(path.absolutePathString(), screenshotFilename)\n                newOutput.screenshotPath.copyTo(screenshotFile)\n                newOutput.copy(screenshotPath = screenshotFile)\n            }\n\n            output.screenOutputs.clear()\n            output.screenOutputs.addAll(updatedOutputs)\n\n            // Write AI JSON output\n            val jsonFilename = \"ai-(${output.flowName.replace(\"/\", \"_\")}).json\"\n            val jsonFile = File(path.absolutePathString(), jsonFilename)\n            mapper.writeValue(jsonFile, output)\n        }\n\n        HtmlAITestSuiteReporter().report(outputs, path.toFile())\n    }\n\n    /**\n     * Save debug information about a single flow, after it has finished.\n     */\n    fun saveFlow(flowName: String, debugOutput: FlowDebugOutput, path: Path, shardIndex: Int? = null) {\n        // TODO(bartekpacia): Potentially accept a single \"FlowPersistentOutput\" object\n        // TODO(bartekpacia: Build output incrementally, instead of single-shot on flow completion\n        //  Be aware that this goal somewhat conflicts with including links to other flows in the HTML report.\n\n        val shardPrefix = shardIndex?.let { \"shard-${it + 1}-\" }.orEmpty()\n        val shardLogPrefix = shardIndex?.let { \"[shard ${it + 1}] \" }.orEmpty()\n\n        // commands\n        try {\n            val commandMetadata = debugOutput.commands\n            if (commandMetadata.isNotEmpty()) {\n                val commandsFilename = \"commands-$shardPrefix(${flowName.replace(\"/\", \"_\")}).json\"\n                val file = File(path.absolutePathString(), commandsFilename)\n                commandMetadata.map {\n                    CommandDebugWrapper(it.key, it.value)\n                }.let {\n                    mapper.writeValue(file, it)\n                }\n            }\n        } catch (e: JsonMappingException) {\n            logger.error(\"${shardLogPrefix}Unable to parse commands\", e)\n        }\n\n        // screenshots\n        debugOutput.screenshots.forEach {\n            val status = when (it.status) {\n                CommandStatus.COMPLETED -> \"✅\"\n                CommandStatus.FAILED -> \"❌\"\n                CommandStatus.WARNED -> \"⚠️\"\n                else -> \"﹖\"\n            }\n            val filename = \"screenshot-$shardPrefix$status-${it.timestamp}-(${flowName}).png\"\n            val file = File(path.absolutePathString(), filename)\n\n            it.screenshot.copyTo(file)\n        }\n    }\n\n    fun deleteOldFiles(days: Long = 14) {\n        try {\n            val currentTime = Instant.now()\n            val daysLimit = currentTime.minus(Duration.of(days, ChronoUnit.DAYS))\n\n            val logParentDirectory = getDebugOutputPath().parent\n            logger.info(\"Performing purge of logs older than $days days from ${logParentDirectory.absolutePathString()}\")\n\n            Files.walk(logParentDirectory).filter {\n                val fileTime = Files.getAttribute(it, \"basic:lastModifiedTime\") as FileTime\n                val isOlderThanLimit = fileTime.toInstant().isBefore(daysLimit)\n                val shouldBeDeleted = Files.isDirectory(it) && isOlderThanLimit\n                if (shouldBeDeleted) {\n                    logger.info(\"Deleting old directory: ${it.absolutePathString()}\")\n                }\n                shouldBeDeleted\n            }.sorted(Comparator.reverseOrder()).forEach { dir ->\n                Files.walk(dir).sorted(Comparator.reverseOrder()).forEach { file -> Files.delete(file) }\n            }\n        } catch (e: Exception) {\n            logger.warn(\"Failed to delete older files\", e)\n        }\n    }\n\n    private fun logSystemInfo() {\n        logger.info(\"Debug output path: {}\", getDebugOutputPath().absolutePathString())\n\n        // Disable specific gRPC and Netty loggers\n        Configurator.setLevel(\"io.grpc.netty.NettyClientHandler\", Level.OFF)\n        Configurator.setLevel(\"io.grpc.netty\", Level.OFF)\n        Configurator.setLevel(\"io.netty\", Level.OFF)\n\n        val logger = LogManager.getLogger(\"MAESTRO\")\n        logger.info(\"---- System Info ----\")\n        logger.info(\"Maestro Version: ${EnvUtils.CLI_VERSION ?: \"Undefined\"}\")\n        logger.info(\"CI: ${CiUtils.getCiProvider() ?: \"Undefined\"}\")\n        logger.info(\"OS Name: ${EnvUtils.OS_NAME}\")\n        logger.info(\"OS Version: ${EnvUtils.OS_VERSION}\")\n        logger.info(\"Architecture: ${EnvUtils.OS_ARCH}\")\n        logger.info(\"Java Version: ${EnvUtils.getJavaVersion()}\")\n        logger.info(\"Xcode Version: ${IOSEnvUtils.xcodeVersion}\")\n        logger.info(\"Flutter Version: ${EnvUtils.getFlutterVersionAndChannel().first ?: \"Undefined\"}\")\n        logger.info(\"Flutter Channel: ${EnvUtils.getFlutterVersionAndChannel().second ?: \"Undefined\"}\")\n        logger.info(\"---------------------\")\n    }\n\n    /**\n     * Calls to this method should be done as soon as possible, to make all\n     * loggers use our custom configuration rather than the defaults.\n     */\n    fun install(debugOutputPathAsString: String? = null, flattenDebugOutput: Boolean = false, printToConsole: Boolean) {\n        this.debugOutputPathAsString = debugOutputPathAsString\n        this.flattenDebugOutput = flattenDebugOutput\n        val path = getDebugOutputPath()\n        LogConfig.configure(logFileName = path.absolutePathString() + \"/maestro.log\", printToConsole = printToConsole)\n        logSystemInfo()\n        DebugLogStore.logSystemInfo()\n    }\n\n    fun updateTestOutputDir(testOutputDir: Path?) {\n        this.testOutputDir = testOutputDir\n        // Reset debugOutputPath so getDebugOutputPath() will properly handle directory creation\n        debugOutputPath = null\n    }\n\n    fun getDebugOutputPath(): Path {\n        if (debugOutputPath != null) return debugOutputPath as Path\n\n        val debugRootPath =\n            if (debugOutputPathAsString != null) debugOutputPathAsString!! else System.getProperty(\"user.home\")\n        val debugOutput =\n            if (flattenDebugOutput) Paths.get(debugRootPath) else buildDefaultDebugOutputPath(debugRootPath)\n\n        if (!debugOutput.exists()) {\n            Files.createDirectories(debugOutput)\n        }\n        debugOutputPath = debugOutput\n        return debugOutput\n    }\n\n    private fun buildDefaultDebugOutputPath(debugRootPath: String): Path {\n        // If testOutputDir is configured, use it as the base path instead of ~/.maestro/tests\n        return if (testOutputDir != null) {\n            val foldername = DateTimeFormatter.ofPattern(\"yyyy-MM-dd_HHmmss\").format(LocalDateTime.now())\n            testOutputDir!!.resolve(foldername)\n        } else {\n            val preamble = arrayOf(\".maestro\", \"tests\")\n            val foldername = DateTimeFormatter.ofPattern(\"yyyy-MM-dd_HHmmss\").format(LocalDateTime.now())\n            Paths.get(debugRootPath, *preamble, foldername)\n        }\n    }\n}\n\nprivate data class CommandDebugWrapper(\n    val command: MaestroCommand, val metadata: CommandDebugMetadata\n)\n\ndata class CommandDebugMetadata(\n    var status: CommandStatus? = null,\n    var timestamp: Long? = null,\n    var duration: Long? = null,\n    var error: Throwable? = null,\n    var hierarchy: TreeNode? = null,\n    var sequenceNumber: Int = 0,\n    var evaluatedCommand: MaestroCommand? = null\n) {\n    fun calculateDuration() {\n        if (timestamp != null) {\n            duration = System.currentTimeMillis() - timestamp!!\n        }\n    }\n}\n\ndata class FlowDebugOutput(\n    val commands: IdentityHashMap<MaestroCommand, CommandDebugMetadata> = IdentityHashMap<MaestroCommand, CommandDebugMetadata>(),\n    val screenshots: MutableList<Screenshot> = mutableListOf(),\n    var exception: MaestroException? = null,\n) {\n    data class Screenshot(\n        val screenshot: File,\n        val timestamp: Long,\n        val status: CommandStatus,\n    )\n}\n\ndata class FlowAIOutput(\n    @JsonProperty(\"flow_name\") val flowName: String,\n    @JsonProperty(\"flow_file_path\") val flowFile: File,\n    @JsonProperty(\"outputs\") val screenOutputs: MutableList<SingleScreenFlowAIOutput> = mutableListOf(),\n)\n\ndata class SingleScreenFlowAIOutput(\n    @JsonProperty(\"screenshot_path\") val screenshotPath: File,\n    val defects: List<Defect>,\n)\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/report/TestSuiteReporter.kt",
    "content": "package maestro.cli.report\n\nimport maestro.cli.model.TestExecutionSummary\nimport okio.Sink\nimport java.time.Instant\nimport java.time.ZoneId\nimport java.time.format.DateTimeFormatter\n\ninterface TestSuiteReporter {\n\n    /**\n     * Writes the report for [summary] to [out] in the format specified by the implementation.\n     */\n    fun report(\n        summary: TestExecutionSummary,\n        out: Sink,\n    )\n\n    companion object {\n        val NOOP: TestSuiteReporter = object : TestSuiteReporter {\n            override fun report(summary: TestExecutionSummary, out: Sink) {\n                // no-op\n            }\n        }\n    }\n\n\n    /**\n     * Judging from https://github.com/testmoapp/junitxml?tab=readme-ov-file#complete-junit-xml-example,\n     * the output of timestamp needs to be an ISO 8601 local date time instead of an ISO 8601 offset date\n     * time (it would be ideal to use ISO 8601 offset date time it needs to be confirmed if it's valid)\n     *\n     * Due to having to use LocalDateTime, we need to get the offset from the client (i.e. the machine running\n     * maestro-cli) using ZoneId.systemDefault() so we can display the time relative to the client machine\n     */\n    fun millisToCurrentLocalDateTime(milliseconds: Long): String {\n        val localDateTime = Instant.ofEpochMilli(milliseconds).atZone(ZoneId.systemDefault()).toLocalDateTime()\n        return localDateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/runner/CliWatcher.kt",
    "content": "package maestro.cli.runner\n\nimport java.io.InputStream\nimport java.nio.file.Path\nimport java.util.concurrent.CompletableFuture\nimport java.util.concurrent.Executors\nimport java.util.concurrent.TimeUnit\n\ninternal object CliWatcher {\n\n    fun waitForFileChangeOrEnter(fileWatcher: FileWatcher, files: List<Path>): SignalType {\n        val executor = Executors.newCachedThreadPool()\n\n        val fileChangeFuture = CompletableFuture.supplyAsync({\n            fileWatcher.waitForChange(files)\n            SignalType.FILE_CHANGE\n        }, executor)\n\n        val enterFuture = CompletableFuture.supplyAsync({\n            interruptibleWaitForChar(System.`in`, '\\n')\n            SignalType.ENTER\n        }, executor)\n\n        val signalType = CompletableFuture.anyOf(fileChangeFuture, enterFuture).get() as SignalType\n\n        executor.shutdownNow()\n        if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {\n            throw InterruptedException(\"Timed out waiting for threads to shutdown\")\n        }\n\n        return signalType\n    }\n\n    private fun interruptibleWaitForChar(inputStream: InputStream, c: Char) {\n        while (true) {\n            if (inputStream.available() > 0){\n                if (inputStream.read().toChar() == c) {\n                    return\n                }\n            } else {\n                Thread.sleep(100)\n            }\n        }\n    }\n\n    enum class SignalType {\n        ENTER, FILE_CHANGE\n    }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/runner/CommandState.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.cli.runner\n\nimport maestro.orchestra.MaestroCommand\nimport maestro.utils.Insight\n\ndata class CommandState(\n    val status: CommandStatus,\n    val command: MaestroCommand,\n    val subOnStartCommands: List<CommandState>?,\n    val subOnCompleteCommands: List<CommandState>?,\n    val numberOfRuns: Int? = null,\n    val subCommands: List<CommandState>? = null,\n    val logMessages: List<String> = emptyList(),\n    val insight: Insight = Insight(\"\", Insight.Level.NONE)\n)"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/runner/CommandStatus.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.cli.runner\n\nenum class CommandStatus {\n    PENDING,\n    RUNNING,\n    COMPLETED,\n    FAILED,\n    WARNED,\n    SKIPPED,\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/runner/FileWatcher.kt",
    "content": "package maestro.cli.runner\n\nimport java.nio.file.FileSystems\nimport java.nio.file.Path\nimport java.nio.file.StandardWatchEventKinds\nimport java.nio.file.WatchKey\nimport kotlin.io.path.absolute\n\nclass FileWatcher {\n\n    private val watchService = FileSystems.getDefault().newWatchService()\n    private val watchKeys = mutableSetOf<WatchKey>()\n\n    fun waitForChange(files: Iterable<Path>) {\n        watchKeys.forEach(WatchKey::cancel)\n        watchKeys.clear()\n\n        val paths = files.map { it.absolute() }\n\n        paths.forEach { path ->\n            val watchKey = path.parent.register(\n                watchService,\n                arrayOf(\n                    StandardWatchEventKinds.ENTRY_CREATE,\n                    StandardWatchEventKinds.ENTRY_DELETE,\n                    StandardWatchEventKinds.ENTRY_MODIFY,\n                ),\n            )\n            watchKeys.add(watchKey)\n        }\n\n        fun isRelevantWatchKey(watchKey: WatchKey): Boolean {\n            watchKey.pollEvents().forEach { event ->\n                val relativePath = event.context() as Path\n                val fullPath = (watchKey.watchable() as Path).resolve(relativePath).toAbsolutePath()\n                if (fullPath in paths) return true\n            }\n            return false\n        }\n\n        while (true) {\n            val watchKey = watchService.take()\n            try {\n                if (watchKey.isValid) {\n                    if (isRelevantWatchKey(watchKey)) {\n                        break\n                    }\n                } else {\n                    watchKeys.remove(watchKey)\n                }\n            } finally {\n                watchKey.reset()\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/runner/MaestroCommandRunner.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.cli.runner\n\nimport maestro.Maestro\nimport maestro.MaestroException\nimport maestro.device.Device\nimport maestro.cli.report.SingleScreenFlowAIOutput\nimport maestro.cli.report.CommandDebugMetadata\nimport maestro.cli.report.FlowAIOutput\nimport maestro.cli.report.FlowDebugOutput\nimport maestro.cli.runner.resultview.ResultView\nimport maestro.cli.runner.resultview.UiState\nimport maestro.cli.util.PrintUtils\nimport maestro.orchestra.ApplyConfigurationCommand\nimport maestro.orchestra.CompositeCommand\nimport maestro.orchestra.MaestroCommand\nimport maestro.orchestra.Orchestra\n\nimport maestro.orchestra.yaml.YamlCommandReader\nimport maestro.utils.CliInsights\nimport org.slf4j.LoggerFactory\nimport java.io.File\nimport java.util.IdentityHashMap\nimport maestro.cli.util.ScreenshotUtils\nimport maestro.utils.Insight\nimport java.nio.file.Path\n\n/**\n * Knows how to run a list of Maestro commands and update the UI.\n *\n * Should not know what a \"flow\" is (apart from knowing a name, for display purposes).\n */\nobject MaestroCommandRunner {\n\n    private val logger = LoggerFactory.getLogger(MaestroCommandRunner::class.java)\n\n    suspend fun runCommands(\n        flowName: String,\n        maestro: Maestro,\n        device: Device?,\n        view: ResultView,\n        commands: List<MaestroCommand>,\n        debugOutput: FlowDebugOutput,\n        aiOutput: FlowAIOutput,\n        apiKey: String? = null,\n        analyze: Boolean = false,\n        testOutputDir: Path?\n    ): Boolean {\n        val config = YamlCommandReader.getConfig(commands)\n        val onFlowComplete = config?.onFlowComplete\n        val onFlowStart = config?.onFlowStart\n\n        val commandStatuses = IdentityHashMap<MaestroCommand, CommandStatus>()\n        val commandMetadata = IdentityHashMap<MaestroCommand, Orchestra.CommandMetadata>()\n\n        fun refreshUi() {\n            view.setState(\n                UiState.Running(\n                    flowName = flowName,\n                    device = device,\n                    onFlowStartCommands = toCommandStates(\n                        onFlowStart?.commands ?: emptyList(),\n                        commandStatuses,\n                        commandMetadata\n                    ),\n                    onFlowCompleteCommands = toCommandStates(\n                        onFlowComplete?.commands ?: emptyList(),\n                        commandStatuses,\n                        commandMetadata\n                    ),\n                    commands = toCommandStates(\n                        commands,\n                        commandStatuses,\n                        commandMetadata\n                    )\n                )\n            )\n        }\n\n        refreshUi()\n\n        if (analyze) {\n            ScreenshotUtils.takeDebugScreenshotByCommand(maestro, debugOutput, CommandStatus.PENDING)\n        }\n\n        var commandSequenceNumber = 0\n\n        val orchestra = Orchestra(\n            maestro = maestro,\n            screenshotsDir = testOutputDir?.resolve(\"screenshots\"),\n            insights = CliInsights,\n            onCommandStart = { _, command ->\n                logger.info(\"${command.description()} RUNNING\")\n                commandStatuses[command] = CommandStatus.RUNNING\n                debugOutput.commands[command] = CommandDebugMetadata(\n                    timestamp = System.currentTimeMillis(),\n                    status = CommandStatus.RUNNING,\n                    sequenceNumber = commandSequenceNumber++\n                )\n\n                refreshUi()\n            },\n            onCommandComplete = { _, command ->\n                logger.info(\"${command.description()} COMPLETED\")\n                commandStatuses[command] = CommandStatus.COMPLETED\n                if (analyze) {\n                    ScreenshotUtils.takeDebugScreenshotByCommand(maestro, debugOutput, CommandStatus.COMPLETED)\n                }\n\n                debugOutput.commands[command]?.apply {\n                    status = CommandStatus.COMPLETED\n                    calculateDuration()\n                }\n                refreshUi()\n            },\n            onCommandFailed = { _, command, e ->\n                debugOutput.commands[command]?.apply {\n                    status = CommandStatus.FAILED\n                    calculateDuration()\n                    error = e\n                }\n\n                ScreenshotUtils.takeDebugScreenshot(maestro, debugOutput, CommandStatus.FAILED)\n\n                if (e !is MaestroException) {\n                    throw e\n                } else {\n                    debugOutput.exception = e\n                }\n\n                logger.info(\"${command.description()} FAILED\")\n                commandStatuses[command] = CommandStatus.FAILED\n                refreshUi()\n                Orchestra.ErrorResolution.FAIL\n            },\n            onCommandSkipped = { _, command ->\n                logger.info(\"${command.description()} SKIPPED\")\n                commandStatuses[command] = CommandStatus.SKIPPED\n                debugOutput.commands[command]?.apply {\n                    status = CommandStatus.SKIPPED\n                }\n                refreshUi()\n            },\n            onCommandWarned = { _, command ->\n                logger.info(\"${command.description()} WARNED\")\n                commandStatuses[command] = CommandStatus.WARNED\n                debugOutput.commands[command]?.apply {\n                    status = CommandStatus.WARNED\n                }\n\n                ScreenshotUtils.takeDebugScreenshot(maestro, debugOutput, CommandStatus.WARNED)\n\n                refreshUi()\n            },\n            onCommandReset = { command ->\n                logger.info(\"${command.description()} PENDING\")\n                commandStatuses[command] = CommandStatus.PENDING\n                debugOutput.commands[command]?.apply {\n                    status = CommandStatus.PENDING\n                }\n                refreshUi()\n            },\n            onCommandMetadataUpdate = { command, metadata ->\n                logger.info(\"${command.description()} metadata $metadata\")\n                commandMetadata[command] = metadata\n                // Update debug output with evaluated command for interpolated labels\n                debugOutput.commands[command]?.evaluatedCommand = metadata.evaluatedCommand\n                refreshUi()\n            },\n            onCommandGeneratedOutput = { command, defects, screenshot ->\n                logger.info(\"${command.description()} generated output\")\n                val screenshotPath = ScreenshotUtils.writeAIscreenshot(screenshot)\n                aiOutput.screenOutputs.add(\n                    SingleScreenFlowAIOutput(\n                        screenshotPath = screenshotPath,\n                        defects = defects,\n                    )\n                )\n            },\n            apiKey = apiKey,    \n        )\n\n        val flowSuccess = orchestra.runFlow(commands)\n\n        // Warn users about deprecated Rhino JS engine\n        val isRhinoExplicitlyRequested = config?.ext?.get(\"jsEngine\") == \"rhino\"\n        if (isRhinoExplicitlyRequested) {\n          PrintUtils.warn(\"⚠️  The Rhino JS engine (jsEngine: rhino) is deprecated and will be removed in a future version. Please migrate to GraalJS (the default) for better performance and compatibility. This warning will be removed in a future version.\")\n        }        \n\n        return flowSuccess\n    }\n\n    private fun toCommandStates(\n        commands: List<MaestroCommand>,\n        commandStatuses: MutableMap<MaestroCommand, CommandStatus>,\n        commandMetadata: IdentityHashMap<MaestroCommand, Orchestra.CommandMetadata>,\n    ): List<CommandState> {\n        return commands\n            // Don't render configuration commands\n            .filter { it.asCommand() !is ApplyConfigurationCommand }\n            .mapIndexed { _, command ->\n                CommandState(\n                    command = commandMetadata[command]?.evaluatedCommand ?: command,\n                    subOnStartCommands = (command.asCommand() as? CompositeCommand)\n                        ?.config()\n                        ?.onFlowStart\n                        ?.let { toCommandStates(it.commands, commandStatuses, commandMetadata) },\n                    subOnCompleteCommands = (command.asCommand() as? CompositeCommand)\n                        ?.config()\n                        ?.onFlowComplete\n                        ?.let { toCommandStates(it.commands, commandStatuses, commandMetadata) },\n                    status = commandStatuses[command] ?: CommandStatus.PENDING,\n                    numberOfRuns = commandMetadata[command]?.numberOfRuns,\n                    subCommands = (command.asCommand() as? CompositeCommand)\n                        ?.subCommands()\n                        ?.let { toCommandStates(it, commandStatuses, commandMetadata) },\n                    logMessages = commandMetadata[command]?.logMessages ?: emptyList(),\n                    insight = commandMetadata[command]?.insight ?: Insight(\"\", Insight.Level.NONE)\n                )\n            }\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt",
    "content": "package maestro.cli.runner\n\nimport com.github.michaelbull.result.Err\nimport com.github.michaelbull.result.Ok\nimport com.github.michaelbull.result.Result\nimport com.github.michaelbull.result.get\nimport com.github.michaelbull.result.getOr\nimport com.github.michaelbull.result.onFailure\nimport kotlinx.coroutines.runBlocking\nimport maestro.Maestro\nimport maestro.MaestroException\nimport maestro.device.Device\nimport maestro.cli.report.FlowAIOutput\nimport maestro.cli.report.FlowDebugOutput\nimport maestro.cli.report.TestDebugReporter\nimport maestro.cli.runner.resultview.AnsiResultView\nimport maestro.cli.runner.resultview.ResultView\nimport maestro.cli.runner.resultview.UiState\nimport maestro.cli.util.EnvUtils\nimport maestro.cli.util.PrintUtils\nimport maestro.cli.view.ErrorViewUtils\nimport maestro.orchestra.MaestroCommand\nimport maestro.orchestra.util.Env.withEnv\nimport maestro.orchestra.util.Env.withDefaultEnvVars\nimport maestro.orchestra.util.Env.withInjectedShellEnvVars\nimport maestro.orchestra.yaml.YamlCommandReader\nimport org.slf4j.LoggerFactory\nimport java.io.File\nimport java.nio.file.Path\nimport kotlin.concurrent.thread\n\n/**\n * Knows how to run a single Maestro flow (either one-shot or continuously).\n */\nobject TestRunner {\n\n    private val logger = LoggerFactory.getLogger(TestRunner::class.java)\n\n    /**\n     * Runs a single flow, one-shot style.\n     *\n     * If the flow generates artifacts, they should be placed in [debugOutputPath].\n     */\n    fun runSingle(\n        maestro: Maestro,\n        device: Device?,\n        flowFile: File,\n        env: Map<String, String>,\n        resultView: ResultView,\n        debugOutputPath: Path,\n        analyze: Boolean = false,\n        apiKey: String? = null,\n        testOutputDir: Path?,\n        deviceId: String?,\n    ): Int {\n        val debugOutput = FlowDebugOutput()\n        var aiOutput = FlowAIOutput(\n            flowName = flowFile.nameWithoutExtension,\n            flowFile = flowFile,\n        )\n\n        val updatedEnv = env\n            .withInjectedShellEnvVars()\n            .withDefaultEnvVars(flowFile, deviceId)\n\n        val result = runCatching(resultView, maestro) {\n            val commands = YamlCommandReader.readCommands(flowFile.toPath())\n                .withEnv(updatedEnv)\n\n            val flowName = YamlCommandReader.getConfig(commands)?.name ?: flowFile.nameWithoutExtension\n            aiOutput = aiOutput.copy(flowName = flowName)\n\n            logger.info(\"Running flow ${flowFile.name}...\")\n\n            runBlocking {\n                MaestroCommandRunner.runCommands(\n                    flowName = flowName,\n                    maestro = maestro,\n                    device = device,\n                    view = resultView,\n                    commands = commands,\n                    debugOutput = debugOutput,\n                    aiOutput = aiOutput,\n                    analyze = analyze,\n                    apiKey = apiKey,\n                    testOutputDir = testOutputDir,\n                )\n            }\n        }\n\n        TestDebugReporter.saveFlow(\n            flowName = flowFile.name,\n            debugOutput = debugOutput,\n            path = debugOutputPath,\n        )\n        TestDebugReporter.saveSuggestions(\n            outputs = listOf(aiOutput),\n            path = debugOutputPath,\n        )\n\n        val exception = debugOutput.exception\n        if (exception != null) {\n            PrintUtils.err(exception.message)\n            if (exception is MaestroException.AssertionFailure) {\n                PrintUtils.err(exception.debugMessage)\n            } else if (exception is MaestroException.HideKeyboardFailure) {\n                PrintUtils.err(exception.debugMessage)\n            } else {\n                val debugMessage = (exception as? MaestroException.DriverTimeout)?.debugMessage\n                if (exception is MaestroException.DriverTimeout && debugMessage != null) {\n                    PrintUtils.err(debugMessage)\n                }\n            }\n        }\n\n        return if (result.get() == true) 0 else 1\n    }\n\n    /**\n     * Runs a single flow continuously.\n     */\n    fun runContinuous(\n        maestro: Maestro,\n        device: Device?,\n        flowFile: File,\n        env: Map<String, String>,\n        analyze: Boolean = false,\n        apiKey: String? = null,\n        testOutputDir: Path?,\n        deviceId: String?,\n    ): Nothing {\n        val resultView = AnsiResultView(\"> Press [ENTER] to restart the Flow\\n\\n\", useEmojis = !EnvUtils.isWindows())\n\n        val fileWatcher = FileWatcher()\n\n        var previousCommands: List<MaestroCommand>? = null\n\n        var ongoingTest: Thread? = null\n        do {\n            val watchFiles = runCatching(resultView, maestro) {\n                ongoingTest?.apply {\n                    interrupt()\n                    join()\n                }\n\n                val updatedEnv = env\n                    .withInjectedShellEnvVars()\n                    .withDefaultEnvVars(flowFile, deviceId)\n\n                val commands = YamlCommandReader\n                    .readCommands(flowFile.toPath())\n                    .withEnv(updatedEnv)\n\n                val flowName = YamlCommandReader.getConfig(commands)?.name\n\n                // Restart the flow if anything has changed\n                if (commands != previousCommands) {\n                    ongoingTest = thread {\n                        previousCommands = commands\n\n                        runCatching(resultView, maestro) {\n                            runBlocking {\n                                MaestroCommandRunner.runCommands(\n                                    flowName = flowName ?: flowFile.nameWithoutExtension,\n                                    maestro = maestro,\n                                    device = device,\n                                    view = resultView,\n                                    commands = commands,\n                                    debugOutput = FlowDebugOutput(),\n                                    // TODO(bartekpacia): make AI outputs work in continuous mode (see #1972)\n                                    aiOutput = FlowAIOutput(\n                                        flowName = \"TODO\",\n                                        flowFile = flowFile,\n                                    ),\n                                    analyze = analyze,\n                                    apiKey = apiKey,\n                                    testOutputDir = testOutputDir\n                                )\n                            }\n                        }.get()\n                    }\n                }\n\n                YamlCommandReader.getWatchFiles(flowFile.toPath())\n            }\n                .onFailure {\n                    previousCommands = null\n                }\n                .getOr(listOf(flowFile.toPath()))\n\n            if (CliWatcher.waitForFileChangeOrEnter(fileWatcher, watchFiles) == CliWatcher.SignalType.ENTER) {\n                // On ENTER force re-run of flow even if commands have not changed\n                previousCommands = null\n            }\n        } while (true)\n    }\n\n    private fun <T> runCatching(\n        view: ResultView,\n        maestro: Maestro,\n        block: () -> T,\n    ): Result<T, Exception> {\n        return try {\n            Ok(block())\n        } catch (e: Exception) {\n            logger.error(\"Failed to run flow\", e)\n            val message = ErrorViewUtils.exceptionToMessage(e)\n\n            if (!maestro.isShutDown()) {\n                view.setState(\n                    UiState.Error(\n                        message = message\n                    )\n                )\n            }\n            return Err(e)\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/runner/TestSuiteInteractor.kt",
    "content": "package maestro.cli.runner\n\nimport maestro.Maestro\nimport maestro.MaestroException\nimport maestro.cli.CliError\nimport maestro.device.Device\nimport maestro.cli.model.FlowStatus\nimport maestro.cli.model.TestExecutionSummary\nimport maestro.cli.report.SingleScreenFlowAIOutput\nimport maestro.cli.report.CommandDebugMetadata\nimport maestro.cli.report.FlowAIOutput\nimport maestro.cli.report.FlowDebugOutput\nimport maestro.cli.report.TestDebugReporter\nimport maestro.cli.report.TestSuiteReporter\nimport maestro.cli.util.PrintUtils\nimport maestro.cli.util.TimeUtils\nimport maestro.cli.view.ErrorViewUtils\nimport maestro.cli.view.TestSuiteStatusView\nimport maestro.cli.view.TestSuiteStatusView.TestSuiteViewModel\nimport maestro.orchestra.Orchestra\nimport maestro.orchestra.util.Env.withEnv\nimport maestro.orchestra.workspace.WorkspaceExecutionPlanner\nimport maestro.orchestra.yaml.YamlCommandReader\nimport okio.Sink\nimport org.slf4j.LoggerFactory\nimport java.io.File\nimport java.nio.file.Path\nimport kotlin.system.measureTimeMillis\nimport kotlin.time.Duration.Companion.seconds\nimport maestro.cli.util.ScreenshotUtils\nimport maestro.orchestra.util.Env.withDefaultEnvVars\nimport maestro.orchestra.util.Env.withInjectedShellEnvVars\n\n/**\n * Similar to [TestRunner], but:\n *  * can run many flows at once\n *  * does not support continuous mode\n *\n *  Does not care about sharding. It only has to know the index of the shard it's running it, for logging purposes.\n */\nclass TestSuiteInteractor(\n    private val maestro: Maestro,\n    private val device: Device? = null,\n    private val reporter: TestSuiteReporter,\n    private val shardIndex: Int? = null,\n    private val captureSteps: Boolean = false,\n) {\n\n    private val logger = LoggerFactory.getLogger(TestSuiteInteractor::class.java)\n    private val shardPrefix = shardIndex?.let { \"[shard ${it + 1}] \" }.orEmpty()\n\n    suspend fun runTestSuite(\n        executionPlan: WorkspaceExecutionPlanner.ExecutionPlan,\n        reportOut: Sink?,\n        env: Map<String, String>,\n        debugOutputPath: Path,\n        testOutputDir: Path? = null,\n        deviceId: String? = null,\n    ): TestExecutionSummary {\n        if (executionPlan.flowsToRun.isEmpty() && executionPlan.sequence.flows.isEmpty()) {\n            throw CliError(\"${shardPrefix}No flows returned from the tag filter used\")\n        }\n\n        val flowResults = mutableListOf<TestExecutionSummary.FlowResult>()\n\n        PrintUtils.message(\"${shardPrefix}Waiting for flows to complete...\")\n\n        var passed = true\n        val aiOutputs = mutableListOf<FlowAIOutput>()\n\n        // first run sequence of flows if present\n        val flowSequence = executionPlan.sequence\n        for (flow in flowSequence.flows) {\n            val flowFile = flow.toFile()\n            val updatedEnv = env\n                .withInjectedShellEnvVars()\n                .withDefaultEnvVars(flowFile, deviceId, shardIndex)\n            val (result, aiOutput) = runFlow(flowFile, updatedEnv, maestro, debugOutputPath, testOutputDir)\n            flowResults.add(result)\n            aiOutputs.add(aiOutput)\n\n            if (result.status == FlowStatus.ERROR) {\n                passed = false\n                if (executionPlan.sequence.continueOnFailure != true) {\n                    PrintUtils.message(\"${shardPrefix}Flow ${result.name} failed and continueOnFailure is set to false, aborting running sequential Flows\")\n                    println()\n                    break\n                }\n            }\n        }\n\n        // proceed to run all other Flows\n        executionPlan.flowsToRun.forEach { flow ->\n            val flowFile = flow.toFile()\n            val updatedEnv = env\n                .withInjectedShellEnvVars()\n                .withDefaultEnvVars(flowFile, deviceId, shardIndex)\n            val (result, aiOutput) = runFlow(flowFile, updatedEnv, maestro, debugOutputPath, testOutputDir)\n            aiOutputs.add(aiOutput)\n\n            if (result.status == FlowStatus.ERROR) {\n                passed = false\n            }\n            flowResults.add(result)\n        }\n\n\n        val suiteDuration = flowResults.sumOf { it.duration?.inWholeSeconds ?: 0 }.seconds\n\n        TestSuiteStatusView.showSuiteResult(\n            TestSuiteViewModel(\n                status = if (passed) FlowStatus.SUCCESS else FlowStatus.ERROR,\n                duration = suiteDuration,\n                shardIndex = shardIndex,\n                flows = flowResults\n                    .map {\n                        TestSuiteViewModel.FlowResult(\n                            name = it.name,\n                            status = it.status,\n                            duration = it.duration,\n                        )\n                    },\n            ),\n            uploadUrl = \"\"\n        )\n\n        val summary = TestExecutionSummary(\n            passed = passed,\n            suites = listOf(\n                TestExecutionSummary.SuiteResult(\n                    passed = passed,\n                    flows = flowResults,\n                    duration = suiteDuration,\n                    deviceName = device?.description,\n                )\n            ),\n            passedCount = flowResults.count { it.status == FlowStatus.SUCCESS },\n            totalTests = flowResults.size\n        )\n\n        if (reportOut != null) {\n            reporter.report(\n                summary,\n                reportOut,\n            )\n        }\n\n        // TODO(bartekpacia): Should it also be saving to debugOutputPath?\n        TestDebugReporter.saveSuggestions(aiOutputs, debugOutputPath)\n\n        return summary\n    }\n\n    private suspend fun runFlow(\n        flowFile: File,\n        env: Map<String, String>,\n        maestro: Maestro,\n        debugOutputPath: Path,\n        testOutputDir: Path? = null\n    ): Pair<TestExecutionSummary.FlowResult, FlowAIOutput> {\n        // TODO(bartekpacia): merge TestExecutionSummary with AI suggestions\n        //  (i.e. consider them also part of the test output)\n        //  See #1973\n\n        var flowStatus: FlowStatus\n        var errorMessage: String? = null\n\n        val debugOutput = FlowDebugOutput()\n        val aiOutput = FlowAIOutput(\n            flowName = flowFile.nameWithoutExtension,\n            flowFile = flowFile,\n        )\n        val commands = YamlCommandReader\n            .readCommands(flowFile.toPath())\n            .withEnv(env)\n\n        val maestroConfig = YamlCommandReader.getConfig(commands)\n        val flowName: String = maestroConfig?.name ?: flowFile.nameWithoutExtension\n\n        logger.info(\"$shardPrefix Running flow $flowName\")\n\n        val flowTimeMillis = measureTimeMillis {\n            try {\n                var commandSequenceNumber = 0\n                val orchestra = Orchestra(\n                    maestro = maestro,\n                    screenshotsDir = testOutputDir?.resolve(\"screenshots\"),\n                    onCommandStart = { _, command ->\n                        logger.info(\"${shardPrefix}${command.description()} RUNNING\")\n                        debugOutput.commands[command] = CommandDebugMetadata(\n                            timestamp = System.currentTimeMillis(),\n                            status = CommandStatus.RUNNING,\n                            sequenceNumber = commandSequenceNumber++\n                        )\n                    },\n                    onCommandComplete = { _, command ->\n                        logger.info(\"${shardPrefix}${command.description()} COMPLETED\")\n                        debugOutput.commands[command]?.let {\n                            it.status = CommandStatus.COMPLETED\n                            it.calculateDuration()\n                        }\n                    },\n                    onCommandFailed = { _, command, e ->\n                        logger.info(\"${shardPrefix}${command.description()} FAILED\")\n                        if (e is MaestroException) debugOutput.exception = e\n                        debugOutput.commands[command]?.let {\n                            it.status = CommandStatus.FAILED\n                            it.calculateDuration()\n                            it.error = e\n                        }\n\n                        ScreenshotUtils.takeDebugScreenshot(maestro, debugOutput, CommandStatus.FAILED)\n                        Orchestra.ErrorResolution.FAIL\n                    },\n                    onCommandSkipped = { _, command ->\n                        logger.info(\"${shardPrefix}${command.description()} SKIPPED\")\n                        debugOutput.commands[command]?.let {\n                            it.status = CommandStatus.SKIPPED\n                        }\n                    },\n                    onCommandWarned = { _, command ->\n                        logger.info(\"${shardPrefix}${command.description()} WARNED\")\n                        debugOutput.commands[command]?.apply {\n                            status = CommandStatus.WARNED\n                        }\n                    },\n                    onCommandReset = { command ->\n                        logger.info(\"${shardPrefix}${command.description()} PENDING\")\n                        debugOutput.commands[command]?.let {\n                            it.status = CommandStatus.PENDING\n                        }\n                    },\n                    onCommandGeneratedOutput = { command, defects, screenshot ->\n                        logger.info(\"${shardPrefix}${command.description()} generated output\")\n                        val screenshotPath = ScreenshotUtils.writeAIscreenshot(screenshot)\n                        aiOutput.screenOutputs.add(\n                            SingleScreenFlowAIOutput(\n                                screenshotPath = screenshotPath,\n                                defects = defects,\n                            )\n                        )\n                    }\n                )\n\n                val flowSuccess = orchestra.runFlow(commands)\n                flowStatus = if (flowSuccess) FlowStatus.SUCCESS else FlowStatus.ERROR\n            } catch (e: Exception) {\n                logger.error(\"${shardPrefix}Failed to complete flow\", e)\n                flowStatus = FlowStatus.ERROR\n                errorMessage = ErrorViewUtils.exceptionToMessage(e)\n            }\n        }\n        val flowDuration = TimeUtils.durationInSeconds(flowTimeMillis)\n\n        TestDebugReporter.saveFlow(\n            flowName = flowName,\n            debugOutput = debugOutput,\n            shardIndex = shardIndex,\n            path = debugOutputPath,\n        )\n        // FIXME(bartekpacia): Save AI output as well\n\n        TestSuiteStatusView.showFlowCompletion(\n            TestSuiteViewModel.FlowResult(\n                name = flowName,\n                status = flowStatus,\n                duration = flowDuration,\n                shardIndex = shardIndex,\n                error = debugOutput.exception?.message,\n            )\n        )\n\n        // Extract step information if captureSteps is enabled\n        val steps = if (captureSteps) {\n            debugOutput.commands.entries\n                .sortedBy { it.value.sequenceNumber }\n                .mapIndexed { index, (command, metadata) ->\n                    val durationStr = when (val duration = metadata.duration) {\n                        null -> \"<1ms\"\n                        else -> if (duration >= 1000) {\n                            \"%.1fs\".format(duration / 1000.0)\n                        } else {\n                            \"${duration}ms\"\n                        }\n                    }\n                    val status = metadata.status?.toString() ?: \"UNKNOWN\"\n                    // Use evaluated command for interpolated labels, fallback to original\n                    val displayCommand = metadata.evaluatedCommand ?: command\n                    TestExecutionSummary.StepResult(\n                        description = \"${index + 1}. ${displayCommand.description()}\",\n                        status = status,\n                        duration = durationStr,\n                    )\n                }\n        } else {\n            emptyList()\n        }\n\n        return Pair(\n            first = TestExecutionSummary.FlowResult(\n                name = flowName,\n                fileName = flowFile.nameWithoutExtension,\n                status = flowStatus,\n                failure = if (flowStatus == FlowStatus.ERROR) {\n                    TestExecutionSummary.Failure(\n                        message = shardPrefix + (errorMessage ?: debugOutput.exception?.message ?: \"Unknown error\"),\n                    )\n                } else null,\n                duration = flowDuration,\n                properties = maestroConfig?.properties,\n                tags = maestroConfig?.tags,\n                steps = steps,\n            ),\n            second = aiOutput,\n        )\n    }\n\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/runner/resultview/AnsiResultView.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.cli.runner.resultview\n\nimport io.ktor.util.encodeBase64\nimport maestro.device.Device\nimport maestro.device.Platform\nimport maestro.cli.runner.CommandState\nimport maestro.cli.runner.CommandStatus\nimport maestro.device.DeviceSpecRequest\nimport maestro.device.DeviceSpec\nimport maestro.orchestra.AssertWithAICommand\nimport maestro.orchestra.ElementSelector\nimport maestro.orchestra.LaunchAppCommand\nimport maestro.orchestra.MaestroCommand\nimport maestro.orchestra.TapOnElementCommand\nimport maestro.orchestra.TapOnPointV2Command\nimport maestro.utils.Insight\nimport maestro.utils.chunkStringByWordCount\nimport org.fusesource.jansi.Ansi\n\nclass AnsiResultView(\n    private val prompt: String? = null,\n    private val printCommandLogs: Boolean = true,\n    private val useEmojis: Boolean = true,\n) : ResultView {\n\n    private val startTimestamp = System.currentTimeMillis()\n\n    private val frames = mutableListOf<Frame>()\n\n    private var previousFrame: String? = null\n\n    init {\n        println(Ansi.ansi().eraseScreen())\n    }\n\n    override fun setState(state: UiState) {\n        when (state) {\n            is UiState.Running -> renderRunningState(state)\n            is UiState.Error -> renderErrorState(state)\n        }\n    }\n\n    fun getFrames(): List<Frame> {\n        return frames.toList()\n    }\n\n    private fun renderErrorState(state: UiState.Error) {\n        renderFrame {\n            fgRed()\n            render(state.message)\n            render(\"\\n\")\n        }\n    }\n\n    private fun renderRunningState(state: UiState.Running) = renderFrame {\n        state.device?.let {\n            render(\"Running on ${state.device.description}\\n\")\n        }\n        render(\"\\n\")\n        if (state.onFlowStartCommands.isNotEmpty()) {\n            render(\" ║\\n\")\n            render(\" ║  > On Flow Start\\n\")\n            render(\" ║\\n\")\n            renderCommands(state.onFlowStartCommands)\n        }\n        render(\" ║\\n\")\n        render(\" ║  > Flow: ${state.flowName}\\n\")\n        render(\" ║\\n\")\n        renderCommands(state.commands)\n        render(\" ║\\n\")\n        if (state.onFlowCompleteCommands.isNotEmpty()) {\n            render(\" ║\\n\")\n            render(\" ║  > On Flow Complete\\n\")\n            render(\" ║\\n\")\n            renderCommands(state.onFlowCompleteCommands)\n        }\n        renderPrompt()\n    }\n\n    private fun Ansi.renderPrompt() {\n        prompt?.let {\n            render(\" ║\\n\")\n            render(\" ║  $prompt\\n\")\n        }\n    }\n\n    private fun Ansi.renderCommands(\n        commands: List<CommandState>,\n        indent: Int = 0,\n    ) {\n        commands\n            .filter { it.command.asCommand()?.visible() ?: true }\n            .forEach { renderCommand(it, indent) }\n    }\n\n    private fun Ansi.renderCommand(commandState: CommandState, indent: Int) {\n        val statusSymbol = status(commandState.status)\n\n        fgDefault()\n        renderLineStart(indent)\n        render(statusSymbol)\n        render(\" \".repeat(2))\n        render(\n            commandState.command.description()\n                .replace(\"(?<!\\\\\\\\)\\\\\\$\\\\{.*}\".toRegex()) { match ->\n                    \"@|cyan ${match.value}|@\"\n                }\n        )\n\n        if (commandState.status == CommandStatus.SKIPPED) {\n            render(\" (skipped)\")\n        } else if (commandState.status == CommandStatus.WARNED) {\n            render(\" (warned)\")\n        } else if (commandState.numberOfRuns != null) {\n            val timesWord = if (commandState.numberOfRuns == 1) \"time\" else \"times\"\n            render(\" (completed ${commandState.numberOfRuns} $timesWord)\")\n        }\n\n        render(\"\\n\")\n\n        if (printCommandLogs && commandState.logMessages.isNotEmpty()) {\n            printLogMessages(indent, commandState)\n        }\n\n        if (commandState.insight.level != Insight.Level.NONE) {\n            printInsight(indent, commandState.insight)\n        }\n\n\n        val subCommandsHasNotPending = (commandState.subCommands\n            ?.any { subCommand -> subCommand.status != CommandStatus.PENDING } ?: false)\n        val onStartHasNotPending = (commandState.subOnStartCommands\n            ?.any { subCommand -> subCommand.status != CommandStatus.PENDING } ?: false)\n        val onCompleteHasNotPending = (commandState.subOnCompleteCommands\n            ?.any { subCommand -> subCommand.status != CommandStatus.PENDING } ?: false)\n        val expandSubCommands = commandState.status in setOf(CommandStatus.RUNNING, CommandStatus.FAILED) &&\n                (subCommandsHasNotPending || onStartHasNotPending || onCompleteHasNotPending)\n\n        if (expandSubCommands) {\n            commandState.subOnStartCommands?.let {\n                render(\" ║\\n\")\n                render(\" ║  > On Flow Start\\n\")\n                render(\" ║\\n\")\n                renderCommands(it)\n            }\n\n            commandState.subCommands?.let { subCommands ->\n                renderCommands(subCommands, indent + 1)\n            }\n\n            commandState.subOnCompleteCommands?.let {\n                render(\" ║\\n\")\n                render(\" ║  > On Flow Complete\\n\")\n                render(\" ║\\n\")\n                renderCommands(it)\n            }\n        }\n    }\n\n    private fun Ansi.printLogMessages(indent: Int, commandState: CommandState) {\n        renderLineStart(indent + 1)\n        render(\"   \")   // Space that a status symbol would normally occupy\n        render(\"@|yellow Log messages:|@\\n\")\n\n        commandState.logMessages.forEach {\n            renderLineStart(indent + 2)\n            render(\"   \")   // Space that a status symbol would normally occupy\n            render(it)\n            render(\"\\n\")\n        }\n    }\n\n    private fun Ansi.printInsight(indent: Int, insight: Insight) {\n        val color = when (insight.level) {\n            Insight.Level.WARNING -> \"yellow\"\n            Insight.Level.INFO -> \"cyan\"\n            else -> \"default\"\n        }\n        val level = insight.level.toString().lowercase().replaceFirstChar(Char::uppercase)\n        renderLineStart(indent + 1)\n        render(\"   \")   // Space that a status symbol would normally occupy\n        render(\"@|$color $level:|@\\n\")\n\n        insight.message.split(\"\\n\").forEach { paragraph ->\n            paragraph.chunkStringByWordCount(12).forEach { chunkedMessage ->\n                renderLineStart(indent + 2)\n                render(\"   \")   // Space that a status symbol would normally occupy\n                render(chunkedMessage)\n                render(\"\\n\")\n            }\n        }\n    }\n\n    private fun Ansi.renderLineStart(indent: Int) {\n        render(\" ║    \")\n        repeat(indent) {\n            render(\"  \")\n        }\n    }\n\n    private fun renderFrame(block: Ansi.() -> Any) {\n        renderFrame(StringBuilder().apply {\n            val ansi = Ansi().cursor(0, 0)\n            ansi.block()\n            append(ansi)\n        }.toString())\n    }\n\n    private fun renderFrame(frame: String) {\n        // Clear previous frame\n        previousFrame?.let { previousFrame ->\n            val lines = previousFrame.lines()\n            val height = lines.size\n            val width = lines.maxOf { it.length }\n            Ansi.ansi().let { ansi ->\n                ansi.cursor(0, 0)\n                repeat(height) {\n                    ansi.render(\" \".repeat(width))\n                    ansi.render(\"\\n\")\n                }\n                ansi.cursor(0, 0)\n                println(ansi)\n            }\n        }\n        print(frame)\n        frames.add(createFrame(frame))\n        previousFrame = frame\n    }\n\n    private fun createFrame(frame: String): Frame {\n        val content = frame.encodeBase64()\n        return Frame(System.currentTimeMillis() - startTimestamp, content)\n    }\n\n    private fun status(status: CommandStatus): String {\n        if (useEmojis) {\n            return when (status) {\n                CommandStatus.COMPLETED -> \"✅ \"\n                CommandStatus.FAILED -> \"❌ \"\n                CommandStatus.RUNNING -> \"⏳ \"\n                CommandStatus.PENDING -> \"\\uD83D\\uDD32 \" // 🔲\n                CommandStatus.WARNED -> \"⚠️ \"\n                CommandStatus.SKIPPED -> \"⚪️ \"\n            }\n        } else {\n            return when (status) {\n                CommandStatus.COMPLETED -> \"+ \"\n                CommandStatus.FAILED -> \"X \"\n                CommandStatus.RUNNING -> \"> \"\n                CommandStatus.PENDING -> \"  \"\n                CommandStatus.WARNED -> \"! \"\n                CommandStatus.SKIPPED -> \"- \"\n            }\n        }\n    }\n\n    data class Frame(val timestamp: Long, val content: String)\n}\n\n// Helper launcher to play around with presentation\nfun main() {\n    val view = AnsiResultView(\"> Press [ENTER] to restart the Flow\\n\")\n\n    view.setState(\n        UiState.Running(\n            flowName = \"Flow for playing around\",\n            device = Device.Connected(\n                instanceId = \"device\",\n                deviceSpec = DeviceSpec.fromRequest(\n                    DeviceSpecRequest.Android()\n                ),\n                description = \"description\",\n                platform = Platform.ANDROID,\n                deviceType = Device.DeviceType.EMULATOR\n            ),\n            onFlowStartCommands = listOf(),\n            onFlowCompleteCommands = listOf(),\n            commands = listOf(\n                CommandState(\n                    command = MaestroCommand(launchAppCommand = LaunchAppCommand(\"com.example.example\")),\n                    status = CommandStatus.COMPLETED,\n                    subOnStartCommands = listOf(),\n                    subOnCompleteCommands = listOf(),\n                ),\n                CommandState(\n                    command = MaestroCommand(\n                        assertWithAICommand = AssertWithAICommand(\n                            assertion = \"There are no bananas visible\",\n                            optional = true\n                        ),\n                    ),\n                    status = CommandStatus.WARNED,\n                    subOnStartCommands = listOf(),\n                    subOnCompleteCommands = listOf(),\n                    insight = Insight(\n                        message = \"\"\"\n                        |Assertion is false: There are no bananas visible\n                        |Reasoning: The screen shows a login screen and no images of bananas are present.\n                        \"\"\".trimMargin(),\n                        level = Insight.Level.WARNING,\n                    ),\n                ),\n                CommandState(\n                    command = MaestroCommand(\n                        tapOnElement = TapOnElementCommand(\n                            selector = ElementSelector(\"id\", \"login\")\n                        ),\n                    ),\n                    status = CommandStatus.SKIPPED,\n                    subOnStartCommands = listOf(),\n                    subOnCompleteCommands = listOf(),\n                ),\n                CommandState(\n                    command = MaestroCommand(\n                        tapOnElement = TapOnElementCommand(\n                            selector = ElementSelector(\"id\", \"login\"),\n                            label = \"Use JS value: \\${output.some_var}\",\n                        ),\n                    ),\n                    status = CommandStatus.RUNNING,\n                    subOnStartCommands = listOf(),\n                    subOnCompleteCommands = listOf(),\n                ),\n                CommandState(\n                    command = MaestroCommand(tapOnPointV2Command = TapOnPointV2Command(point = \"50%, 25%\")),\n                    status = CommandStatus.PENDING,\n                    subOnStartCommands = listOf(),\n                    subOnCompleteCommands = listOf(),\n                ),\n                CommandState(\n                    command = MaestroCommand(tapOnPointV2Command = TapOnPointV2Command(point = \"50%, 25%\")),\n                    status = CommandStatus.FAILED,\n                    subOnStartCommands = listOf(),\n                    subOnCompleteCommands = listOf(),\n                    insight = Insight(\"This is insight message\", Insight.Level.NONE),\n                ),\n                CommandState(\n                    command = MaestroCommand(tapOnPointV2Command = TapOnPointV2Command(point = \"50%, 25%\")),\n                    status = CommandStatus.FAILED,\n                    subOnStartCommands = listOf(),\n                    subOnCompleteCommands = listOf(),\n                    insight = Insight(\"This is an error message\", Insight.Level.INFO),\n                ),\n            )\n        )\n    )\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/runner/resultview/PlainTextResultView.kt",
    "content": "package maestro.cli.runner.resultview\n\nimport maestro.cli.runner.CommandState\nimport maestro.cli.runner.CommandStatus\nimport maestro.orchestra.CompositeCommand\nimport maestro.utils.Insight\nimport maestro.utils.chunkStringByWordCount\n\nclass PlainTextResultView: ResultView {\n\n    private val printed = mutableSetOf<String>()\n\n    private val terminalStatuses = setOf(\n        CommandStatus.COMPLETED,\n        CommandStatus.FAILED,\n        CommandStatus.SKIPPED,\n        CommandStatus.WARNED\n    )\n\n    private inline fun printOnce(key: String, block: () -> Unit) {\n        if (printed.add(key)) block()\n    }\n\n    override fun setState(state: UiState) {\n        when (state) {\n            is UiState.Running -> renderRunningState(state)\n            is UiState.Error -> renderErrorState(state)\n        }\n    }\n\n    private fun renderErrorState(state: UiState.Error) {\n        println(state.message)\n    }\n\n    private fun renderRunningState(state: UiState.Running) {\n        renderRunningStatePlainText(state)\n    }\n\n    private fun renderRunningStatePlainText(state: UiState.Running) {\n        state.device?.let {\n            printOnce(\"device\") { println(\"Running on ${state.device.description}\") }\n        }\n\n        if (state.onFlowStartCommands.isNotEmpty()) {\n            printOnce(\"onFlowStart\") { println(\"  > On Flow Start\") }\n            renderCommandsPlainText(state.onFlowStartCommands, prefix = \"onFlowStart\")\n        }\n\n        printOnce(\"flowName:${state.flowName}\") { println(\" > Flow ${state.flowName}\") }\n\n        renderCommandsPlainText(state.commands, prefix = \"main\")\n\n        if (state.onFlowCompleteCommands.isNotEmpty()) {\n            printOnce(\"onFlowComplete\") { println(\"  > On Flow Complete\") }\n            renderCommandsPlainText(state.onFlowCompleteCommands, prefix = \"onFlowComplete\")\n        }\n    }\n\n    private fun renderCommandsPlainText(commands: List<CommandState>, indent: Int = 0, prefix: String = \"\") {\n        for ((index, command) in commands.withIndex()) {\n            renderCommandPlainText(command, indent, \"$prefix:$index\")\n        }\n    }\n\n    private fun renderCommandPlainText(command: CommandState, indent: Int, key: String) {\n        val c = command.command.asCommand()\n        if (c?.visible() == false) return\n\n        val desc = c?.description() ?: \"Unknown command\"\n        val pad = \"  \".repeat(indent)\n\n        when (c) {\n            is CompositeCommand -> {\n                // Print start line once when command begins\n                if (command.status != CommandStatus.PENDING) {\n                    printOnce(\"$key:start\") { println(\"$pad$desc...\") }\n                }\n\n                // onFlowStart hooks\n                command.subOnStartCommands?.let { cmds ->\n                    printOnce(\"$key:onStart\") { println(\"$pad  > On Flow Start\") }\n                    renderCommandsPlainText(cmds, indent + 1, \"$key:subOnStart\")\n                }\n\n                // The actual sub-commands of the composite\n                command.subCommands?.let { cmds ->\n                    renderCommandsPlainText(cmds, indent + 1, \"$key:sub\")\n                }\n\n                // onFlowComplete hooks\n                command.subOnCompleteCommands?.let { cmds ->\n                    printOnce(\"$key:onComplete\") { println(\"$pad  > On Flow Complete\") }\n                    renderCommandsPlainText(cmds, indent + 1, \"$key:subOnComplete\")\n                }\n\n                // Print completion line once when it reaches a terminal status\n                if (command.status in terminalStatuses) {\n                    printOnce(\"$key:complete\") { println(\"$pad$desc... ${status(command.status)}\") }\n                }\n            }\n\n            else -> {\n                // Simple command (tapOn, assertVisible, etc.)\n                when (command.status) {\n                    CommandStatus.RUNNING -> {\n                        printOnce(\"$key:start\") { print(\"$pad$desc...\") }\n                    }\n\n                    in terminalStatuses -> {\n                        printOnce(\"$key:start\") { print(\"$pad$desc...\") }\n                        printOnce(\"$key:complete\") {\n                            println(\" ${status(command.status)}\")\n                            renderInsight(command.insight, indent + 1)\n                        }\n                    }\n\n                    else -> {}\n                }\n            }\n        }\n    }\n\n    private fun renderInsight(insight: Insight, indent: Int) {\n        if (insight.level != Insight.Level.NONE) {\n            println(\"\\n\")\n            val level = insight.level.toString().lowercase().replaceFirstChar(Char::uppercase)\n            print(\" \".repeat(indent) + level + \":\")\n            insight.message.chunkStringByWordCount(12).forEach { chunkedMessage ->\n                print(\" \".repeat(indent))\n                print(chunkedMessage)\n                print(\"\\n\")\n            }\n        }\n    }\n\n    private fun status(status: CommandStatus): String {\n        return when (status) {\n            CommandStatus.COMPLETED -> \"COMPLETED\"\n            CommandStatus.FAILED -> \"FAILED\"\n            CommandStatus.RUNNING -> \"RUNNING\"\n            CommandStatus.PENDING -> \"PENDING\"\n            CommandStatus.SKIPPED -> \"SKIPPED\"\n            CommandStatus.WARNED -> \"WARNED\"\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/runner/resultview/ResultView.kt",
    "content": "package maestro.cli.runner.resultview\n\ninterface ResultView {\n    fun setState(state: UiState)\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/runner/resultview/UiState.kt",
    "content": "package maestro.cli.runner.resultview\n\nimport maestro.device.Device\nimport maestro.cli.runner.CommandState\n\nsealed class UiState {\n\n    data class Error(val message: String) : UiState()\n\n    data class Running(\n        val flowName: String,\n        val device: Device? = null,\n        val onFlowStartCommands: List<CommandState> = emptyList(),\n        val onFlowCompleteCommands: List<CommandState> = emptyList(),\n        val commands: List<CommandState>,\n    ) : UiState()\n\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/session/MaestroSessionManager.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.cli.session\n\nimport dadb.Dadb\nimport dadb.adbserver.AdbServer\nimport ios.LocalIOSDevice\nimport ios.devicectl.DeviceControlIOSDevice\nimport device.SimctlIOSDevice\nimport ios.xctest.XCTestIOSDevice\nimport maestro.Maestro\nimport maestro.device.Device\nimport maestro.cli.device.PickDeviceInteractor\nimport maestro.cli.driver.DriverBuilder\nimport maestro.cli.driver.RealIOSDeviceDriver\nimport maestro.cli.util.PrintUtils\nimport maestro.device.Platform\nimport maestro.utils.CliInsights\nimport maestro.cli.util.ScreenReporter\nimport maestro.drivers.AndroidDriver\nimport maestro.drivers.IOSDriver\nimport maestro.orchestra.WorkspaceConfig.PlatformConfiguration\nimport maestro.orchestra.workspace.WorkspaceExecutionPlanner\nimport maestro.utils.TempFileHandler\nimport org.slf4j.LoggerFactory\nimport util.IOSDeviceType\nimport util.XCRunnerCLIUtils\nimport xcuitest.XCTestClient\nimport xcuitest.XCTestDriverClient\nimport xcuitest.installer.Context\nimport xcuitest.installer.LocalXCTestInstaller\nimport xcuitest.installer.LocalXCTestInstaller.*\nimport java.nio.file.Paths\nimport java.util.UUID\nimport java.util.concurrent.Executors\nimport java.util.concurrent.TimeUnit\nimport kotlin.concurrent.thread\nimport kotlin.io.path.pathString\n\nobject MaestroSessionManager {\n    private const val defaultHost = \"localhost\"\n    private const val defaultXctestHost = \"127.0.0.1\"\n    private const val defaultXcTestPort = 22087\n\n    private val executor = Executors.newScheduledThreadPool(1)\n    private val logger = LoggerFactory.getLogger(MaestroSessionManager::class.java)\n\n\n    fun <T> newSession(\n        host: String?,\n        port: Int?,\n        driverHostPort: Int?,\n        deviceId: String?,\n        teamId: String? = null,\n        platform: String? = null,\n        isStudio: Boolean = false,\n        isHeadless: Boolean = false,\n        screenSize: String? = null,\n        reinstallDriver: Boolean = true,\n        deviceIndex: Int? = null,\n        executionPlan: WorkspaceExecutionPlanner.ExecutionPlan? = null,\n        block: (MaestroSession) -> T,\n    ): T {\n        val selectedDevice = selectDevice(\n            host = host,\n            port = port,\n            driverHostPort = driverHostPort,\n            deviceId = deviceId,\n            teamId = teamId,\n            platform = if(!platform.isNullOrEmpty()) Platform.fromString(platform) else null,\n            deviceIndex = deviceIndex,\n        )\n        val sessionId = UUID.randomUUID().toString()\n\n        val heartbeatFuture = executor.scheduleAtFixedRate(\n            {\n                try {\n                    Thread.sleep(1000) // Add a 1-second delay here for fixing race condition\n                    SessionStore.heartbeat(sessionId, selectedDevice.platform)\n                } catch (e: Exception) {\n                    logger.error(\"Failed to record heartbeat\", e)\n                }\n            },\n            0L,\n            5L,\n            TimeUnit.SECONDS\n        )\n\n        val session = createMaestro(\n            selectedDevice = selectedDevice,\n            connectToExistingSession = if (isStudio) {\n                false\n            } else {\n                SessionStore.hasActiveSessions(\n                    sessionId,\n                    selectedDevice.platform\n                )\n            },\n            isStudio = isStudio,\n            isHeadless = isHeadless,\n            screenSize = screenSize,\n            driverHostPort = driverHostPort,\n            reinstallDriver = reinstallDriver,\n            platformConfiguration = executionPlan?.workspaceConfig?.platform\n        )\n        Runtime.getRuntime().addShutdownHook(thread(start = false) {\n            heartbeatFuture.cancel(true)\n            SessionStore.delete(sessionId, selectedDevice.platform)\n            runCatching { ScreenReporter.reportMaxDepth() }\n            if (SessionStore.activeSessions().isEmpty()) {\n                session.close()\n            }\n        })\n\n        return block(session)\n    }\n\n    private fun selectDevice(\n        host: String?,\n        port: Int?,\n        driverHostPort: Int?,\n        deviceId: String?,\n        platform: Platform? = null,\n        teamId: String? = null,\n        deviceIndex: Int? = null,\n    ): SelectedDevice {\n\n        if (deviceId == \"chromium\" || platform == Platform.WEB) {\n            return SelectedDevice(\n                platform = Platform.WEB,\n                deviceType = Device.DeviceType.BROWSER\n            )\n        }\n\n        if (host == null) {\n            val device = PickDeviceInteractor.pickDevice(deviceId, driverHostPort, platform, deviceIndex)\n\n            if (device.deviceType == Device.DeviceType.REAL && device.platform == Platform.IOS) {\n                PrintUtils.message(\"Detected connected iPhone with ${device.instanceId}!\")\n                val driverBuilder = DriverBuilder()\n                RealIOSDeviceDriver(\n                    destination = \"platform=iOS,id=${device.instanceId}\",\n                    teamId = teamId,\n                    driverBuilder = driverBuilder\n                ).validateAndUpdateDriver()\n            }\n            return SelectedDevice(\n                platform = device.platform,\n                device = device,\n                deviceType = device.deviceType\n            )\n        }\n\n        if (isAndroid(host, port)) {\n            val deviceType = when {\n                deviceId?.startsWith(\"emulator\") == true -> Device.DeviceType.EMULATOR\n                else -> Device.DeviceType.REAL\n            }\n            return SelectedDevice(\n                platform = Platform.ANDROID,\n                host = host,\n                port = port,\n                deviceId = deviceId,\n                deviceType = deviceType\n            )\n        }\n\n        return SelectedDevice(\n            platform = Platform.IOS,\n            host = null,\n            port = null,\n            deviceId = deviceId,\n            deviceType = Device.DeviceType.SIMULATOR\n        )\n    }\n\n    private fun createMaestro(\n        selectedDevice: SelectedDevice,\n        connectToExistingSession: Boolean,\n        isStudio: Boolean,\n        isHeadless: Boolean,\n        screenSize: String?,\n        reinstallDriver: Boolean,\n        driverHostPort: Int?,\n        platformConfiguration: PlatformConfiguration? = null,\n    ): MaestroSession {\n        return when {\n            selectedDevice.device != null -> MaestroSession(\n                maestro = when (selectedDevice.device.platform) {\n                    Platform.ANDROID -> createAndroid(\n                        selectedDevice.device.instanceId,\n                        !connectToExistingSession,\n                        driverHostPort,\n                        reinstallDriver,\n                    )\n\n                    Platform.IOS -> createIOS(\n                        selectedDevice.device.instanceId,\n                        !connectToExistingSession,\n                        driverHostPort,\n                        reinstallDriver,\n                        deviceType = selectedDevice.device.deviceType,\n                        platformConfiguration = platformConfiguration\n                    )\n\n                    Platform.WEB -> pickWebDevice(isStudio, isHeadless, screenSize)\n                },\n                device = selectedDevice.device,\n            )\n\n            selectedDevice.platform == Platform.ANDROID -> MaestroSession(\n                maestro = pickAndroidDevice(\n                    selectedDevice.host,\n                    selectedDevice.port,\n                    driverHostPort,\n                    !connectToExistingSession,\n                    reinstallDriver,\n                    selectedDevice.deviceId,\n                ),\n                device = null,\n            )\n\n            selectedDevice.platform == Platform.IOS -> MaestroSession(\n                maestro = pickIOSDevice(\n                    deviceId = selectedDevice.deviceId,\n                    openDriver = !connectToExistingSession,\n                    driverHostPort = driverHostPort ?: defaultXcTestPort,\n                    reinstallDriver = reinstallDriver,\n                    platformConfiguration = platformConfiguration,\n                ),\n                device = null,\n            )\n\n            selectedDevice.platform == Platform.WEB -> MaestroSession(\n                maestro = pickWebDevice(isStudio, isHeadless, screenSize),\n                device = null\n            )\n\n            else -> error(\"Unable to create Maestro session\")\n        }\n    }\n\n    private fun isAndroid(host: String?, port: Int?): Boolean {\n        return try {\n            val dadb = if (port != null) {\n                Dadb.create(host ?: defaultHost, port)\n            } else {\n                Dadb.discover(host ?: defaultHost)\n                    ?: createAdbServerDadb()\n                    ?: error(\"No android devices found.\")\n            }\n\n            dadb.close()\n\n            true\n        } catch (_: Exception) {\n            false\n        }\n    }\n\n    private fun pickAndroidDevice(\n        host: String?,\n        port: Int?,\n        driverHostPort: Int?,\n        openDriver: Boolean,\n        reinstallDriver: Boolean,\n        deviceId: String? = null,\n    ): Maestro {\n        val dadb = if (port != null) {\n            Dadb.create(host ?: defaultHost, port)\n        } else if (deviceId != null) {\n            Dadb.list(host = host ?: defaultHost).find { it.toString() == deviceId }\n                ?: error(\"No Android device found with id '$deviceId' on host '${host ?: defaultHost}'\")\n        } else {\n            Dadb.discover(host ?: defaultHost)\n                ?: createAdbServerDadb()\n                ?: error(\"No android devices found.\")\n        }\n\n        return Maestro.android(\n            driver = AndroidDriver(dadb, driverHostPort, \"\", reinstallDriver),\n            openDriver = openDriver,\n        )\n    }\n\n    private fun createAdbServerDadb(): Dadb? {\n        return try {\n            AdbServer.createDadb(adbServerPort = 5038)\n        } catch (ignored: Exception) {\n            null\n        }\n    }\n\n    private fun pickIOSDevice(\n        deviceId: String?,\n        openDriver: Boolean,\n        driverHostPort: Int,\n        reinstallDriver: Boolean,\n        platformConfiguration: PlatformConfiguration?,\n    ): Maestro {\n        val device = PickDeviceInteractor.pickDevice(deviceId, driverHostPort)\n        return createIOS(\n            device.instanceId,\n            openDriver,\n            driverHostPort,\n            reinstallDriver,\n            deviceType = device.deviceType,\n            platformConfiguration = platformConfiguration\n        )\n    }\n\n    private fun createAndroid(\n        instanceId: String,\n        openDriver: Boolean,\n        driverHostPort: Int?,\n        reinstallDriver: Boolean,\n    ): Maestro {\n        val driver = AndroidDriver(\n            dadb = Dadb\n                .list()\n                .find { it.toString() == instanceId }\n                ?: Dadb.discover()\n                ?: error(\"Unable to find device with id $instanceId\"),\n            hostPort = driverHostPort,\n            emulatorName = instanceId,\n            reinstallDriver = reinstallDriver,\n        )\n\n        return Maestro.android(\n            driver = driver,\n            openDriver = openDriver,\n        )\n    }\n\n    private fun createIOS(\n        deviceId: String,\n        openDriver: Boolean,\n        driverHostPort: Int?,\n        reinstallDriver: Boolean,\n        platformConfiguration: PlatformConfiguration?,\n        deviceType: Device.DeviceType,\n    ): Maestro {\n\n        val iOSDeviceType = when (deviceType) {\n            Device.DeviceType.REAL -> IOSDeviceType.REAL\n            Device.DeviceType.SIMULATOR -> IOSDeviceType.SIMULATOR\n            else -> {\n                throw UnsupportedOperationException(\"Unsupported device type $deviceType for iOS platform\")\n            }\n        }\n        val iOSDriverConfig = when (deviceType) {\n            Device.DeviceType.REAL -> {\n                val maestroDirectory = Paths.get(System.getProperty(\"user.home\"), \".maestro\")\n                val driverPath = maestroDirectory.resolve(\"maestro-iphoneos-driver-build\").resolve(\"driver-iphoneos\")\n                    .resolve(\"Build\").resolve(\"Products\")\n                IOSDriverConfig(\n                    prebuiltRunner = false,\n                    sourceDirectory = driverPath.pathString,\n                    context = Context.CLI,\n                    snapshotKeyHonorModalViews = platformConfiguration?.ios?.snapshotKeyHonorModalViews\n                )\n            }\n            Device.DeviceType.SIMULATOR -> {\n                IOSDriverConfig(\n                    prebuiltRunner = false,\n                    sourceDirectory =  \"driver-iPhoneSimulator\",\n                    context = Context.CLI,\n                    snapshotKeyHonorModalViews = platformConfiguration?.ios?.snapshotKeyHonorModalViews\n                )\n            }\n             else -> throw UnsupportedOperationException(\"Unsupported device type $deviceType for iOS platform\")\n        }\n\n        val tempFileHandler = TempFileHandler()\n        val deviceController = when (deviceType) {\n            Device.DeviceType.REAL -> {\n                val device = util.LocalIOSDevice().listDeviceViaDeviceCtl(deviceId)\n                val deviceCtlDevice = DeviceControlIOSDevice(deviceId = device.identifier)\n                deviceCtlDevice\n            }\n            Device.DeviceType.SIMULATOR -> {\n                val simctlIOSDevice = SimctlIOSDevice(\n                    deviceId = deviceId,\n                    tempFileHandler = tempFileHandler\n                )\n                simctlIOSDevice\n            }\n            else -> throw UnsupportedOperationException(\"Unsupported device type $deviceType for iOS platform\")\n        }\n\n        val xcTestInstaller = LocalXCTestInstaller(\n            deviceId = deviceId,\n            host = defaultXctestHost,\n            defaultPort = driverHostPort ?: defaultXcTestPort,\n            reinstallDriver = reinstallDriver,\n            deviceType = iOSDeviceType,\n            iOSDriverConfig = iOSDriverConfig,\n            deviceController = deviceController,\n            tempFileHandler = tempFileHandler\n        )\n\n        val xcTestDriverClient = XCTestDriverClient(\n            installer = xcTestInstaller,\n            client = XCTestClient(defaultXctestHost, driverHostPort ?: defaultXcTestPort),\n            reinstallDriver = reinstallDriver,\n        )\n\n        val xcRunnerCLIUtils = XCRunnerCLIUtils(tempFileHandler = tempFileHandler)\n        val xcTestDevice = XCTestIOSDevice(\n            deviceId = deviceId,\n            client = xcTestDriverClient,\n            getInstalledApps = { xcRunnerCLIUtils.listApps(deviceId) },\n        )\n\n        val iosDriver = IOSDriver(\n            LocalIOSDevice(\n                deviceId = deviceId,\n                xcTestDevice = xcTestDevice,\n                deviceController = deviceController,\n                insights = CliInsights\n            ),\n            insights = CliInsights\n        )\n\n        return Maestro.ios(\n            driver = iosDriver,\n            openDriver = openDriver || xcTestDevice.isShutdown(),\n        )\n    }\n\n    private fun pickWebDevice(isStudio: Boolean, isHeadless: Boolean, screenSize: String?): Maestro {\n        return Maestro.web(isStudio, isHeadless, screenSize)\n    }\n\n    private data class SelectedDevice(\n        val platform: Platform,\n        val device: Device.Connected? = null,\n        val host: String? = null,\n        val port: Int? = null,\n        val deviceId: String? = null,\n        val deviceType: Device.DeviceType,\n    )\n\n    data class MaestroSession(\n        val maestro: Maestro,\n        val device: Device? = null,\n    ) {\n\n        fun close() {\n            maestro.close()\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/session/SessionStore.kt",
    "content": "package maestro.cli.session\n\nimport maestro.cli.db.KeyValueStore\nimport maestro.device.Platform\nimport java.nio.file.Paths\nimport java.util.concurrent.TimeUnit\n\nobject SessionStore {\n\n    private val keyValueStore by lazy {\n        KeyValueStore(\n            dbFile = Paths\n                .get(System.getProperty(\"user.home\"), \".maestro\", \"sessions\")\n                .toFile()\n                .also { it.parentFile.mkdirs() }\n        )\n    }\n\n    fun heartbeat(sessionId: String, platform: Platform) {\n        synchronized(keyValueStore) {\n            keyValueStore.set(\n                key = key(sessionId, platform),\n                value = System.currentTimeMillis().toString(),\n            )\n\n            pruneInactiveSessions()\n        }\n    }\n\n    private fun pruneInactiveSessions() {\n        keyValueStore.keys()\n            .forEach { key ->\n                val lastHeartbeat = keyValueStore.get(key)?.toLongOrNull()\n                if (lastHeartbeat != null && System.currentTimeMillis() - lastHeartbeat >= TimeUnit.SECONDS.toMillis(21)) {\n                    keyValueStore.delete(key)\n                }\n            }\n    }\n\n    fun delete(sessionId: String, platform: Platform) {\n        synchronized(keyValueStore) {\n            keyValueStore.delete(\n                key(sessionId, platform)\n            )\n        }\n    }\n\n    fun activeSessions(): List<String> {\n        synchronized(keyValueStore) {\n            return keyValueStore\n                .keys()\n                .filter { key ->\n                    val lastHeartbeat = keyValueStore.get(key)?.toLongOrNull()\n                    lastHeartbeat != null && System.currentTimeMillis() - lastHeartbeat < TimeUnit.SECONDS.toMillis(21)\n                }\n        }\n    }\n\n    fun hasActiveSessions(\n        sessionId: String,\n        platform: Platform\n    ): Boolean {\n        synchronized(keyValueStore) {\n            return activeSessions()\n                .any { it != key(sessionId, platform) }\n        }\n    }\n\n    private fun key(sessionId: String, platform: Platform): String {\n        return \"${platform}_$sessionId\"\n    }\n\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/update/Updates.kt",
    "content": "package maestro.cli.update\n\nimport maestro.cli.api.ApiClient\nimport maestro.cli.api.CliVersion\nimport maestro.cli.util.EnvUtils\nimport maestro.cli.util.EnvUtils.CLI_VERSION\nimport java.util.concurrent.CompletableFuture\nimport java.util.concurrent.Executors\nimport java.util.concurrent.TimeUnit\nimport maestro.cli.util.ChangeLogUtils\nimport maestro.cli.util.ChangeLog\n\nobject Updates {\n    private val DEFAULT_THREAD_FACTORY = Executors.defaultThreadFactory()\n    private val EXECUTOR = Executors.newCachedThreadPool {\n        DEFAULT_THREAD_FACTORY.newThread(it).apply { isDaemon = true }\n    }\n\n    private var future: CompletableFuture<CliVersion?>? = null\n    private var changelogFuture: CompletableFuture<List<String>>? = null\n\n    fun fetchUpdatesAsync() {\n        getFuture()\n    }\n\n    fun fetchChangelogAsync() {\n        getChangelogFuture()\n    }\n\n    fun checkForUpdates(): CliVersion? {\n        // Disable update check, when MAESTRO_DISABLE_UPDATE_CHECK is set to \"true\" e.g. when installed by a package manager. e.g. nix\n        if (System.getenv(\"MAESTRO_DISABLE_UPDATE_CHECK\")?.toBoolean() == true) {\n            return null\n        }\n        return try {\n            getFuture().get(3, TimeUnit.SECONDS)\n        } catch (e: Exception) {\n            return null\n        }\n    }\n\n    fun getChangelog(): List<String>? {\n        // Disable update check, when MAESTRO_DISABLE_UPDATE_CHECK is set to \"true\" e.g. when installed by a package manager. e.g. nix\n        if (System.getenv(\"MAESTRO_DISABLE_UPDATE_CHECK\")?.toBoolean() == true) {\n            return null\n        }\n        return try {\n            getChangelogFuture().get(3, TimeUnit.SECONDS)\n        } catch (e: Exception) {\n            return null\n        }\n    }\n\n    private fun fetchUpdates(): CliVersion? {\n        if (CLI_VERSION == null) {\n            return null\n        }\n\n        val latestCliVersion = ApiClient(EnvUtils.BASE_API_URL).getLatestCliVersion()\n\n        return if (latestCliVersion > CLI_VERSION) {\n            latestCliVersion\n        } else {\n            null\n        }\n    }\n\n    private fun fetchChangelog(): ChangeLog {\n        if (CLI_VERSION == null) {\n            return null\n        }\n        val version = fetchUpdates()?.toString() ?: return null\n        val content = ChangeLogUtils.fetchContent()\n        return ChangeLogUtils.formatBody(content, version)\n    }\n\n    @Synchronized\n    private fun getFuture(): CompletableFuture<CliVersion?> {\n        var future = this.future\n        if (future == null) {\n            future = CompletableFuture.supplyAsync(this::fetchUpdates, EXECUTOR)!!\n            this.future = future\n        }\n        return future\n    }\n\n    @Synchronized\n    private fun getChangelogFuture(): CompletableFuture<List<String>> {\n        var changelogFuture = this.changelogFuture\n        if (changelogFuture == null) {\n            changelogFuture = CompletableFuture.supplyAsync(this::fetchChangelog, EXECUTOR)!!\n            this.changelogFuture = changelogFuture\n        }\n        return changelogFuture\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/util/ChangeLogUtils.kt",
    "content": "package maestro.cli.util\n\nimport maestro.cli.util.EnvUtils.CLI_VERSION\nimport maestro.utils.HttpClient\nimport okhttp3.Request\nimport java.io.File\n\ntypealias ChangeLog = List<String>?\n\nobject ChangeLogUtils {\n\n    fun formatBody(content: String?, version: String): ChangeLog = content\n        ?.split(\"\\n## \")?.map { it.lines() }\n        ?.firstOrNull { it.firstOrNull()?.startsWith(version) == true }\n        ?.drop(1)\n        ?.map { it.trim().replace(\"**\", \"\") }\n        ?.map { it.replace(\"\\\\[(.*?)]\\\\(.*?\\\\)\".toRegex(), \"$1\") }\n        ?.filter { it.isNotEmpty() && it.startsWith(\"- \") }\n\n    fun fetchContent(): String? {\n        val request = Request.Builder()\n            .url(\"https://raw.githubusercontent.com/mobile-dev-inc/maestro/main/CHANGELOG.md\")\n            .build()\n        return HttpClient.build(\"ChangeLogUtils\").newCall(request).execute().body?.string()\n    }\n\n    fun print(changelog: ChangeLog): String =\n        changelog?.let { \"\\n${it.joinToString(\"\\n\")}\\n\" }.orEmpty()\n}\n\n// Helper launcher to play around with presentation\nfun main() {\n    val changelogFile = File(System.getProperty(\"user.dir\"), \"CHANGELOG.md\")\n    val content = changelogFile.readText()\n    val unreleased = ChangeLogUtils.formatBody(content, \"Unreleased\")\n    val current = ChangeLogUtils.formatBody(content, CLI_VERSION.toString())\n    val changelog = unreleased ?: current\n    println(\"## ${unreleased?.let { \"Unreleased\" } ?: CLI_VERSION.toString()}\")\n    println(\"-\".repeat(100))\n    println(ChangeLogUtils.print(changelog))\n    println(\"-\".repeat(100))\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/util/CiUtils.kt",
    "content": "package maestro.cli.util\n\nobject CiUtils {\n\n    // When adding a new CI, also add the first version of Maestro that supports it.\n    private val ciEnvVarMap = mapOf(\n        \"APPVEYOR\" to \"appveyor\", // since v1.37.4\n        \"BITBUCKET_BUILD_NUMBER\" to \"bitbucket\",\n        \"BITRISE_IO\" to \"bitrise\",\n        \"BUILDKITE\" to \"buildkite\", // since v1.37.4\n        \"CIRCLECI\" to \"circleci\",\n        \"CIRRUS_CI\" to \"cirrusci\", // since v1.37.4\n        \"DRONE\" to \"drone\", // since v1.37.4\n        \"GITHUB_ACTIONS\" to \"github\",\n        \"GITLAB_CI\" to \"gitlab\",\n        \"JENKINS_HOME\" to \"jenkins\",\n        \"TEAMCITY_VERSION\" to \"teamcity\", // since v1.37.4\n        \"CI\" to \"ci\"\n    )\n\n    private fun isTruthy(envVar: String?): Boolean {\n        if (envVar == null) return false\n        return envVar != \"0\" && envVar != \"false\"\n    }\n\n    fun getCiProvider(): String? {\n        val mdevCiEnvVar = System.getenv(\"MDEV_CI\")\n        if (isTruthy(mdevCiEnvVar)) {\n            return mdevCiEnvVar\n        }\n\n        for (ciEnvVar in ciEnvVarMap.entries) {\n            try {\n                if (isTruthy(System.getenv(ciEnvVar.key).lowercase())) return ciEnvVar.value\n            } catch (e: Exception) {\n                // We don't care\n            }\n        }\n\n        return null\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/util/DependencyResolver.kt",
    "content": "package maestro.cli.util\n\nimport maestro.orchestra.ApplyConfigurationCommand\nimport maestro.orchestra.CompositeCommand\nimport maestro.orchestra.MaestroCommand\nimport maestro.orchestra.yaml.MaestroFlowParser\nimport java.nio.file.Files\nimport java.nio.file.Path\nimport java.nio.file.LinkOption\nimport kotlin.io.path.exists\n\nobject DependencyResolver {\n\n    fun discoverAllDependencies(flowFile: Path): List<Path> {\n        val discoveredFiles = mutableSetOf<Path>()\n        val filesToProcess = mutableListOf(normalizePath(flowFile))\n        \n        while (filesToProcess.isNotEmpty()) {\n            val currentFile = normalizePath(filesToProcess.removeFirst())\n            \n            // Skip if we've already processed this file (prevents circular references)\n            if (discoveredFiles.contains(currentFile)) continue\n            \n            // Add current file to discovered set\n            discoveredFiles.add(currentFile)\n            \n            try {\n                // Only process YAML files for dependency discovery\n                if (!isYamlFile(currentFile)) {\n                    continue\n                }\n                \n                val flowContent = Files.readString(currentFile)\n                val commands = MaestroFlowParser.parseFlow(currentFile, flowContent)\n                \n                // Discover dependencies from each command\n                val dependencies = commands.flatMap { maestroCommand ->\n                    extractDependenciesFromCommand(maestroCommand, currentFile)\n                }\n                \n                val newDependencies = dependencies\n                    .map { normalizePath(it) }\n                    .filter { it.exists() && !discoveredFiles.contains(it) }\n                filesToProcess.addAll(newDependencies)\n                \n            } catch (e: Exception) {\n                // Ignore\n            }\n        }\n        \n        return discoveredFiles.toList()\n    }\n    \n    private fun extractDependenciesFromCommand(maestroCommand: MaestroCommand, currentFile: Path): List<Path> {\n        val commandDependencies = mutableListOf<Path>()\n\n        // Check for runFlow commands and add the dependency if it exists (sourceDescription is not null)\n        maestroCommand.runFlowCommand?.let { runFlow ->\n            resolveDependencyFile(currentFile, runFlow.sourceDescription)?.let { commandDependencies.add(it) }\n        }\n        \n        // Check for runScript commands and add the dependency if it exists (sourceDescription is not null)\n        maestroCommand.runScriptCommand?.let { runScript ->\n            resolveDependencyFile(currentFile, runScript.sourceDescription)?.let { commandDependencies.add(it) }\n        }\n        \n        // Check for retry commands and add the dependency if it exists (sourceDescription is not null)\n        maestroCommand.retryCommand?.let { retry ->\n            resolveDependencyFile(currentFile, retry.sourceDescription)?.let { commandDependencies.add(it) }\n        }\n        \n        // Check for assertScreenshot commands and add the reference image as a dependency\n        maestroCommand.assertScreenshotCommand?.let { assertScreenshot ->\n            resolveDependencyFile(currentFile, assertScreenshot.path)?.let { commandDependencies.add(it) }\n        }\n\n        // Check for addMedia commands and add the dependency if it exists (mediaPaths is not null)\n        maestroCommand.addMediaCommand?.let { addMedia ->\n            addMedia.mediaPaths.forEach { mediaPath ->\n                resolveDependencyFile(currentFile, mediaPath)?.let { commandDependencies.add(it) }\n            }\n        }\n        \n        // Handle configuration commands (onFlowStart, onFlowComplete)\n        maestroCommand.applyConfigurationCommand?.let { config ->\n            config.config.onFlowStart?.commands?.forEach { startCommand ->\n                commandDependencies.addAll(extractDependenciesFromCommand(startCommand, currentFile))\n            }\n            config.config.onFlowComplete?.commands?.forEach { completeCommand ->\n                commandDependencies.addAll(extractDependenciesFromCommand(completeCommand, currentFile))\n            }\n        }\n        \n        // Handle ALL composite commands to extract dependencies from nested commands (RunFlow, Repeat, Retry)\n        val command = maestroCommand.asCommand()\n        if (command is CompositeCommand) {\n            command.subCommands().forEach { nestedCommand ->\n                commandDependencies.addAll(extractDependenciesFromCommand(nestedCommand, currentFile))\n            }\n        }\n        \n        return commandDependencies\n    }\n    \n    private fun resolvePath(flowPath: Path, requestedPath: String): Path {\n        val path = flowPath.fileSystem.getPath(requestedPath)\n        \n        return if (path.isAbsolute) {\n            path\n        } else {\n            flowPath.resolveSibling(path).toAbsolutePath()\n        }\n    }\n    \n    private fun resolveDependencyFile(currentFile: Path, requestedPath: String?): Path? {\n        val trimmed = requestedPath?.trim()\n        if (trimmed.isNullOrEmpty()) return null\n        val resolved = resolvePath(currentFile, trimmed)\n        return if (resolved.exists() && !Files.isDirectory(resolved)) resolved else null\n    }\n\n    private fun isYamlFile(path: Path): Boolean {\n        val filename = path.fileName.toString().lowercase()\n        return filename.endsWith(\".yaml\") || filename.endsWith(\".yml\")\n    }\n\n    private fun isJsFile(path: Path): Boolean {\n        val filename = path.fileName.toString().lowercase()\n        return filename.endsWith(\".js\")\n    }\n\n    fun getDependencySummary(flowFile: Path): String {\n        val dependencies = discoverAllDependencies(flowFile)\n        val mainFile = dependencies.firstOrNull { it == flowFile }\n        val subflows = dependencies.filter { it != flowFile && isYamlFile(it) }\n        val scripts = dependencies.filter { it != flowFile && isJsFile(it) }\n        val otherFiles = dependencies.filter { it != flowFile && !isYamlFile(it) && !isJsFile(it) }\n        \n        return buildString {\n            appendLine(\"Dependency discovery for: ${flowFile.fileName}\")\n            appendLine(\"Total files: ${dependencies.size}\")\n            if (subflows.isNotEmpty()) appendLine(\"Subflows: ${subflows.size}\")\n            if (scripts.isNotEmpty()) appendLine(\"Scripts: ${scripts.size}\")\n            if (otherFiles.isNotEmpty()) appendLine(\"Other files: ${otherFiles.size}\")\n            \n            if (subflows.isNotEmpty()) {\n                appendLine(\"Subflow files:\")\n                subflows.forEach { appendLine(\"  - ${it.fileName}\") }\n            }\n            if (scripts.isNotEmpty()) {\n                appendLine(\"Script files:\")\n                scripts.forEach { appendLine(\"  - ${it.fileName}\") }\n            }\n            if (otherFiles.isNotEmpty()) {\n                appendLine(\"Other files:\")\n                otherFiles.forEach { appendLine(\"  - ${it.fileName}\") }\n            }\n        }\n    }\n\n    private fun normalizePath(path: Path): Path {\n        return try {\n            // Prefer canonical path without following symlinks\n            path.toRealPath(LinkOption.NOFOLLOW_LINKS)\n        } catch (e: Exception) {\n            // Fall back to absolute normalized path if real path resolution fails\n            path.toAbsolutePath().normalize()\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/util/EnvUtils.kt",
    "content": "package maestro.cli.util\n\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport com.fasterxml.jackson.module.kotlin.readValue\nimport maestro.cli.api.CliVersion\nimport maestro.cli.update.Updates\nimport maestro.cli.view.red\nimport maestro.device.CPU_ARCHITECTURE\nimport java.io.File\nimport java.io.IOException\nimport java.nio.file.Path\nimport java.nio.file.Paths\nimport java.util.Properties\nimport java.util.concurrent.TimeUnit\nimport java.util.concurrent.TimeoutException\n\nobject EnvUtils {\n    private const val PROD_API_URL = \"https://api.copilot.mobile.dev\"\n\n    val OS_NAME: String = System.getProperty(\"os.name\")\n    val OS_ARCH: String = System.getProperty(\"os.arch\")\n    val OS_VERSION: String = System.getProperty(\"os.version\")\n\n    val CLI_VERSION: CliVersion? = getCLIVersion()\n\n    fun getVersion(): CliVersion? {\n        return getCLIVersion().apply {\n            if (this == null) {\n                System.err.println(\"\\nWarning: Failed to parse current version\".red())\n            }\n        }\n    }\n\n    val BASE_API_URL: String\n        get() = System.getenv(\"MAESTRO_API_URL\") ?: PROD_API_URL\n\n    /**\n     * Where Maestro config and state files were located before v1.37.0.\n     */\n    fun legacyMaestroHome(): Path {\n        return Paths.get(System.getProperty(\"user.home\"), \".maestro\")\n    }\n\n    fun xdgStateHome(): Path {\n        if (System.getenv(\"XDG_STATE_HOME\") != null) {\n            return Paths.get(System.getenv(\"XDG_STATE_HOME\"), \"maestro\")\n        }\n\n        return Paths.get(System.getProperty(\"user.home\"), \".maestro\")\n    }\n\n    /**\n     * @return true, if we're executing from Windows Linux shell (WSL)\n     */\n    fun isWSL(): Boolean {\n        try {\n            val p1 = ProcessBuilder(\"printenv\", \"WSL_DISTRO_NAME\").start()\n            if (!p1.waitFor(20, TimeUnit.SECONDS)) throw TimeoutException()\n            if (p1.exitValue() == 0 && String(p1.inputStream.readBytes()).trim().isNotEmpty()) {\n                return true\n            }\n\n            val p2 = ProcessBuilder(\"printenv\", \"IS_WSL\").start()\n            if (!p2.waitFor(20, TimeUnit.SECONDS)) throw TimeoutException()\n            if (p2.exitValue() == 0 && String(p2.inputStream.readBytes()).trim().isNotEmpty()) {\n                return true\n            }\n        } catch (ignore: Exception) {\n            // ignore\n        }\n\n        return false\n    }\n\n    fun isWindows(): Boolean {\n        return OS_NAME.lowercase().startsWith(\"windows\")\n    }\n\n    /**\n     * Returns major version of Java, e.g. 8, 11, 17, 21.\n     */\n    fun getJavaVersion(): Int {\n        // Adapted from https://stackoverflow.com/a/2591122/7009800\n        val version = System.getProperty(\"java.version\")\n        return if (version.startsWith(\"1.\")) {\n            version.substring(2, 3).toInt()\n        } else {\n            val dot = version.indexOf(\".\")\n            if (dot != -1) version.substring(0, dot).toInt() else 0\n        }\n    }\n\n    fun getFlutterVersionAndChannel(): Pair<String?, String?> {\n        val stdout = try {\n             runProcess(\n                \"flutter\",\n                \"--no-version-check\", \"--version\", \"--machine\",\n            ).joinToString(separator = \"\")\n        } catch (e: IOException) {\n            // Flutter is probably not installed\n            return Pair(first = null, second = null)\n        }\n\n        val mapper = jacksonObjectMapper()\n        val version = runCatching {\n            val obj: Map<String, String> = mapper.readValue(stdout)\n            obj[\"flutterVersion\"].toString()\n        }\n        val channel = runCatching {\n            val obj: Map<String, String> = mapper.readValue(stdout)\n            obj[\"channel\"].toString()\n        }\n\n        return Pair(first = version.getOrNull(), second = channel.getOrNull())\n    }\n\n    fun getMacOSArchitecture(): CPU_ARCHITECTURE {\n        return determineArchitectureDetectionStrategy().detectArchitecture()\n    }\n\n    private fun determineArchitectureDetectionStrategy(): ArchitectureDetectionStrategy {\n        return if (isWindows()) {\n            ArchitectureDetectionStrategy.WindowsArchitectureDetection\n        } else if (runProcess(\"uname\").contains(\"Linux\")) {\n            ArchitectureDetectionStrategy.LinuxArchitectureDetection\n        } else {\n            ArchitectureDetectionStrategy.MacOsArchitectureDetection\n        }\n    }\n\n    fun getCLIVersion(): CliVersion? {\n        val props = try {\n            Updates::class.java.classLoader.getResourceAsStream(\"version.properties\").use {\n                Properties().apply { load(it) }\n            }\n        } catch (e: Exception) {\n            return null\n        }\n\n        val versionString = props[\"version\"] as? String ?: return null\n\n        return CliVersion.parse(versionString)\n    }\n}\n\nsealed interface ArchitectureDetectionStrategy {\n\n    fun detectArchitecture(): CPU_ARCHITECTURE\n\n    object MacOsArchitectureDetection : ArchitectureDetectionStrategy {\n        override fun detectArchitecture(): CPU_ARCHITECTURE {\n            fun runSysctl(property: String) = runProcess(\"sysctl\", property).any { it.endsWith(\": 1\") }\n\n            // Prefer sysctl over 'uname -m' due to Rosetta making it unreliable\n            val isArm64 = runSysctl(\"hw.optional.arm64\")\n            val isX86_64 = runSysctl(\"hw.optional.x86_64\")\n            return when {\n                isArm64 -> CPU_ARCHITECTURE.ARM64\n                isX86_64 -> CPU_ARCHITECTURE.X86_64\n                else -> CPU_ARCHITECTURE.UNKNOWN\n            }\n        }\n    }\n\n    object LinuxArchitectureDetection : ArchitectureDetectionStrategy {\n        override fun detectArchitecture(): CPU_ARCHITECTURE {\n            return when (runProcess(\"uname\", \"-m\").first()) {\n              \"x86_64\" -> CPU_ARCHITECTURE.X86_64\n              \"arm64\" -> CPU_ARCHITECTURE.ARM64\n              else -> CPU_ARCHITECTURE.UNKNOWN\n            }\n        }\n    }\n\n    object WindowsArchitectureDetection: ArchitectureDetectionStrategy {\n        override fun detectArchitecture(): CPU_ARCHITECTURE {\n            return CPU_ARCHITECTURE.X86_64\n        }\n    }\n}\n\ninternal fun runProcess(program: String, vararg arguments: String): List<String> {\n    val process = ProcessBuilder(program, *arguments).start()\n    return try {\n        process.inputStream.reader().use { it.readLines().map(String::trim) }\n    } catch (ignore: Exception) {\n        emptyList()\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/util/ErrorReporter.kt",
    "content": "package maestro.cli.util\n\nimport maestro.cli.api.ApiClient\nimport picocli.CommandLine\nimport java.security.MessageDigest\nimport java.util.concurrent.Executors\nimport java.util.concurrent.TimeUnit\nimport kotlin.Exception\n\nobject ErrorReporter {\n\n    private val executor = Executors.newCachedThreadPool {\n        Executors.defaultThreadFactory().newThread(it).apply { isDaemon = true }\n    }\n\n    fun report(exception: Exception, parseResult: CommandLine.ParseResult) {\n        val args = parseResult.expandedArgs()\n        val scrubbedArgs = args.mapIndexed { idx, arg ->\n            if (idx > 0 && args[idx - 1] in listOf(\"-e\", \"--env\")) {\n                val (key, value) = arg.split(\"=\", limit = 1)\n                key + \"=\" + hashString(value)\n            } else arg\n        }\n\n        val task = executor.submit {\n            ApiClient(EnvUtils.BASE_API_URL).sendErrorReport(\n                exception,\n                scrubbedArgs.joinToString(\" \")\n            )\n        }\n\n        runCatching { task.get(1, TimeUnit.SECONDS) }\n    }\n\n    private fun hashString(input: String): String {\n        return MessageDigest\n            .getInstance(\"SHA-256\")\n            .digest(input.toByteArray())\n            .fold(StringBuilder()) { sb, it ->\n                sb.append(\"%02x\".format(it))\n            }.toString()\n    }\n\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/util/FileDownloader.kt",
    "content": "package maestro.cli.util\n\nimport io.ktor.client.HttpClient\nimport io.ktor.client.engine.cio.CIO\nimport io.ktor.client.request.request\nimport io.ktor.client.request.url\nimport io.ktor.client.statement.bodyAsChannel\nimport io.ktor.http.HttpMethod\nimport io.ktor.http.contentLength\nimport io.ktor.http.isSuccess\nimport io.ktor.utils.io.*\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.flow\nimport java.io.File\n\nobject FileDownloader {\n\n    fun downloadFile(\n        url: String,\n        destination: File\n    ): Flow<DownloadResult> {\n        val httpClient = HttpClient(CIO)\n        return flow {\n            with(httpClient) {\n                val response = request {\n                    url(url)\n                    method = HttpMethod.Get\n                }.call.response\n\n                val contentLength = response.contentLength()\n                    ?: error(\"Content length is null\")\n\n                val data = ByteArray(contentLength.toInt())\n\n                val bodyChannel = response.bodyAsChannel()\n\n                var offset = 0\n                do {\n                    val currentRead = bodyChannel\n                        .readAvailable(data, offset, data.size)\n\n                    offset += currentRead\n                    val progress = offset / data.size.toFloat()\n                    emit(DownloadResult.Progress(progress))\n                } while (currentRead > 0)\n\n                if (response.status.isSuccess()) {\n                    destination.writeBytes(data)\n                    emit(DownloadResult.Success)\n                } else {\n                    emit(DownloadResult.Error(\"Network error. Response code: ${response.status}\"))\n                }\n            }\n        }\n    }\n\n    sealed class DownloadResult {\n\n        object Success : DownloadResult()\n\n        data class Error(val message: String, val cause: Exception? = null) : DownloadResult()\n\n        data class Progress(\n            val progress: Float\n        ) : DownloadResult()\n    }\n\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/util/FileUtils.kt",
    "content": "package maestro.cli.util\n\nimport maestro.orchestra.yaml.YamlCommandReader\nimport maestro.utils.StringUtils.toRegexSafe\nimport java.io.File\nimport java.util.zip.ZipInputStream\n\nobject FileUtils {\n\n    fun File.isZip(): Boolean {\n        return try {\n            ZipInputStream(inputStream()).close()\n            true\n        } catch (ignored: Exception) {\n            false\n        }\n    }\n\n    fun File.isWebFlow(): Boolean {\n        if (isDirectory) {\n            return listFiles()\n                ?.any { it.isWebFlow() }\n                ?: false\n        }\n\n        val isYaml =\n            name.endsWith(\".yaml\", ignoreCase = true) ||\n            name.endsWith(\".yml\", ignoreCase = true)\n\n        if (\n            !isYaml ||\n            name.equals(\"config.yaml\", ignoreCase = true) ||\n            name.equals(\"config.yml\", ignoreCase = true)\n        ) {\n            return false\n        }\n\n        val config = YamlCommandReader.readConfig(toPath())\n        return config.url != null\n    }\n\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/util/IOSEnvUtils.kt",
    "content": "package maestro.cli.util\n\nimport java.io.IOException\nimport kotlin.io.path.Path\n\nobject IOSEnvUtils {\n\n    val simulatorRuntimes: List<String>\n        get() {\n            // See also: https://stackoverflow.com/a/78755176/7009800\n\n            val topLevelDirs = Path(\"/Library/Developer/CoreSimulator/Volumes\").toFile()\n                .listFiles()\n                ?.filter { it.exists() } ?: emptyList()\n\n            val installedRuntimes = topLevelDirs\n                .map { it.resolve(\"Library/Developer/CoreSimulator/Profiles/Runtimes\") }\n                .map { it.listFiles() ?: emptyArray() }\n                .reduceOrNull { acc, list -> acc + list }\n                ?.map { file -> file.nameWithoutExtension } ?: emptyList()\n\n            return installedRuntimes\n        }\n\n    val xcodeVersion: String?\n        get() {\n            val lines = try {\n                runProcess(\"xcodebuild\", \"-version\")\n            } catch (e: IOException) {\n                // Xcode toolchain is probably not installed\n                return null\n            }\n\n            if (lines.size == 2 && lines.first().contains(' ')) {\n                // Correct xcodebuild invocation is always 2 lines. Example:\n                //   $ xcodebuild -version\n                //   Xcode 15.4\n                //   Build version 15F31d\n                return lines.first().split(' ')[1]\n            }\n\n            return null\n        }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/util/PrintUtils.kt",
    "content": "package maestro.cli.util\n\nimport org.fusesource.jansi.Ansi\nimport java.io.IOException\nimport kotlin.system.exitProcess\n\nobject PrintUtils {\n\n    fun info(message: String, bold: Boolean = false, newline: Boolean = true) {\n        val function: (Any) -> Unit = if (newline) ::println else ::print\n        function(\n            Ansi.ansi()\n                .bold(apply = bold)\n                .render(message)\n                .boldOff()\n        )\n    }\n\n    fun message(message: String) {\n        println(Ansi.ansi().render(\"@|cyan \\n$message|@\"))\n    }\n\n    fun prompt(message: String): String {\n        print(Ansi.ansi().render(\"\\n@|yellow,bold $message\\n>|@\"))\n        try {\n            return readln().trim()\n        } catch (e: IOException) {\n            exitProcess(1)\n        }\n    }\n\n    fun success(message: String, bold: Boolean = false) {\n        println(\n            Ansi.ansi()\n                .render(\"\\n\")\n                .fgBrightGreen()\n                .bold(apply = bold)\n                .render(message)\n                .boldOff()\n                .fgDefault()\n        )\n    }\n\n    fun err(message: String, bold: Boolean = false) {\n        println(\n            Ansi.ansi()\n                .render(\"\\n\")\n                .fgRed()\n                .bold(apply = bold)\n                .render(message)\n                .boldOff()\n                .fgDefault()\n        )\n    }\n\n    fun warn(message: String, bold: Boolean = false) {\n        println(\n            Ansi.ansi()\n                .render(\"\\n\")\n                .fgYellow()\n                .bold(apply = bold)\n                .render(message)\n                .boldOff()\n                .fgDefault()\n        )\n    }\n\n    fun Ansi.bold(apply: Boolean = true): Ansi {\n        return if (apply) {\n            bold()\n        } else {\n            this\n        }\n    }\n\n    fun clearConsole() {\n        print(\"\\u001b[H\\u001b[2J\")\n        System.out.flush()\n    }\n\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/util/ResourceUtils.kt",
    "content": "import kotlin.reflect.KClass\n\nfun readResourceAsText(cls: KClass<*>, path: String): String {\n    val resourceStream = cls::class.java.getResourceAsStream(path)\n        ?: throw IllegalStateException(\"Could not find $path in resources\")\n\n    return resourceStream.bufferedReader().use { it.readText() }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/util/ScreenReporter.kt",
    "content": "package maestro.cli.util\n\nimport maestro.cli.api.ApiClient\nimport maestro.utils.DepthTracker\n\nobject ScreenReporter {\n\n    fun reportMaxDepth() {\n        val maxDepth = DepthTracker.getMaxDepth()\n\n        if (maxDepth == 0) return\n\n        ApiClient(EnvUtils.BASE_API_URL).sendScreenReport(maxDepth = maxDepth)\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/util/ScreenshotUtils.kt",
    "content": "package maestro.cli.util\n\nimport java.io.File\nimport maestro.Maestro\nimport maestro.cli.report.FlowDebugOutput\nimport maestro.cli.runner.CommandStatus\nimport okio.Buffer\nimport okio.sink\n\nobject ScreenshotUtils {\n\n    fun takeDebugScreenshot(maestro: Maestro, debugOutput: FlowDebugOutput, status: CommandStatus): File? {\n        val containsFailed = debugOutput.screenshots.any { it.status == CommandStatus.FAILED }\n\n        // Avoids duplicate failed images from parent commands\n        if (containsFailed && status == CommandStatus.FAILED) {\n            return null\n        }\n\n        val result = kotlin.runCatching {\n            val out = File\n                .createTempFile(\"screenshot-${System.currentTimeMillis()}\", \".png\")\n                .also { it.deleteOnExit() } // save to another dir before exiting\n            maestro.takeScreenshot(out.sink(), false)\n            debugOutput.screenshots.add(\n                FlowDebugOutput.Screenshot(\n                    screenshot = out,\n                    timestamp = System.currentTimeMillis(),\n                    status = status\n                )\n            )\n            out\n        }\n\n        return result.getOrNull()\n    }\n\n    fun takeDebugScreenshotByCommand(maestro: Maestro, debugOutput: FlowDebugOutput, status: CommandStatus): File? {\n        val result = kotlin.runCatching {\n            val out = File\n                .createTempFile(\"screenshot-${status}-${System.currentTimeMillis()}\", \".png\")\n                .also { it.deleteOnExit() } // save to another dir before exiting\n            maestro.takeScreenshot(out.sink(), false)\n            debugOutput.screenshots.add(\n                FlowDebugOutput.Screenshot(\n                    screenshot = out,\n                    timestamp = System.currentTimeMillis(),\n                    status = status\n                )\n            )\n            out\n        }\n\n        return result.getOrNull()\n    }\n\n    fun writeAIscreenshot(buffer: Buffer): File {\n        val out = File\n            .createTempFile(\"ai-screenshot-${System.currentTimeMillis()}\", \".png\")\n            .also { it.deleteOnExit() }\n        out.outputStream().use { it.write(buffer.readByteArray()) }\n        return out\n    }\n\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/util/SocketUtils.kt",
    "content": "package maestro.cli.util\n\nimport java.net.ServerSocket\n\nfun getFreePort(): Int {\n    (9999..11000).forEach { port ->\n        try {\n            ServerSocket(port).use { return it.localPort }\n        } catch (ignore: Exception) {}\n    }\n    ServerSocket(0).use { return it.localPort }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/util/TimeUtils.kt",
    "content": "package maestro.cli.util\n\nimport kotlin.math.roundToLong\nimport kotlin.time.Duration\nimport kotlin.time.Duration.Companion.seconds\n\nobject TimeUtils {\n\n    fun durationInSeconds(startTimeInMillis: Long?, endTimeInMillis: Long?): Duration {\n        if (startTimeInMillis == null || endTimeInMillis == null) return Duration.ZERO\n        return ((endTimeInMillis - startTimeInMillis) / 1000f).roundToLong().seconds\n    }\n\n    fun durationInSeconds(durationInMillis: Long): Duration {\n        return ((durationInMillis) / 1000f).roundToLong().seconds\n    }\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/util/Unpacker.kt",
    "content": "package maestro.cli.util\n\nimport org.apache.commons.codec.digest.DigestUtils\nimport java.io.File\nimport java.net.URL\nimport java.nio.file.FileSystems\nimport java.nio.file.Files\nimport java.nio.file.Paths\nimport java.nio.file.attribute.PosixFilePermission\n\n/**\n * Unpacks files from jar resources.\n */\nobject Unpacker {\n\n    fun unpack(\n        jarPath: String,\n        target: File,\n    ) {\n        Unpacker::class.java.classLoader.getResource(jarPath)?.let { resource ->\n            if (target.exists()) {\n                if (sameContents(resource, target)) {\n                    return\n                }\n            }\n\n            target.writeBytes(resource.readBytes())\n        }\n    }\n\n    private fun sameContents(resource: URL, target: File): Boolean {\n        return DigestUtils.sha1Hex(resource.openStream()) == DigestUtils.sha1Hex(target.inputStream())\n    }\n\n    fun binaryDependency(name: String): File {\n        return Paths\n            .get(\n                System.getProperty(\"user.home\"),\n                \".maestro\",\n                \"deps\",\n                name\n            )\n            .toAbsolutePath()\n            .toFile()\n            .also { file ->\n                createParentDirectories(file)\n                createFileIfDoesNotExist(file)\n                grantBinaryPermissions(file)\n            }\n    }\n\n    private fun createParentDirectories(file: File) {\n        file.parentFile?.let { parent ->\n            if (!parent.exists()) {\n                parent.mkdirs()\n            }\n        }\n    }\n\n    private fun createFileIfDoesNotExist(file: File) {\n        if (!file.exists()) {\n            if (!file.createNewFile()) {\n                error(\"Unable to create file $file\")\n            }\n        }\n    }\n\n    private fun grantBinaryPermissions(file: File) {\n        if (isPosixFilesystem()) {\n            Files.setPosixFilePermissions(\n                file.toPath(),\n                setOf(\n                    PosixFilePermission.OWNER_EXECUTE,\n                    PosixFilePermission.OWNER_READ,\n                    PosixFilePermission.OWNER_WRITE,\n                )\n            )\n        }\n    }\n\n    private fun isPosixFilesystem() = FileSystems.getDefault()\n        .supportedFileAttributeViews()\n        .contains(\"posix\")\n\n}"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/util/WorkingDirectory.kt",
    "content": "package maestro.cli.util\n\nimport java.io.File\n\nobject WorkingDirectory {\n    var baseDir: File = File(System.getProperty(\"user.dir\"))\n\n    fun resolve(path: String): File = File(baseDir, path)\n    fun resolve(file: File): File = if (file.isAbsolute) file else File(baseDir, file.path)\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/util/WorkspaceUtils.kt",
    "content": "package maestro.cli.util\n\nimport java.io.FileNotFoundException\nimport java.net.URI\nimport java.nio.file.FileSystems\nimport java.nio.file.Files\nimport java.nio.file.Path\nimport kotlin.io.path.absolutePathString\nimport kotlin.io.path.copyTo\nimport kotlin.io.path.exists\nimport kotlin.io.path.isDirectory\nimport kotlin.streams.toList\n\nobject WorkspaceUtils {\n\n    fun createWorkspaceZip(file: Path, out: Path) {\n        if (!file.exists()) throw FileNotFoundException(file.absolutePathString())\n        if (out.exists()) throw FileAlreadyExistsException(out.toFile())\n        \n        val filesToInclude = if (!file.isDirectory()) {\n            DependencyResolver.discoverAllDependencies(file)\n        } else {\n            Files.walk(file).filter { !it.isDirectory() }.toList()\n        }\n        val relativeTo = if (file.isDirectory()) file else file.parent\n        createWorkspaceZipFromFiles(filesToInclude, relativeTo, out)\n    }\n    \n    fun createWorkspaceZipFromFiles(files: List<Path>, relativeTo: Path, out: Path) {\n        if (out.exists()) throw FileAlreadyExistsException(out.toFile())\n        \n        val outUri = URI.create(\"jar:${out.toUri()}\")\n        FileSystems.newFileSystem(outUri, mapOf(\"create\" to \"true\")).use { fs ->\n            files.forEach {\n                val outPath = fs.getPath(relativeTo.relativize(it).toString())\n                if (outPath.parent != null) {\n                    Files.createDirectories(outPath.parent)\n                }\n                it.copyTo(outPath)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/view/ErrorViewUtils.kt",
    "content": "package maestro.cli.view\n\nimport maestro.MaestroException\nimport maestro.orchestra.error.InvalidFlowFile\nimport maestro.orchestra.error.NoInputException\nimport maestro.orchestra.error.UnicodeNotSupportedError\nimport maestro.orchestra.error.ValidationError\nimport org.mozilla.javascript.EcmaError\n\nobject ErrorViewUtils {\n\n    fun exceptionToMessage(e: Exception): String {\n        return when (e) {\n            is ValidationError -> e.message\n            is NoInputException -> \"No commands found in Flow file\"\n            is InvalidFlowFile -> \"Flow file is invalid: ${e.flowPath}\"\n            is UnicodeNotSupportedError -> \"Unicode character input is not supported: ${e.text}. Please use ASCII characters. Follow the issue: https://github.com/mobile-dev-inc/maestro/issues/146\"\n            is InterruptedException -> \"Interrupted\"\n            is MaestroException -> e.message\n            is EcmaError -> \"${e.name}: ${e.message}}\"\n            else -> e.stackTraceToString()\n        }\n    }\n\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/view/ProgressBar.kt",
    "content": "package maestro.cli.view\n\nimport maestro.cli.DisableAnsiMixin\nimport org.fusesource.jansi.Ansi\n\nclass ProgressBar(private val width: Int) {\n\n    private var progressWidth: Int? = null\n    private var alreadyPrinted = 0\n\n    fun set(progress: Float) {\n        if (DisableAnsiMixin.ansiEnabled) {\n            val progressWidth = (progress * width).toInt()\n            if (progressWidth == this.progressWidth) return\n            this.progressWidth = progressWidth\n            val ansi = Ansi.ansi()\n            ansi.cursorToColumn(0)\n            ansi.fgCyan()\n            repeat(progressWidth) { ansi.a(\"█\") }\n            repeat(width - progressWidth) { ansi.a(\"░\") }\n            ansi.fgDefault()\n            System.err.print(ansi)\n        } else {\n            val progressFactor = (progress * width).toInt()\n            var amountToAdd = progressFactor - alreadyPrinted\n            if (amountToAdd < 0) amountToAdd = 0\n            alreadyPrinted = progressFactor\n            print(\".\".repeat(amountToAdd))\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/view/TestSuiteStatusView.kt",
    "content": "package maestro.cli.view\n\nimport maestro.cli.api.UploadStatus\nimport maestro.cli.model.FlowStatus\nimport maestro.cli.util.PrintUtils\nimport maestro.cli.view.TestSuiteStatusView.TestSuiteViewModel.FlowResult\nimport maestro.cli.view.TestSuiteStatusView.uploadUrl\nimport org.fusesource.jansi.Ansi\nimport java.util.UUID\nimport kotlin.time.Duration\nimport kotlin.time.Duration.Companion.milliseconds\nimport kotlin.time.Duration.Companion.seconds\n\nobject TestSuiteStatusView {\n\n    fun showFlowCompletion(result: FlowResult) {\n        val shardPrefix = result.shardIndex?.let { \"[shard ${it + 1}] \" }.orEmpty()\n        print(Ansi.ansi().fgCyan().render(shardPrefix).fgDefault())\n\n        printStatus(result.status, result.cancellationReason)\n\n        val durationString = result.duration?.let { \" ($it)\" }.orEmpty()\n        print(\" ${result.name}$durationString\")\n\n        if (result.status == FlowStatus.ERROR && result.error != null) {\n            val error = \" (${result.error})\"\n            print(Ansi.ansi().fgRed().render(error).fgDefault())\n        }\n        else if (result.status == FlowStatus.WARNING) {\n            val warning = \" (Warning)\"\n            print(Ansi.ansi().fgYellow().render(warning).fgDefault())\n        }\n        println()\n    }\n\n    fun showSuiteResult(\n        suite: TestSuiteViewModel,\n        uploadUrl: String,\n    ) {\n        val hasError = suite.flows.find { it.status == FlowStatus.ERROR } != null\n        val canceledFlows = suite.flows\n            .filter { it.status == FlowStatus.CANCELED }\n        val shardPrefix = suite.shardIndex?.let { \"[shard ${it + 1}] \" }.orEmpty()\n\n        if (suite.status == FlowStatus.ERROR || hasError) {\n            val failedFlows = suite.flows\n                .filter { it.status == FlowStatus.ERROR }\n\n            PrintUtils.err(\n                \"${shardPrefix}${failedFlows.size}/${suite.flows.size} ${flowWord(failedFlows.size)} Failed\",\n                bold = true,\n            )\n\n            if (canceledFlows.isNotEmpty()) {\n                PrintUtils.warn(\"${shardPrefix}${canceledFlows.size} ${flowWord(canceledFlows.size)} Canceled\")\n            }\n        } else {\n            val passedFlows = suite.flows\n                .filter { it.status == FlowStatus.SUCCESS || it.status == FlowStatus.WARNING }\n\n\n            if (passedFlows.isNotEmpty()) {\n                val durationMessage = suite.duration?.let { \" in $it\" } ?: \"\"\n                PrintUtils.success(\n                    \"${shardPrefix}${passedFlows.size}/${suite.flows.size} ${flowWord(passedFlows.size)} Passed$durationMessage\",\n                    bold = true,\n                )\n\n                if (canceledFlows.isNotEmpty()) {\n                    PrintUtils.warn(\"${shardPrefix}${canceledFlows.size} ${flowWord(canceledFlows.size)} Canceled\")\n                }\n            } else {\n                println()\n                PrintUtils.err(\"${shardPrefix}All flows were canceled\")\n            }\n        }\n        println()\n\n        if (suite.uploadDetails != null) {\n            PrintUtils.info(\"==== View Details on Maestro Cloud ====\")\n            PrintUtils.info(uploadUrl.cyan())\n            println()\n        }\n    }\n\n    private fun printStatus(status: FlowStatus, cancellationReason: UploadStatus.CancellationReason?) {\n        val color = when (status) {\n            FlowStatus.SUCCESS,\n            FlowStatus.WARNING -> Ansi.Color.GREEN\n            FlowStatus.ERROR -> Ansi.Color.RED\n            FlowStatus.STOPPED -> Ansi.Color.RED\n            else -> Ansi.Color.DEFAULT\n        }\n        val title = when (status) {\n            FlowStatus.SUCCESS,\n            FlowStatus.WARNING -> \"Passed\"\n            FlowStatus.ERROR -> \"Failed\"\n            FlowStatus.PENDING -> \"Pending\"\n            FlowStatus.RUNNING -> \"Running\"\n            FlowStatus.STOPPED -> \"Stopped\"\n            FlowStatus.PREPARING -> \"Preparing Device\"\n            FlowStatus.INSTALLING -> \"Installing App\"\n            FlowStatus.CANCELED -> when (cancellationReason) {\n                UploadStatus.CancellationReason.TIMEOUT -> \"Timeout\"\n                UploadStatus.CancellationReason.OVERLAPPING_BENCHMARK -> \"Skipped\"\n                UploadStatus.CancellationReason.BENCHMARK_DEPENDENCY_FAILED -> \"Skipped\"\n                UploadStatus.CancellationReason.CANCELED_BY_USER -> \"Canceled by user\"\n                UploadStatus.CancellationReason.RUN_EXPIRED -> \"Run expired\"\n                else -> \"Canceled (unknown reason)\"\n            }\n        }\n\n        print(\n            Ansi.ansi()\n                .fgBright(color)\n                .render(\"[$title]\")\n                .fgDefault()\n        )\n    }\n\n    fun uploadUrl(\n        projectId: String,\n        appId: String,\n        uploadId: String,\n        domain: String = \"\"\n    ): String {\n        return if (domain.contains(\"localhost\")) {\n            \"http://localhost:3000/project/$projectId/maestro-test/app/$appId/upload/$uploadId\"\n        } else {\n            \"https://app.maestro.dev/project/$projectId/maestro-test/app/$appId/upload/$uploadId\"\n        }\n    }\n\n    private fun flowWord(count: Int) = if (count == 1) \"Flow\" else \"Flows\"\n\n    data class TestSuiteViewModel(\n        val status: FlowStatus,\n        val flows: List<FlowResult>,\n        val duration: Duration? = null,\n        val shardIndex: Int? = null,\n        val uploadDetails: UploadDetails? = null,\n    ) {\n\n        data class FlowResult(\n            val name: String,\n            val status: FlowStatus,\n            val duration: Duration? = null,\n            val error: String? = null,\n            val shardIndex: Int? = null,\n            val cancellationReason: UploadStatus.CancellationReason? = null\n        )\n\n        data class UploadDetails(\n            val uploadId: String,\n            val appId: String,\n            val domain: String,\n        )\n\n        companion object {\n\n            fun UploadStatus.toViewModel(\n                uploadDetails: UploadDetails\n            ) = TestSuiteViewModel(\n                uploadDetails = uploadDetails,\n                status = FlowStatus.from(status),\n                flows = flows.map {\n                    it.toViewModel()\n                }\n            )\n\n            fun UploadStatus.FlowResult.toViewModel() = FlowResult(\n                name = name,\n                status = status,\n                error = errors.firstOrNull(),\n                cancellationReason = cancellationReason,\n                duration = totalTime?.milliseconds\n            )\n        }\n\n    }\n\n}\n\n// Helper launcher to play around with presentation\nfun main() {\n    val uploadDetails = TestSuiteStatusView.TestSuiteViewModel.UploadDetails(\n        uploadId = UUID.randomUUID().toString(),\n        appId = \"appid\",\n        domain = \"mobile.dev\",\n    )\n    val status = TestSuiteStatusView.TestSuiteViewModel(\n        uploadDetails = uploadDetails,\n        status = FlowStatus.CANCELED,\n        flows = listOf(\n            FlowResult(\n                name = \"A\",\n                status = FlowStatus.SUCCESS,\n                duration = 42.seconds,\n            ),\n            FlowResult(\n                name = \"B\",\n                status = FlowStatus.SUCCESS,\n                duration = 231.seconds,\n            ),\n            FlowResult(\n                name = \"C\",\n                status = FlowStatus.CANCELED,\n            )\n        ),\n        duration = 273.seconds,\n    )\n\n    status.flows\n        .forEach {\n            TestSuiteStatusView.showFlowCompletion(it)\n        }\n\n    val uploadUrl = uploadUrl(\n        uploadDetails.uploadId.toString(),\n        \"teamid\",\n        uploadDetails.appId,\n        uploadDetails.domain,\n    )\n    TestSuiteStatusView.showSuiteResult(status, uploadUrl)\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/view/ViewUtils.kt",
    "content": "package maestro.cli.view\n\nimport org.fusesource.jansi.Ansi\n\nfun String.magenta(): String {\n    return \"@|magenta $this|@\".render()\n}\n\nfun String.red(): String {\n    return \"@|red $this|@\".render()\n}\n\nfun String.brightRed(): String {\n    return \"\\u001B[91m$this\\u001B[0m\"\n}\n\nfun String.green(): String {\n    return \"@|green $this|@\".render()\n}\n\nfun String.blue(): String {\n    return \"@|blue $this|@\".render()\n}\n\nfun String.bold(): String {\n    return \"@|bold $this|@\".render()\n}\n\nfun String.yellow(): String {\n    return \"@|yellow $this|@\".render()\n}\n\nfun String.cyan(): String {\n    return \"@|cyan $this|@\".render()\n}\n\nfun String.faint(): String {\n    return \"@|faint $this|@\".render()\n}\n\nfun String.box(): String {\n    return boxWithColor { it.magenta() }\n}\n\nfun String.greenBox(): String {\n    return boxWithColor { it.green() }\n}\n\nprivate fun String.boxWithColor(colorize: (String) -> String): String {\n    val lines = this.lines()\n\n    val messageWidth = lines.map { it.replace(Regex(\"\\u001B\\\\[[\\\\d;]*[^\\\\d;]\"),\"\") }.maxOf { it.length }\n    val paddingX = 3\n    val paddingY = 1\n    val width = messageWidth + paddingX * 2\n\n    val tl = colorize(\"╭\")\n    val tr = colorize(\"╮\")\n    val bl = colorize(\"╰\")\n    val br = colorize(\"╯\")\n    val hl = colorize(\"─\")\n    val vl = colorize(\"│\")\n\n    val py = \"$vl${\" \".repeat(width)}$vl\\n\".repeat(paddingY)\n    val px = \" \".repeat(paddingX)\n    val l = \"$vl$px\"\n    val r = \"$px$vl\"\n\n    val sb = StringBuilder()\n    sb.appendLine(\"$tl${hl.repeat(width)}$tr\")\n    sb.append(py)\n    lines.forEach { line ->\n        sb.appendLine(\"$l${padRight(line, messageWidth)}$r\")\n    }\n    sb.append(py)\n    sb.appendLine(\"$bl${hl.repeat(width)}$br\")\n\n    return sb.toString()\n}\n\nfun String.render(): String {\n    return Ansi.ansi().render(this).toString()\n}\n\nprivate fun padRight(s: String, width: Int): String {\n    // Strip ANSI escape sequences to compute the visible width\n    val visible = s.replace(Regex(\"\\u001B\\\\[[\\\\d;]*[^\\\\d;]\"), \"\")\n    val pad = (width - visible.length).coerceAtLeast(0)\n    return s + \" \".repeat(pad)\n}\n"
  },
  {
    "path": "maestro-cli/src/main/java/maestro/cli/web/WebInteractor.kt",
    "content": "package maestro.cli.web\n\nimport maestro.cli.util.FileUtils.isWebFlow\nimport maestro.orchestra.yaml.YamlCommandReader\nimport java.io.File\n\nobject WebInteractor {\n\n    fun createManifestFromWorkspace(workspaceFile: File): File? {\n        val appId = inferAppId(workspaceFile) ?: return null\n\n        val manifest = \"\"\"\n            {\n                \"url\": \"$appId\"\n            }\n        \"\"\".trimIndent()\n\n        val manifestFile = File.createTempFile(\"manifest\", \".json\")\n        manifestFile.writeText(manifest)\n        return manifestFile\n    }\n\n    private fun inferAppId(file: File): String? {\n        if (file.isDirectory) {\n            return file.listFiles()\n                ?.firstNotNullOfOrNull { inferAppId(it) }\n        }\n\n        if (!file.isWebFlow()) {\n            return null\n        }\n\n        return file.readText()\n            .let { YamlCommandReader.readConfig(file.toPath()) }\n            .appId\n    }\n\n}"
  },
  {
    "path": "maestro-cli/src/main/resources/ai_report.css",
    "content": "@layer components {\n    body {\n        @apply dark:bg-gray-dark dark:text-gray-1 text-gray-dark;\n    }\n\n    .screenshot-image {\n        @apply w-64 rounded-lg border-2 border-gray-medium dark:border-gray-1 pb-1;\n    }\n\n    .screen-card {\n        @apply flex items-start gap-4;\n    }\n\n    .defect-card {\n        @apply flex flex-col items-start gap-2 rounded-lg bg-[#f8f8f8] p-2 text-gray-dark dark:bg-gray-medium dark:text-gray-1;\n    }\n\n    .badge {\n        @apply dark:text-red-500 rounded-lg bg-[#ececec] dark:bg-gray-dark p-1 font-semibold text-gray-medium dark:text-gray-1;\n    }\n\n    .toggle-link {\n        @apply block border-2 border-gray-medium bg-[#ececec] px-3 py-4 text-gray-medium hover:bg-gray-medium hover:text-[#ececec];\n    }\n\n    .toggle-link-selected {\n        @apply border-orange-2;\n    }\n\n    .divider {\n        @apply h-0.5 rounded-sm bg-gray-medium dark:bg-gray-1 my-2;\n    }\n\n    .btn {\n        @apply hover:text-gray-medium dark:hover:text-gray-medium;\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/main/resources/html-detailed.css",
    "content": ".step-item {\n    border-left: 3px solid #dee2e6;\n    padding-left: 12px;\n    padding-top: 8px;\n    padding-bottom: 8px;\n}\n\n.step-header {\n    font-family: monospace;\n    font-size: 14px;\n}\n\n.step-name {\n    font-weight: 500;\n}\n"
  },
  {
    "path": "maestro-cli/src/main/resources/logback-test.xml",
    "content": "<configuration>\n    <appender name=\"CONSOLE\" class=\"ch.qos.logback.core.ConsoleAppender\">\n        <encoder>\n            <pattern>[%-5level] %logger{36} - %msg%n</pattern>\n        </encoder>\n    </appender>\n\n    <logger name=\"io.micrometer.common.util.internal.logging\" level=\"WARN\" />\n\n\n    <logger name=\"CONSOLE\" level=\"INFO\">\n        <appender-ref ref=\"CONSOLE\" />\n    </logger>\n\n    <root level=\"INFO\">\n        <appender-ref ref=\"CONSOLE\" />\n    </root>\n</configuration>"
  },
  {
    "path": "maestro-cli/src/main/resources/tailwind.config.js",
    "content": "tailwind.config = {\n    darkMode: \"media\",\n    theme: {\n        extend: {\n            colors: {\n                \"gray-dark\": \"#110c22\", // text-gray-dark\n                \"gray-medium\": \"#4f4b5c\", // text-gray-medium\n                \"gray-1\": \"#f8f8f8\", // surface-gray-1\n                \"gray-0\": \"#110C22\", // surface-gray-0\n                \"orange-2\": \"#ff9254\", // surface-orange-2\n            },\n        },\n    },\n};\n"
  },
  {
    "path": "maestro-cli/src/test/kotlin/maestro/cli/android/AndroidDeviceProvider.kt",
    "content": "package maestro.cli.android\n\nimport dadb.Dadb\nimport dadb.adbserver.AdbServer\n\nclass AndroidDeviceProvider {\n\n    fun local(): Dadb {\n        val dadb = AdbServer.createDadb(connectTimeout = 60_000, socketTimeout = 60_000)\n\n        return dadb\n    }\n}"
  },
  {
    "path": "maestro-cli/src/test/kotlin/maestro/cli/cloud/CloudInteractorTest.kt",
    "content": "package maestro.cli.cloud\n\nimport com.google.common.truth.Truth.assertThat\nimport io.mockk.*\nimport maestro.cli.CliError\nimport maestro.cli.api.ApiClient\nimport maestro.cli.api.AppBinaryInfo\nimport maestro.cli.api.DeviceConfiguration\nimport maestro.cli.api.UploadResponse\nimport maestro.cli.api.UploadStatus\nimport maestro.cli.auth.Auth\nimport maestro.cli.model.FlowStatus\nimport maestro.cli.report.ReportFormat\nimport maestro.orchestra.validation.AppMetadataAnalyzer\nimport maestro.orchestra.validation.WorkspaceValidator\nimport org.junit.jupiter.api.BeforeEach\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.AfterEach\nimport org.junit.jupiter.api.assertThrows\nimport org.junit.jupiter.api.io.TempDir\nimport java.io.ByteArrayOutputStream\nimport java.io.File\nimport java.io.PrintStream\nimport java.util.concurrent.TimeUnit\n\nclass CloudInteractorTest {\n\n    private lateinit var mockApiClient: ApiClient\n    private lateinit var mockAuth: Auth\n\n    private lateinit var originalOut: PrintStream\n    private lateinit var outputStream: ByteArrayOutputStream\n\n    @TempDir\n    lateinit var tempDir: File\n\n    @BeforeEach\n    fun setUp() {\n        mockApiClient = mockk(relaxed = true)\n        mockAuth = mockk(relaxed = true)\n        every { mockAuth.getAuthToken(any(), any()) } returns \"test-token\"\n        every { mockApiClient.getProjects(any()) } returns listOf(\n            maestro.cli.api.ProjectResponse(id = \"proj_1\", name = \"Test Project\")\n        )\n        every { mockApiClient.listCloudDevices() } returns mapOf(\n            \"android\" to mapOf(\"pixel_6\" to listOf(\"android-34\", \"android-33\", \"android-31\", \"android-30\", \"android-29\")),\n            \"ios\" to mapOf(\n                \"iPhone-11\" to listOf(\"iOS-16-2\", \"iOS-17-5\", \"iOS-18-2\"),\n                \"iPhone-14\" to listOf(\"iOS-16-2\", \"iOS-17-5\", \"iOS-18-2\"),\n            ),\n            \"web\" to mapOf(\"chromium\" to listOf(\"default\")),\n        )\n\n        // Capture console output\n        originalOut = System.out\n        outputStream = ByteArrayOutputStream()\n        System.setOut(PrintStream(outputStream))\n    }\n\n    @AfterEach\n    fun tearDown() {\n        System.setOut(originalOut)\n    }\n\n    // ---- Fixtures from test resources ----\n\n    private fun resourceFile(path: String): File =\n        File(javaClass.getResource(path)!!.toURI())\n\n    private fun androidFlowFile(): File = resourceFile(\"/workspaces/cloud_test/android/flow.yaml\")\n    private fun iosFlowFile(): File = resourceFile(\"/workspaces/cloud_test/ios/flow.yaml\")\n    private fun webFlowFile(): File = resourceFile(\"/workspaces/cloud_test/web/flow.yaml\")\n    private fun taggedFlowDir(): File = resourceFile(\"/workspaces/cloud_test/tagged\")\n    private fun iosApp(): File = resourceFile(\"/apps/test-ios.zip\")\n    private fun webManifest(): File = resourceFile(\"/apps/web-manifest.json\")\n\n    /** Creates a flow file with a custom appId in tempDir (for mismatch / error tests). */\n    private fun createFlowFile(appId: String): File {\n        return File(tempDir, \"flow.yaml\").also {\n            it.writeText(\"appId: $appId\\n---\\n- launchApp\\n\")\n        }\n    }\n\n    private fun stubUploadResponse(\n        platform: String = \"Android\",\n        appBinaryId: String? = null,\n    ) {\n        every {\n            mockApiClient.upload(\n                authToken = any(), appFile = any(), workspaceZip = any(),\n                uploadName = any(), mappingFile = any(), repoOwner = any(),\n                repoName = any(), branch = any(), commitSha = any(),\n                pullRequestId = any(), env = any(), appBinaryId = any(), includeTags = any(),\n                excludeTags = any(), disableNotifications = any(),\n                deviceLocale = any(), progressListener = any(),\n                projectId = any(), deviceModel = any(), deviceOs = any(),\n                androidApiLevel = any(), iOSVersion = any(),\n            )\n        } returns UploadResponse(\n            orgId = \"org_1\",\n            uploadId = \"upload_1\",\n            appId = \"app_1\",\n            deviceConfiguration = DeviceConfiguration(\n                platform = platform,\n                deviceName = \"Test Device\",\n                orientation = \"portrait\",\n                osVersion = \"33\",\n                displayInfo = \"Test Device\",\n                deviceLocale = \"en_US\",\n            ),\n            appBinaryId = appBinaryId,\n        )\n\n        // Stub the upload status for async=true (not polled)\n        every { mockApiClient.uploadStatus(any(), any(), any()) } returns UploadStatus(\n            uploadId = \"upload_1\",\n            status = UploadStatus.Status.SUCCESS,\n            completed = true,\n            totalTime = 30L,\n            startTime = 0L,\n            flows = emptyList(),\n            appPackageId = null,\n            wasAppLaunched = false,\n        )\n    }\n\n    private fun createCloudInteractor(\n        webManifestProvider: (() -> File?)? = null,\n    ): CloudInteractor {\n        return CloudInteractor(\n            client = mockApiClient,\n            appFileValidator = { AppMetadataAnalyzer.validateAppFile(it) },\n            workspaceValidator = WorkspaceValidator(),\n            webManifestProvider = webManifestProvider,\n            auth = mockAuth,\n            waitTimeoutMs = TimeUnit.SECONDS.toMillis(1),\n            minPollIntervalMs = TimeUnit.MILLISECONDS.toMillis(10),\n            maxPollingRetries = 2,\n            failOnTimeout = true,\n        )\n    }\n\n    // ---- 1. iOS .app + matching workspace (happy path) ----\n\n    @Test\n    fun `upload with iOS app file and matching workspace succeeds`() {\n        stubUploadResponse(platform = \"IOS\")\n\n        val result = createCloudInteractor().upload(\n            flowFile = iosFlowFile(),\n            appFile = iosApp(),\n            async = true,\n            projectId = \"proj_1\",\n        )\n\n        assertThat(result).isEqualTo(0)\n        verify { mockApiClient.upload(\n            authToken = \"test-token\",\n            appFile = any(),\n            workspaceZip = any(),\n            uploadName = any(),\n            mappingFile = any(),\n            repoOwner = any(),\n            repoName = any(),\n            branch = any(),\n            commitSha = any(),\n            pullRequestId = any(),\n            env = any(),\n            appBinaryId = isNull(),\n            includeTags = any(),\n            excludeTags = any(),\n            disableNotifications = any(),\n            deviceLocale = any(),\n            progressListener = any(),\n            projectId = \"proj_1\",\n            deviceModel = any(),\n            deviceOs = any(),\n            androidApiLevel = any(),\n            iOSVersion = any(),\n        ) }\n    }\n\n    // ---- 2. Web flow (no app file) ----\n\n    @Test\n    fun `upload with web flow and no app file succeeds`() {\n        stubUploadResponse(platform = \"WEB\")\n\n        val result = createCloudInteractor(webManifestProvider = { webManifest() }).upload(\n            flowFile = webFlowFile(),\n            appFile = null,\n            async = true,\n            projectId = \"proj_1\",\n        )\n\n        assertThat(result).isEqualTo(0)\n    }\n\n    // ---- 3. --app-binary-id Android ----\n\n    @Test\n    fun `upload with Android appBinaryId resolves platform from server`() {\n        stubUploadResponse(platform = \"Android\", appBinaryId = \"bin_android_1\")\n\n        every { mockApiClient.getAppBinaryInfo(\"test-token\", \"bin_android_1\") } returns AppBinaryInfo(\n            appBinaryId = \"bin_android_1\",\n            platform = \"Android\",\n            appId = \"com.example.maestro.orientation\",\n        )\n\n        val result = createCloudInteractor().upload(\n            flowFile = androidFlowFile(),\n            appFile = null,\n            async = true,\n            appBinaryId = \"bin_android_1\",\n            projectId = \"proj_1\",\n        )\n\n        assertThat(result).isEqualTo(0)\n        verify(exactly = 1) { mockApiClient.getAppBinaryInfo(\"test-token\", \"bin_android_1\") }\n    }\n\n    // ---- 4. --app-binary-id iOS ----\n\n    @Test\n    fun `upload with iOS appBinaryId resolves platform from server`() {\n        stubUploadResponse(platform = \"IOS\", appBinaryId = \"bin_ios_1\")\n\n        every { mockApiClient.getAppBinaryInfo(\"test-token\", \"bin_ios_1\") } returns AppBinaryInfo(\n            appBinaryId = \"bin_ios_1\",\n            platform = \"iOS\",\n            appId = \"com.example.SimpleWebViewApp\",\n        )\n\n        val result = createCloudInteractor().upload(\n            flowFile = iosFlowFile(),\n            appFile = null,\n            async = true,\n            appBinaryId = \"bin_ios_1\",\n            projectId = \"proj_1\",\n        )\n\n        assertThat(result).isEqualTo(0)\n        verify(exactly = 1) { mockApiClient.getAppBinaryInfo(\"test-token\", \"bin_ios_1\") }\n    }\n\n    // ---- 5. Missing app file + no binary ID + not web ----\n\n    @Test\n    fun `upload throws CliError when no app file, no binary id, and not web flow`() {\n        val error = assertThrows<CliError> {\n            createCloudInteractor().upload(\n                flowFile = androidFlowFile(),\n                appFile = null,\n                async = true,\n                projectId = \"proj_1\",\n            )\n        }\n\n        assertThat(error.message).contains(\"Missing required parameter\")\n    }\n\n    // ---- 6. Workspace with no matching flows ----\n\n    @Test\n    fun `upload throws CliError when workspace flows do not match app id`() {\n        // Flow has appId=com.example.SimpleWebViewApp but we tell the server the app is \"com.different.app\"\n        val flowFile = createFlowFile(\"com.nonexistent.app\")\n\n        val error = assertThrows<CliError> {\n            createCloudInteractor().upload(\n                flowFile = flowFile,\n                appFile = iosApp(),\n                async = true,\n                projectId = \"proj_1\",\n            )\n        }\n\n        assertThat(error.message).contains(\"No flows in workspace match app ID\")\n    }\n\n    // ---- 7. --app-binary-id not found (404) ----\n\n    @Test\n    fun `upload throws CliError when appBinaryId not found on server`() {\n        every { mockApiClient.getAppBinaryInfo(\"test-token\", \"nonexistent\") } throws ApiClient.ApiException(404)\n\n        val error = assertThrows<CliError> {\n            createCloudInteractor().upload(\n                flowFile = androidFlowFile(),\n                appFile = null,\n                async = true,\n                appBinaryId = \"nonexistent\",\n                projectId = \"proj_1\",\n            )\n        }\n\n        assertThat(error.message).contains(\"not found\")\n    }\n\n    // ---- 8. --app-binary-id server error ----\n\n    @Test\n    fun `upload throws CliError when server returns error for appBinaryId`() {\n        every { mockApiClient.getAppBinaryInfo(\"test-token\", \"bin_err\") } throws ApiClient.ApiException(500)\n\n        val error = assertThrows<CliError> {\n            createCloudInteractor().upload(\n                flowFile = androidFlowFile(),\n                appFile = null,\n                async = true,\n                appBinaryId = \"bin_err\",\n                projectId = \"proj_1\",\n            )\n        }\n\n        assertThat(error.message).contains(\"Failed to fetch app binary info\")\n    }\n\n    // ---- 9. --device-locale passed through ----\n\n    @Test\n    fun `upload passes device locale to api client`() {\n        stubUploadResponse(platform = \"IOS\")\n\n        createCloudInteractor().upload(\n            flowFile = iosFlowFile(),\n            appFile = iosApp(),\n            async = true,\n            deviceLocale = \"fr_FR\",\n            projectId = \"proj_1\",\n        )\n\n        verify { mockApiClient.upload(\n            authToken = any(), appFile = any(), workspaceZip = any(),\n            uploadName = any(), mappingFile = any(), repoOwner = any(),\n            repoName = any(), branch = any(), commitSha = any(),\n            pullRequestId = any(), env = any(), appBinaryId = any(), includeTags = any(),\n            excludeTags = any(), disableNotifications = any(),\n            deviceLocale = eq(\"fr_FR\"), progressListener = any(),\n            projectId = any(), deviceModel = any(), deviceOs = any(),\n            androidApiLevel = any(), iOSVersion = any(),\n        ) }\n    }\n\n    // ---- 10. --include-tags passed through ----\n\n    @Test\n    fun `upload passes include tags to workspace validation and api client`() {\n        stubUploadResponse(platform = \"IOS\")\n\n        createCloudInteractor().upload(\n            flowFile = taggedFlowDir(),\n            appFile = iosApp(),\n            async = true,\n            includeTags = listOf(\"smoke\"),\n            projectId = \"proj_1\",\n        )\n\n        verify { mockApiClient.upload(\n            authToken = any(), appFile = any(), workspaceZip = any(),\n            uploadName = any(), mappingFile = any(), repoOwner = any(),\n            repoName = any(), branch = any(), commitSha = any(),\n            pullRequestId = any(), env = any(), appBinaryId = any(),\n            includeTags = eq(listOf(\"smoke\")),\n            excludeTags = any(), disableNotifications = any(),\n            deviceLocale = any(), progressListener = any(),\n            projectId = any(), deviceModel = any(), deviceOs = any(),\n            androidApiLevel = any(), iOSVersion = any(),\n        ) }\n    }\n\n    // ---- 11. Unsupported platform from server ----\n\n    @Test\n    fun `upload throws CliError when server returns unsupported platform for appBinaryId`() {\n        every { mockApiClient.getAppBinaryInfo(\"test-token\", \"bin_symbian\") } returns AppBinaryInfo(\n            appBinaryId = \"bin_symbian\",\n            platform = \"Symbian\",\n            appId = \"com.example.app\",\n        )\n\n        val error = assertThrows<CliError> {\n            createCloudInteractor().upload(\n                flowFile = androidFlowFile(),\n                appFile = null,\n                async = true,\n                appBinaryId = \"bin_symbian\",\n                projectId = \"proj_1\",\n            )\n        }\n\n        assertThat(error.message).contains(\"Unsupported platform\")\n    }\n\n    // ---- 12. CI metadata passed through ----\n\n    @Test\n    fun `upload passes CI metadata to api client`() {\n        stubUploadResponse(platform = \"IOS\")\n\n        createCloudInteractor().upload(\n            flowFile = iosFlowFile(),\n            appFile = iosApp(),\n            async = true,\n            repoOwner = \"acme\",\n            repoName = \"app\",\n            branch = \"feature/x\",\n            commitSha = \"abc123\",\n            pullRequestId = \"42\",\n            projectId = \"proj_1\",\n        )\n\n        verify { mockApiClient.upload(\n            authToken = any(), appFile = any(), workspaceZip = any(),\n            uploadName = any(), mappingFile = any(),\n            repoOwner = eq(\"acme\"), repoName = eq(\"app\"),\n            branch = eq(\"feature/x\"), commitSha = eq(\"abc123\"),\n            pullRequestId = eq(\"42\"),\n            env = any(), appBinaryId = any(), includeTags = any(),\n            excludeTags = any(), disableNotifications = any(),\n            deviceLocale = any(), progressListener = any(),\n            projectId = any(), deviceModel = any(), deviceOs = any(),\n            androidApiLevel = any(), iOSVersion = any(),\n        ) }\n    }\n\n    // ---- 13. Env vars passed through ----\n\n    @Test\n    fun `upload passes env vars to api client`() {\n        stubUploadResponse(platform = \"IOS\")\n\n        createCloudInteractor().upload(\n            flowFile = iosFlowFile(),\n            appFile = iosApp(),\n            async = true,\n            env = mapOf(\"API_KEY\" to \"secret\"),\n            projectId = \"proj_1\",\n        )\n\n        verify { mockApiClient.upload(\n            authToken = any(), appFile = any(), workspaceZip = any(),\n            uploadName = any(), mappingFile = any(), repoOwner = any(),\n            repoName = any(), branch = any(), commitSha = any(),\n            pullRequestId = any(),\n            env = eq(mapOf(\"API_KEY\" to \"secret\")), appBinaryId = any(),\n            includeTags = any(), excludeTags = any(),\n            disableNotifications = any(), deviceLocale = any(),\n            progressListener = any(), projectId = any(),\n            deviceModel = any(), deviceOs = any(),\n            androidApiLevel = any(), iOSVersion = any(),\n        ) }\n    }\n\n    // ---- 16. Valid device config and compatible app succeeds ----\n\n    @Test\n    fun `upload with valid device config and compatible app succeeds`() {\n        stubUploadResponse(platform = \"IOS\")\n\n        val result = createCloudInteractor().upload(\n            flowFile = iosFlowFile(),\n            appFile = iosApp(),\n            async = true,\n            projectId = \"proj_1\",\n            deviceModel = \"iPhone-14\",\n            deviceOs = \"iOS-18-2\",\n        )\n\n        assertThat(result).isEqualTo(0)\n    }\n\n    // ---- waitForCompletion tests (existing) ----\n\n    @Test\n    fun `waitForCompletion should return 0 when upload completes successfully`() {\n        val uploadStatus = createUploadStatus(\n          completed = true,\n          status = UploadStatus.Status.SUCCESS,\n          startTime = 0L,\n          totalTime = 30L,\n          flows = listOf(\n            createFlowResult(\"flow1\", FlowStatus.SUCCESS, 0L, 50L),\n            createFlowResult(\"flow2\", FlowStatus.SUCCESS, 0L, 50L)\n          )\n        )\n        every { mockApiClient.uploadStatus(any(), any(), any()) } returns uploadStatus\n        val result = createCloudInteractor().waitForCompletion(\n            authToken = \"token\",\n            uploadId = \"upload123\",\n            appId = \"app123\",\n            failOnCancellation = false,\n            reportFormat = ReportFormat.NOOP,\n            reportOutput = null,\n            testSuiteName = null,\n            uploadUrl = \"http://example.com\",\n            projectId = \"project123\"\n        )\n\n        assertThat(result.status).isEqualTo(UploadStatus.Status.SUCCESS)\n        verify(exactly = 1) { mockApiClient.uploadStatus(\"token\", \"upload123\", \"project123\") }\n\n        val output = outputStream.toString()\n        val cleanOutput = output.replace(Regex(\"\\\\u001B\\\\[[;\\\\d]*m\"), \"\")\n        assertThat(cleanOutput).contains(\"[Passed] flow1 (50ms)\")\n        assertThat(cleanOutput).contains(\"[Passed] flow2 (50ms)\")\n        assertThat(cleanOutput).contains(\"2/2 Flows Passed\")\n        assertThat(cleanOutput).contains(\"Process will exit with code 0 (SUCCESS)\")\n        assertThat(cleanOutput).contains(\"http://example.com\")\n\n        val flow1Occurrences = cleanOutput.split(\"[Passed] flow1 (50ms)\").size - 1\n        val flow2Occurrences = cleanOutput.split(\"[Passed] flow2 (50ms)\").size - 1\n        assertThat(flow1Occurrences).isEqualTo(1)\n        assertThat(flow2Occurrences).isEqualTo(1)\n    }\n\n    @Test\n    fun `waitForCompletion should handle status changes and eventually complete`() {\n        val initialStatus = createUploadStatus(\n            completed = false,\n            status = UploadStatus.Status.RUNNING,\n            startTime = 0L,\n            totalTime = null,\n            flows = listOf(\n                createFlowResult(\"flow1\", FlowStatus.RUNNING, 0L, null),\n                createFlowResult(\"flow2\", FlowStatus.RUNNING, 0L, null),\n                createFlowResult(\"flow3\", FlowStatus.PENDING, 0L, null)\n            )\n        )\n\n        val intermediateStatus = createUploadStatus(\n            completed = false,\n            status = UploadStatus.Status.RUNNING,\n            startTime = 0L,\n            totalTime = null,\n            flows = listOf(\n                createFlowResult(\"flow1\", FlowStatus.SUCCESS, 0L, 45L),\n                createFlowResult(\"flow2\", FlowStatus.RUNNING, 0L, null),\n                createFlowResult(\"flow3\", FlowStatus.RUNNING, 0L, null)\n            )\n        )\n\n        val finalStatus = createUploadStatus(\n            completed = true,\n            status = UploadStatus.Status.SUCCESS,\n            startTime = 0L,\n            totalTime = 60L,\n            flows = listOf(\n                createFlowResult(\"flow1\", FlowStatus.SUCCESS, 0L, 45L),\n                createFlowResult(\"flow2\", FlowStatus.ERROR, 0L, 60L),\n                createFlowResult(\"flow3\", FlowStatus.STOPPED, 0L, null)\n            )\n        )\n\n        every { mockApiClient.uploadStatus(any(), any(), any()) } returnsMany listOf(\n            initialStatus,\n            initialStatus,\n            intermediateStatus,\n            intermediateStatus,\n            intermediateStatus,\n            finalStatus\n        )\n\n        val result = createCloudInteractor().waitForCompletion(\n            authToken = \"token\",\n            uploadId = \"upload123\",\n            appId = \"app123\",\n            failOnCancellation = false,\n            reportFormat = ReportFormat.NOOP,\n            reportOutput = null,\n            testSuiteName = null,\n            uploadUrl = \"http://example.com\",\n            projectId = \"project123\"\n        )\n\n        assertThat(result.status).isEqualTo(UploadStatus.Status.SUCCESS)\n        verify(exactly = 6) { mockApiClient.uploadStatus(\"token\", \"upload123\", \"project123\") }\n\n        val output = outputStream.toString()\n        val cleanOutput = output.replace(Regex(\"\\\\u001B\\\\[[;\\\\d]*m\"), \"\")\n        assertThat(cleanOutput).contains(\"[Passed] flow1 (45ms)\")\n        assertThat(cleanOutput).contains(\"[Failed] flow2 (60ms)\")\n        assertThat(cleanOutput).contains(\"[Stopped] flow3\")\n        assertThat(cleanOutput).contains(\"1/3 Flow Failed\")\n        assertThat(cleanOutput).contains(\"Process will exit with code 1 (FAIL)\")\n        assertThat(cleanOutput).contains(\"http://example.com\")\n\n        val flow1Occurrences = cleanOutput.split(\"[Passed] flow1 (45ms)\").size - 1\n        val flow2Occurrences = cleanOutput.split(\"[Failed] flow2 (60ms)\").size - 1\n        assertThat(flow1Occurrences).isEqualTo(1)\n        assertThat(flow2Occurrences).isEqualTo(1)\n    }\n\n    // ---- Helpers ----\n\n    private fun createUploadStatus(completed: Boolean, status: UploadStatus.Status, flows: List<UploadStatus.FlowResult>, startTime: Long?, totalTime: Long?): UploadStatus {\n        return UploadStatus(\n            uploadId = \"upload123\",\n            status = status,\n            completed = completed,\n            flows = flows,\n            totalTime = totalTime,\n            startTime = startTime,\n            appPackageId = null,\n            wasAppLaunched = false,\n        )\n    }\n\n    private fun createFlowResult(name: String, status: FlowStatus, startTime: Long = 0L, totalTime: Long?): UploadStatus.FlowResult {\n        return UploadStatus.FlowResult(\n            name = name,\n            status = status,\n            errors = emptyList(),\n            startTime = startTime,\n            totalTime = totalTime\n        )\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/test/kotlin/maestro/cli/command/TestCommandTest.kt",
    "content": "package maestro.cli.command\n\nimport com.google.common.truth.Truth.assertThat\nimport maestro.orchestra.workspace.WorkspaceExecutionPlanner\nimport maestro.orchestra.WorkspaceConfig\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.BeforeEach\nimport java.nio.file.Path\n\nclass TestCommandTest {\n\n    private lateinit var testCommand: TestCommand\n\n    @BeforeEach\n    fun setUp() {\n        testCommand = TestCommand()\n    }\n\n    /*****************************************\n    *** executionPlanIncludesWebFlow Tests ***\n    ******************************************/\n    @Test\n    fun `executionPlanIncludesWebFlow should return false when both flowsToRun and sequence flows are empty`() {\n        val executionPlan = WorkspaceExecutionPlanner.ExecutionPlan(\n            flowsToRun = emptyList(),\n            sequence = WorkspaceExecutionPlanner.FlowSequence(emptyList(), true),\n            workspaceConfig = WorkspaceConfig()\n        )\n        val result = testCommand.executionPlanIncludesWebFlow(executionPlan)\n        assertThat(result).isFalse()\n    }\n\n    @Test\n    fun `executionPlanIncludesWebFlow should return true when flowsToRun contains both mobile & web flow`() {\n        val workspacePath = getTestResourcePath(\"workspaces/test_command_test/00_mixed_web_mobile_flow_tests\")\n        val executionPlan = WorkspaceExecutionPlanner.plan(\n            input = setOf(workspacePath),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n            config = null\n        )\n        val includesWebFlow = testCommand.executionPlanIncludesWebFlow(executionPlan)\n        assertThat(includesWebFlow).isTrue()\n    }\n\n    @Test\n    fun `executionPlanIncludesWebFlow should return true when sequence flows contains web flow only`() {\n        val workspacePath = getTestResourcePath(\"workspaces/test_command_test/01_web_only\")\n        val executionPlan = WorkspaceExecutionPlanner.plan(\n            input = setOf(workspacePath),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n            config = null\n        )\n        val result = testCommand.executionPlanIncludesWebFlow(executionPlan)\n        assertThat(result).isTrue()\n    }\n\n    @Test\n    fun `executionPlanIncludesWebFlow should return false when no web flows exist`() {\n        val workspacePath = getTestResourcePath(\"workspaces/test_command_test/02_mobile_only\")\n        val executionPlan = WorkspaceExecutionPlanner.plan(\n            input = setOf(workspacePath),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n            config = null\n        )\n        val result = testCommand.executionPlanIncludesWebFlow(executionPlan)\n        assertThat(result).isFalse()\n    }\n\n    @Test\n    fun `executionPlanIncludesWebFlow should return true if after config mixed flows exist`() {\n        val workspacePath = getTestResourcePath(\"workspaces/test_command_test/03_mixed_with_config_execution_order\")\n        val executionPlan = WorkspaceExecutionPlanner.plan(\n            input = setOf(workspacePath),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n            config = null\n        )\n        val result = testCommand.executionPlanIncludesWebFlow(executionPlan)\n        assertThat(result).isTrue()\n    }\n\n    @Test\n    fun `executionPlanIncludesWebFlow should return false if after config no web flows exist`() {\n        val workspacePath = getTestResourcePath(\"workspaces/test_command_test/04_web_only_with_config_execution_order\")\n        val executionPlan = WorkspaceExecutionPlanner.plan(\n            input = setOf(workspacePath),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n            config = null\n        )\n        val result = testCommand.executionPlanIncludesWebFlow(executionPlan)\n        assertThat(result).isFalse()\n    }\n\n    /*****************************************\n    ******** allFlowsAreWebFlow Tests ********\n    ******************************************/\n    @Test\n    fun `allFlowsAreWebFlow should return false when both flowsToRun and sequence flows are empty`() {\n        val executionPlan = WorkspaceExecutionPlanner.ExecutionPlan(\n            flowsToRun = emptyList(),\n            sequence = WorkspaceExecutionPlanner.FlowSequence(emptyList(), true),\n            workspaceConfig = WorkspaceConfig()\n        )\n        val result = testCommand.allFlowsAreWebFlow(executionPlan)\n        assertThat(result).isFalse()\n    }\n\n    @Test\n    fun `allFlowsAreWebFlow should return false when flowsToRun contains both mobile & web flow`() {\n        val workspacePath = getTestResourcePath(\"workspaces/test_command_test/00_mixed_web_mobile_flow_tests\")\n        val executionPlan = WorkspaceExecutionPlanner.plan(\n            input = setOf(workspacePath),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n            config = null\n        )\n       val result = testCommand.allFlowsAreWebFlow(executionPlan)\n       assertThat(result).isFalse()\n    }\n\n    @Test\n    fun `allFlowsAreWebFlow should return true when sequence flows contains web flow only`() {\n        val workspacePath = getTestResourcePath(\"workspaces/test_command_test/01_web_only\")\n        val executionPlan = WorkspaceExecutionPlanner.plan(\n            input = setOf(workspacePath),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n            config = null\n        )\n        val result = testCommand.allFlowsAreWebFlow(executionPlan)\n        assertThat(result).isTrue()\n    }\n\n    @Test\n    fun `allFlowsAreWebFlow should return false when no web flows exist`() {\n        val workspacePath = getTestResourcePath(\"workspaces/test_command_test/02_mobile_only\")\n        val executionPlan = WorkspaceExecutionPlanner.plan(\n            input = setOf(workspacePath),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n            config = null\n        )\n        val result = testCommand.allFlowsAreWebFlow(executionPlan)\n        assertThat(result).isFalse()\n    }\n\n    @Test\n    fun `allFlowsAreWebFlow should return false if after config mixed flows exist`() {\n        val workspacePath = getTestResourcePath(\"workspaces/test_command_test/03_mixed_with_config_execution_order\")\n        val executionPlan = WorkspaceExecutionPlanner.plan(\n            input = setOf(workspacePath),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n            config = null\n        )\n        val result = testCommand.allFlowsAreWebFlow(executionPlan)\n        assertThat(result).isFalse()\n    }\n\n    @Test\n    fun `allFlowsAreWebFlow should return false if after config no web flows exist`() {\n        val workspacePath = getTestResourcePath(\"workspaces/test_command_test/04_web_only_with_config_execution_order\")\n        val executionPlan = WorkspaceExecutionPlanner.plan(\n            input = setOf(workspacePath),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n            config = null\n        )\n        val result = testCommand.executionPlanIncludesWebFlow(executionPlan)\n        assertThat(result).isFalse()\n    }\n\n    /*****************************************\n    ************ Common Functions ************\n    ******************************************/\n    private fun getTestResourcePath(resourcePath: String): Path {\n        val resourceUrl = javaClass.classLoader.getResource(resourcePath)\n        requireNotNull(resourceUrl) { \"Test resource not found: $resourcePath\" }\n        return Path.of(resourceUrl.toURI())\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/test/kotlin/maestro/cli/driver/DriverBuilderTest.kt",
    "content": "package maestro.cli.driver\n\nimport com.google.common.truth.Truth.assertThat\nimport io.mockk.every\nimport io.mockk.mockk\nimport io.mockk.slot\nimport org.junit.jupiter.api.Assertions.*\nimport io.mockk.spyk\nimport maestro.cli.api.CliVersion\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.io.TempDir\nimport java.io.File\nimport java.nio.file.Files\nimport java.nio.file.Path\nimport java.nio.file.Paths\nimport java.util.concurrent.TimeUnit\nimport kotlin.io.path.exists\nimport kotlin.io.path.pathString\nimport kotlin.io.path.readText\n\nclass DriverBuilderTest {\n\n    @TempDir\n    lateinit var tempDir: Path\n\n    @Test\n    fun `test if driver is built successfully and written in directory`() {\n        // given\n        val mockProcess = mockk<Process>(relaxed = true)\n        val mockProcessBuilderFactory = mockk<XcodeBuildProcessBuilderFactory>()\n        val sourceCodeRoot = System.getenv(\"GITHUB_WORKSPACE\") ?: System.getProperty(\"user.home\")\n        every { mockProcess.waitFor(120, TimeUnit.SECONDS) } returns true // Simulate successful execution\n        every { mockProcess.exitValue() } returns 0\n        every { mockProcessBuilderFactory.createProcess(any(), any(), any()) }  answers {\n            val derivedDataPath = Files.createDirectories(\n                Paths.get(sourceCodeRoot , \".maestro\", \"maestro-iphoneos-driver-build\", \"driver-iphoneos\", \"Build\", \"Products\")\n            )\n            val debugIphoneDir = Files.createDirectories(Paths.get(derivedDataPath.pathString, \"Debug-iphoneos\"))\n            // Simulate creating build products\n            File(derivedDataPath.toFile(), \"maestro-driver-ios-config.xctestrun\").writeText(\"Fake Runner xctestrun file\")\n            File(debugIphoneDir.toFile(), \"maestro-driver-iosUITests-Runner.app\").writeText(\"Fake Runner App Content\")\n            File(debugIphoneDir.toFile(), \"maestro-driver-ios.app\").writeText(\"Fake iOS Driver App Content\")\n            println(\"Simulated build process: Build products created in derived data path.\")\n\n            mockProcess // Return the mocked process\n        }\n\n        // when\n        val builder = DriverBuilder(mockProcessBuilderFactory)\n        val buildProducts = builder.buildDriver(\n            DriverBuildConfig(\n                teamId = \"25CQD4CKK3\",\n                derivedDataPath = \"driver-iphoneos\",\n                sourceCodePath = \"driver/ios\",\n                sourceCodeRoot = System.getenv(\"GITHUB_WORKSPACE\") ?: System.getProperty(\"user.home\"),\n                cliVersion = CliVersion(1, 40, 0)\n            )\n        )\n        val xctestRunFile = buildProducts.toFile().walk().firstOrNull { it.extension == \"xctestrun\" }\n        val appDir = buildProducts.resolve(\"Debug-iphoneos/maestro-driver-ios.app\")\n        val runnerDir = buildProducts.resolve(\"Debug-iphoneos/maestro-driver-iosUITests-Runner.app\")\n\n\n        // then\n        assertThat(xctestRunFile?.exists()).isTrue()\n        assertThat(appDir.exists()).isTrue()\n        assertThat(runnerDir.exists()).isTrue()\n\n        Paths.get(System.getenv(\"GITHUB_WORKSPACE\") ?: System.getProperty(\"user.home\"), \"maestro-iphoneos-driver-build\").toFile().deleteRecursively()\n    }\n\n    @Test\n    fun `should write error output to file inside _maestro on build failure`() {\n        // given\n        val sourceCodeRoot = System.getenv(\"GITHUB_WORKSPACE\") ?: System.getProperty(\"user.home\")\n        val driverBuildConfig = mockk<DriverBuildConfig>()\n        val processBuilderFactory = mockk<XcodeBuildProcessBuilderFactory>()\n        val driverBuilder = spyk(DriverBuilder(processBuilderFactory))\n        val mockProcess = mockk<Process>(relaxed = true)\n        val capturedFileSlot = slot<File>()\n\n        every { driverBuildConfig.sourceCodePath } returns  \"mock/source\"\n        every { driverBuildConfig.sourceCodeRoot } returns sourceCodeRoot\n        every { driverBuildConfig.derivedDataPath } returns  \"mock/source\"\n        every { driverBuildConfig.teamId } returns \"mock-team-id\"\n        every { driverBuildConfig.architectures } returns \"arm64\"\n        every { driverBuildConfig.destination } returns \"generic/platform=ios\"\n        every { driverBuildConfig.cliVersion } returns CliVersion.parse(\"1.40.0\")\n        every { driverBuilder.getDriverSourceFromResources(any()) } returns tempDir\n        every { mockProcess.exitValue() } returns 1\n        every { mockProcess.waitFor(120, TimeUnit.SECONDS) } returns true\n        every {\n            processBuilderFactory.createProcess(commands = any(), workingDirectory = any(), outputFile = capture(capturedFileSlot))\n        } answers {\n            capturedFileSlot.captured.writeText(\"xcodebuild failed!\")\n            mockProcess\n        }\n\n        // when\n        val error = assertThrows(RuntimeException::class.java) {\n            driverBuilder.buildDriver(driverBuildConfig)\n        }\n\n        // then\n        assertThat(error.message).contains(\"Failed to build iOS driver for connected iOS device\")\n        // Verify that the error log has been written inside the `.maestro` directory\n        val maestroDir = Paths.get(sourceCodeRoot, \".maestro\")\n        val errorLog = maestroDir.resolve(\"maestro-iphoneos-driver-build\").resolve(\"output.log\")\n\n        // Verify file exists and contains error output\n        assertTrue(Files.exists(errorLog), \"Expected an error log file to be written.\")\n        assertTrue(errorLog.readText().contains(\"xcodebuild failed!\"), \"Log should contain build failure message.\")\n\n        Paths.get(System.getenv(\"GITHUB_WORKSPACE\") ?: System.getProperty(\"user.home\"), \"maestro-iphoneos-driver-build\").toFile().deleteRecursively()\n    }\n}"
  },
  {
    "path": "maestro-cli/src/test/kotlin/maestro/cli/driver/RealDeviceDriverTest.kt",
    "content": "package maestro.cli.driver\n\nimport io.mockk.clearAllMocks\nimport io.mockk.every\nimport io.mockk.mockk\nimport io.mockk.mockkObject\nimport io.mockk.verify\nimport maestro.cli.api.CliVersion\nimport maestro.cli.util.EnvUtils\nimport org.junit.jupiter.api.AfterEach\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.io.TempDir\nimport java.nio.file.Files\nimport java.nio.file.Path\nimport java.nio.file.Paths\nimport java.util.*\nimport kotlin.io.path.createDirectories\nimport kotlin.io.path.createFile\nimport kotlin.io.path.pathString\nimport kotlin.io.path.writeText\n\n\nclass RealDeviceDriverTest {\n\n    @TempDir\n    lateinit var tempDir: Path // Temporary directory for test isolation\n\n\n    @Test\n    fun `should update driver when version is outdated`() {\n        // Set up a test version.properties file with an outdated version\n        // Mock CLI_VERSION in EnvUtils to return \"1.2.0\"\n        mockkObject(EnvUtils)\n        val driverBuilder = mockk<DriverBuilder>()\n        every { driverBuilder.buildDriver(any()) } returns tempDir.resolve(\"Build/Products\")\n        every { EnvUtils.getCLIVersion() } returns CliVersion.parse(\"1.2.0\")\n        every { EnvUtils.CLI_VERSION } returns CliVersion.parse(\"1.2.0\")\n        val driverDirectory = Files.createDirectories(Paths.get(tempDir.pathString + \"/maestro-iphoneos-driver-build\"))\n        val propertiesFile = driverDirectory.resolve(\"version.properties\")\n        val teamId = \"dummy-team\"\n        val destination = \"destination\"\n\n\n        Files.newBufferedWriter(propertiesFile).use { writer ->\n            val props = Properties()\n            props.setProperty(\"version\", \"1.1.0\") // Outdated version\n            props.store(writer, null)\n        }\n\n        // Call RealIOSDeviceDriver's validateAndUpdateDriver\n        RealIOSDeviceDriver(teamId = teamId, destination = destination, driverBuilder)\n            .validateAndUpdateDriver(driverRootDirectory = tempDir)\n\n        // Verify that the driver was built due to outdated version\n        verify(exactly = 1) { driverBuilder.buildDriver(any()) } // Assert buildDriver was called\n        driverDirectory.toFile().deleteRecursively()\n    }\n\n    @Test\n    fun `should not update driver when version is up to date`() {\n        // Set up a test version.properties file with an outdated version\n        // Mock CLI_VERSION in EnvUtils to return \"1.2.0\"\n        mockkObject(EnvUtils)\n        val driverBuilder = mockk<DriverBuilder>()\n        every { driverBuilder.buildDriver(any()) } returns tempDir.resolve(\"Build/Products\")\n        every { EnvUtils.getCLIVersion() } returns CliVersion.parse(\"1.3.0\")\n        every { EnvUtils.CLI_VERSION } returns CliVersion.parse(\"1.3.0\")\n        val driverDirectory = Files.createDirectories(Paths.get(tempDir.pathString + \"/maestro-iphoneos-driver-build\"))\n        val productDirectory = driverDirectory.resolve(\"driver-iphoneos\").resolve(\"Build\").resolve(\"Products\").createDirectories()\n        productDirectory.resolve(\"maestro-driver-ios-config.xctestrun\").createFile()\n            .apply {\n                writeText(\"Fake Runner xctestrun file\")\n            }\n        val propertiesFile = driverDirectory.resolve(\"version.properties\")\n        val teamId = \"dummy-team\"\n        val destination = \"destination\"\n\n\n        Files.newBufferedWriter(propertiesFile).use { writer ->\n            val props = Properties()\n            props.setProperty(\"version\", \"1.3.0\") // Outdated version\n            props.store(writer, null)\n        }\n\n        // Call RealIOSDeviceDriver's validateAndUpdateDriver\n        RealIOSDeviceDriver(teamId = teamId, destination = destination, driverBuilder)\n            .validateAndUpdateDriver(driverRootDirectory = tempDir)\n\n        // Verify that the driver was built due to outdated version\n        verify(exactly = 0) { driverBuilder.buildDriver(any()) } // Assert buildDriver was called\n\n        driverDirectory.toFile().deleteRecursively()\n    }\n\n    @Test\n    fun `should update driver when version file is missing`() {\n        // Set up a test version.properties file with an outdated version\n        // Mock CLI_VERSION in EnvUtils to return \"1.2.0\"\n        mockkObject(EnvUtils)\n        every { EnvUtils.getCLIVersion() } returns CliVersion.parse(\"1.3.0\")\n        every { EnvUtils.CLI_VERSION } returns CliVersion.parse(\"1.3.0\")\n\n        val driverBuilder = mockk<DriverBuilder>()\n        every { driverBuilder.buildDriver(any()) } returns tempDir.resolve(\"Build/Products\")\n\n        val teamId = \"dummy-team\"\n        val destination = \"destination\"\n\n        // Call RealIOSDeviceDriver's validateAndUpdateDriver\n        RealIOSDeviceDriver(teamId = teamId, destination = destination, driverBuilder)\n            .validateAndUpdateDriver(driverRootDirectory = tempDir)\n\n        // Verify that the driver was built due to outdated version\n        verify(exactly = 1) { driverBuilder.buildDriver(any()) } // Assert buildDriver was called\n    }\n\n    @AfterEach\n    fun cleanup() {\n        clearAllMocks()\n    }\n}"
  },
  {
    "path": "maestro-cli/src/test/kotlin/maestro/cli/report/HtmlTestSuiteReporterTest.kt",
    "content": "package maestro.cli.report\n\nimport com.google.common.truth.Truth.assertThat\nimport okio.Buffer\nimport org.junit.jupiter.api.Test\n\nclass HtmlTestSuiteReporterTest : TestSuiteReporterTest() {\n\n    @Test\n    fun `HTML - Test passed`() {\n        // Given\n        val testee = HtmlTestSuiteReporter()\n        val sink = Buffer()\n\n        // When\n        testee.report(\n            summary = testSuccessWithWarning,\n            out = sink\n        )\n        val resultStr = sink.readUtf8()\n\n        // Then\n        assertThat(resultStr).isEqualTo(\n            \"\"\"\n            <html>\n              <head>\n                <title>Maestro Test Report</title>\n                <link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css\" rel=\"stylesheet\">\n              </head>\n              <body>\n                <div class=\"card mb-4\">\n                  <div class=\"card-body\">\n                    <h1 class=\"mt-5 text-center\">Flow Execution Summary</h1>\n            <br>Test Result: PASSED<br>Duration: 31m 55.947s<br>Start Time: $nowAsIso<br><br>\n                    <div class=\"card-group mb-4\">\n                      <div class=\"card\">\n                        <div class=\"card-body\">\n                          <h5 class=\"card-title text-center\">Total number of Flows</h5>\n                          <h3 class=\"card-text text-center\">2</h3>\n                        </div>\n                      </div>\n                      <div class=\"card text-white bg-danger\">\n                        <div class=\"card-body\">\n                          <h5 class=\"card-title text-center\">Failed Flows</h5>\n                          <h3 class=\"card-text text-center\">0</h3>\n                        </div>\n                      </div>\n                      <div class=\"card text-white bg-success\">\n                        <div class=\"card-body\">\n                          <h5 class=\"card-title text-center\">Successful Flows</h5>\n                          <h3 class=\"card-text text-center\">2</h3>\n                        </div>\n                      </div>\n                    </div>\n                    <div class=\"card mb-4\">\n                      <div class=\"card-header\">\n                        <h5 class=\"mb-0\"><button class=\"btn btn-success\" type=\"button\" data-bs-toggle=\"collapse\" data-bs-target=\"#flow-0-Flow-A\" aria-expanded=\"false\" aria-controls=\"flow-0-Flow-A\">Flow A : SUCCESS</button></h5>\n                      </div>\n                      <div class=\"collapse\" id=\"flow-0-Flow-A\">\n                        <div class=\"card-body\">\n                          <p class=\"card-text\">Status: SUCCESS<br>Duration: 7m 1.573s<br>Start Time: $nowPlus1AsIso<br>File Name: flow_a<br></p>\n                        </div>\n                      </div>\n                    </div>\n                    <div class=\"card mb-4\">\n                      <div class=\"card-header\">\n                        <h5 class=\"mb-0\"><button class=\"btn btn-success\" type=\"button\" data-bs-toggle=\"collapse\" data-bs-target=\"#flow-1-Flow-B\" aria-expanded=\"false\" aria-controls=\"flow-1-Flow-B\">Flow B : WARNING</button></h5>\n                      </div>\n                      <div class=\"collapse\" id=\"flow-1-Flow-B\">\n                        <div class=\"card-body\">\n                          <p class=\"card-text\">Status: WARNING<br>Duration: 24m 54.749s<br>Start Time: $nowPlus2AsIso<br>File Name: flow_b<br></p>\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n                  <script src=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js\"></script>\n                </div>\n              </body>\n            </html>\n\n            \"\"\".trimIndent()\n        )\n    }\n\n    @Test\n    fun `HTML - Test failed`() {\n        // Given\n        val testee = HtmlTestSuiteReporter()\n        val sink = Buffer()\n\n        // When\n        testee.report(\n            summary = testSuccessWithError,\n            out = sink\n        )\n        val resultStr = sink.readUtf8()\n\n        // Then\n        assertThat(resultStr).isEqualTo(\n            \"\"\"\n            <html>\n              <head>\n                <title>Maestro Test Report</title>\n                <link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css\" rel=\"stylesheet\">\n              </head>\n              <body>\n                <div class=\"card mb-4\">\n                  <div class=\"card-body\">\n                    <h1 class=\"mt-5 text-center\">Flow Execution Summary</h1>\n            <br>Test Result: FAILED<br>Duration: 9m 12.743s<br>Start Time: $nowAsIso<br><br>\n                    <div class=\"card-group mb-4\">\n                      <div class=\"card\">\n                        <div class=\"card-body\">\n                          <h5 class=\"card-title text-center\">Total number of Flows</h5>\n                          <h3 class=\"card-text text-center\">2</h3>\n                        </div>\n                      </div>\n                      <div class=\"card text-white bg-danger\">\n                        <div class=\"card-body\">\n                          <h5 class=\"card-title text-center\">Failed Flows</h5>\n                          <h3 class=\"card-text text-center\">1</h3>\n                        </div>\n                      </div>\n                      <div class=\"card text-white bg-success\">\n                        <div class=\"card-body\">\n                          <h5 class=\"card-title text-center\">Successful Flows</h5>\n                          <h3 class=\"card-text text-center\">1</h3>\n                        </div>\n                      </div>\n                    </div>\n                    <div class=\"card border-danger mb-3\">\n                      <div class=\"card-body text-danger\"><b>Failed Flow</b><br>\n                        <p class=\"card-text\">Flow B<br></p>\n                      </div>\n                    </div>\n                    <div class=\"card mb-4\">\n                      <div class=\"card-header\">\n                        <h5 class=\"mb-0\"><button class=\"btn btn-success\" type=\"button\" data-bs-toggle=\"collapse\" data-bs-target=\"#flow-0-Flow-A\" aria-expanded=\"false\" aria-controls=\"flow-0-Flow-A\">Flow A : SUCCESS</button></h5>\n                      </div>\n                      <div class=\"collapse\" id=\"flow-0-Flow-A\">\n                        <div class=\"card-body\">\n                          <p class=\"card-text\">Status: SUCCESS<br>Duration: 7m 1.573s<br>Start Time: $nowPlus1AsIso<br>File Name: flow_a<br></p>\n                        </div>\n                      </div>\n                    </div>\n                    <div class=\"card mb-4\">\n                      <div class=\"card-header\">\n                        <h5 class=\"mb-0\"><button class=\"btn btn-danger\" type=\"button\" data-bs-toggle=\"collapse\" data-bs-target=\"#flow-1-Flow-B\" aria-expanded=\"false\" aria-controls=\"flow-1-Flow-B\">Flow B : ERROR</button></h5>\n                      </div>\n                      <div class=\"collapse\" id=\"flow-1-Flow-B\">\n                        <div class=\"card-body\">\n                          <p class=\"card-text\">Status: ERROR<br>Duration: 2m 11.846s<br>Start Time: $nowPlus2AsIso<br>File Name: flow_b<br></p>\n                          <p class=\"card-text text-danger\">Error message</p>\n                        </div>\n                      </div>\n                    </div>\n                  </div>\n                  <script src=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js\"></script>\n                </div>\n              </body>\n            </html>\n\n            \"\"\".trimIndent()\n        )\n    }\n\n    @Test\n    fun `HTML - Pretty mode with successful test and steps`() {\n        // Given\n        val testee = HtmlTestSuiteReporter(detailed = true)\n        val sink = Buffer()\n\n        // When\n        testee.report(\n            summary = testSuccessWithSteps,\n            out = sink\n        )\n        val resultStr = sink.readUtf8()\n\n        // Then\n        // Verify key elements are present\n        assertThat(resultStr).contains(\"Test Steps (3)\")\n        assertThat(resultStr).contains(\"✅\")\n        assertThat(resultStr).contains(\"1. Launch app\")\n        assertThat(resultStr).contains(\"2. Tap on button\")\n        assertThat(resultStr).contains(\"3. Assert visible\")\n        assertThat(resultStr).contains(\"1.2s\")\n        assertThat(resultStr).contains(\"500ms\")\n        assertThat(resultStr).contains(\"100ms\")\n        assertThat(resultStr).contains(\".step-item\")\n        assertThat(resultStr).contains(\".step-header\")\n        assertThat(resultStr).contains(\".step-name\")\n\n        // Verify proper HTML structure\n        assertThat(resultStr).contains(\"<html>\")\n        assertThat(resultStr).contains(\"</html>\")\n        assertThat(resultStr).contains(\"Flow Execution Summary\")\n        assertThat(resultStr).contains(\"Test Result: PASSED\")\n    }\n\n    @Test\n    fun `HTML - Pretty mode with failed test and steps with various statuses`() {\n        // Given\n        val testee = HtmlTestSuiteReporter(detailed = true)\n        val sink = Buffer()\n\n        // When\n        testee.report(\n            summary = testErrorWithSteps,\n            out = sink\n        )\n        val resultStr = sink.readUtf8()\n\n        // Then\n        // Verify key elements and various step statuses\n        assertThat(resultStr).contains(\"Test Steps (4)\")\n        assertThat(resultStr).contains(\"✅\") // COMPLETED\n        assertThat(resultStr).contains(\"⚠️\") // WARNED\n        assertThat(resultStr).contains(\"❌\") // FAILED\n        assertThat(resultStr).contains(\"⏭️\") // SKIPPED\n        assertThat(resultStr).contains(\"1. Launch app\")\n        assertThat(resultStr).contains(\"2. Tap on optional element\")\n        assertThat(resultStr).contains(\"3. Tap on button\")\n        assertThat(resultStr).contains(\"4. Assert visible\")\n        assertThat(resultStr).contains(\"Element not found\")\n        assertThat(resultStr).contains(\".step-item\")\n        assertThat(resultStr).contains(\".step-header\")\n        assertThat(resultStr).contains(\".step-name\")\n\n        // Verify proper HTML structure\n        assertThat(resultStr).contains(\"<html>\")\n        assertThat(resultStr).contains(\"</html>\")\n        assertThat(resultStr).contains(\"Flow Execution Summary\")\n        assertThat(resultStr).contains(\"Test Result: FAILED\")\n        assertThat(resultStr).contains(\"Failed Flow\")\n    }\n\n    @Test\n    fun `HTML - Basic mode does not show steps even when present`() {\n        // Given\n        val testee = HtmlTestSuiteReporter(detailed = false)\n        val sink = Buffer()\n\n        // When\n        testee.report(\n            summary = testSuccessWithSteps,\n            out = sink\n        )\n        val resultStr = sink.readUtf8()\n\n        // Then\n        // Should not contain step details\n        assertThat(resultStr).doesNotContain(\"Test Steps\")\n        assertThat(resultStr).doesNotContain(\"Launch app\")\n        assertThat(resultStr).doesNotContain(\"step-item\")\n        assertThat(resultStr).doesNotContain(\"step-header\")\n\n        // Should contain basic flow information\n        assertThat(resultStr).contains(\"Flow A\")\n        assertThat(resultStr).contains(\"Status: SUCCESS\")\n        assertThat(resultStr).contains(\"File Name: flow_a\")\n    }\n\n    @Test\n    fun `HTML - Tags and properties are displayed`() {\n        // Given\n        val testee = HtmlTestSuiteReporter(detailed = false)\n        val sink = Buffer()\n\n        // When\n        testee.report(\n            summary = testWithTagsAndProperties,\n            out = sink\n        )\n        val resultStr = sink.readUtf8()\n\n        // Then\n        // Verify tags are displayed\n        assertThat(resultStr).contains(\"Tags:\")\n        assertThat(resultStr).contains(\"smoke\")\n        assertThat(resultStr).contains(\"critical\")\n        assertThat(resultStr).contains(\"auth\")\n        assertThat(resultStr).contains(\"regression\")\n        assertThat(resultStr).contains(\"e2e\")\n        assertThat(resultStr).contains(\"badge bg-primary\")\n\n        // Verify properties section and table\n        assertThat(resultStr).contains(\"Properties\")\n        assertThat(resultStr).contains(\"testCaseId\")\n        assertThat(resultStr).contains(\"TC-001\")\n        assertThat(resultStr).contains(\"xray-test-key\")\n        assertThat(resultStr).contains(\"PROJ-123\")\n        assertThat(resultStr).contains(\"priority\")\n        assertThat(resultStr).contains(\"P0\")\n        assertThat(resultStr).contains(\"TC-002\")\n        assertThat(resultStr).contains(\"testrail-case-id\")\n        assertThat(resultStr).contains(\"C456\")\n        assertThat(resultStr).contains(\"table table-sm table-bordered\")\n\n        // Verify flow names\n        assertThat(resultStr).contains(\"Login Flow\")\n        assertThat(resultStr).contains(\"Checkout Flow\")\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/test/kotlin/maestro/cli/report/JUnitTestSuiteReporterTest.kt",
    "content": "package maestro.cli.report\n\nimport com.google.common.truth.Truth.assertThat\nimport okio.Buffer\nimport org.junit.jupiter.api.Test\n\nclass JUnitTestSuiteReporterTest : TestSuiteReporterTest() {\n\n    @Test\n    fun `XML - Test passed`() {\n        // Given\n        val testee = JUnitTestSuiteReporter.xml()\n        val sink = Buffer()\n\n        // When\n        testee.report(\n            summary = testSuccessWithWarning,\n            out = sink\n        )\n        val resultStr = sink.readUtf8()\n\n        // Then\n        assertThat(resultStr).isEqualTo(\n            \"\"\"\n                <?xml version='1.0' encoding='UTF-8'?>\n                <testsuites>\n                  <testsuite name=\"Test Suite\" device=\"iPhone 15\" tests=\"2\" failures=\"0\" time=\"1915.947\" timestamp=\"$nowAsIso\">\n                    <testcase id=\"Flow A\" name=\"Flow A\" classname=\"Flow A\" time=\"421.573\" timestamp=\"$nowPlus1AsIso\" status=\"SUCCESS\"/>\n                    <testcase id=\"Flow B\" name=\"Flow B\" classname=\"Flow B\" time=\"1494.749\" timestamp=\"$nowPlus2AsIso\" status=\"WARNING\"/>\n                  </testsuite>\n                </testsuites>\n                \n            \"\"\".trimIndent()\n        )\n    }\n\n    @Test\n    fun `XML - Test failed`() {\n        // Given\n        val testee = JUnitTestSuiteReporter.xml()\n        val sink = Buffer()\n\n        // When\n        testee.report(\n            summary = testSuccessWithError,\n            out = sink\n        )\n        val resultStr = sink.readUtf8()\n\n        // Then\n        assertThat(resultStr).isEqualTo(\n            \"\"\"\n                <?xml version='1.0' encoding='UTF-8'?>\n                <testsuites>\n                  <testsuite name=\"Test Suite\" tests=\"2\" failures=\"1\" time=\"552.743\" timestamp=\"$nowAsIso\">\n                    <testcase id=\"Flow A\" name=\"Flow A\" classname=\"Flow A\" time=\"421.573\" timestamp=\"$nowPlus1AsIso\" status=\"SUCCESS\"/>\n                    <testcase id=\"Flow B\" name=\"Flow B\" classname=\"Flow B\" time=\"131.846\" timestamp=\"$nowPlus2AsIso\" status=\"ERROR\">\n                      <failure>Error message</failure>\n                    </testcase>\n                  </testsuite>\n                </testsuites>\n                \n            \"\"\".trimIndent()\n        )\n    }\n\n    @Test\n    fun `XML - Custom test suite name is used when present`() {\n        // Given\n        val testee = JUnitTestSuiteReporter.xml(\"Custom test suite name\")\n        val sink = Buffer()\n\n        // When\n        testee.report(\n            summary = testSuccessWithWarning,\n            out = sink\n        )\n        val resultStr = sink.readUtf8()\n\n        // Then\n        assertThat(resultStr).isEqualTo(\n            \"\"\"\n                <?xml version='1.0' encoding='UTF-8'?>\n                <testsuites>\n                  <testsuite name=\"Custom test suite name\" device=\"iPhone 15\" tests=\"2\" failures=\"0\" time=\"1915.947\" timestamp=\"$nowAsIso\">\n                    <testcase id=\"Flow A\" name=\"Flow A\" classname=\"Flow A\" time=\"421.573\" timestamp=\"$nowPlus1AsIso\" status=\"SUCCESS\"/>\n                    <testcase id=\"Flow B\" name=\"Flow B\" classname=\"Flow B\" time=\"1494.749\" timestamp=\"$nowPlus2AsIso\" status=\"WARNING\"/>\n                  </testsuite>\n                </testsuites>\n                \n            \"\"\".trimIndent()\n        )\n    }\n\n    @Test\n    fun `XML - Tags and properties are included in output`() {\n        // Given\n        val testee = JUnitTestSuiteReporter.xml()\n        val sink = Buffer()\n\n        // When\n        testee.report(\n            summary = testWithTagsAndProperties,\n            out = sink\n        )\n        val resultStr = sink.readUtf8()\n\n        // Then\n        assertThat(resultStr).isEqualTo(\n            \"\"\"\n                <?xml version='1.0' encoding='UTF-8'?>\n                <testsuites>\n                  <testsuite name=\"Test Suite\" tests=\"2\" failures=\"0\" time=\"6.0\" timestamp=\"$nowAsIso\">\n                    <testcase id=\"Login Flow\" name=\"Login Flow\" classname=\"Login Flow\" time=\"2.5\" timestamp=\"$nowPlus1AsIso\" status=\"SUCCESS\">\n                      <properties>\n                        <property name=\"testCaseId\" value=\"TC-001\"/>\n                        <property name=\"xray-test-key\" value=\"PROJ-123\"/>\n                        <property name=\"priority\" value=\"P0\"/>\n                        <property name=\"tags\" value=\"smoke, critical, auth\"/>\n                      </properties>\n                    </testcase>\n                    <testcase id=\"Checkout Flow\" name=\"Checkout Flow\" classname=\"Checkout Flow\" time=\"3.5\" timestamp=\"$nowPlus2AsIso\" status=\"SUCCESS\">\n                      <properties>\n                        <property name=\"testCaseId\" value=\"TC-002\"/>\n                        <property name=\"testrail-case-id\" value=\"C456\"/>\n                        <property name=\"tags\" value=\"regression, e2e\"/>\n                      </properties>\n                    </testcase>\n                  </testsuite>\n                </testsuites>\n                \n            \"\"\".trimIndent()\n        )\n    }\n\n}\n"
  },
  {
    "path": "maestro-cli/src/test/kotlin/maestro/cli/report/TestDebugReporterTest.kt",
    "content": "package maestro.cli.report\n\nimport com.google.common.truth.Truth.assertThat\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.io.TempDir\nimport java.nio.file.Files\nimport java.nio.file.Path\nimport java.nio.file.Paths\nimport kotlin.io.path.createDirectories\nimport kotlin.io.path.exists\nimport kotlin.io.path.pathString\n\n\nclass TestDebugReporterTest {\n\n    @TempDir\n    lateinit var tempDir: Path\n\n    @Test\n    fun `will delete old files`() {\n        // Create directory structure, and an old test directory\n        val oldDir = Files.createDirectories(tempDir.resolve(\".maestro/tests/old\"))\n        Files.setLastModifiedTime(oldDir, java.nio.file.attribute.FileTime.fromMillis(System.currentTimeMillis() - 1000 * 60 * 60 * 24 * 15))\n\n        // Initialise a new test reporter, which will create ./maestro/tests/<datestamp>\n        TestDebugReporter.install(tempDir.pathString, false,false)\n\n        // Run the deleteOldFiles method, which happens at the end of each test run\n        // This should delete the 'old' directory created above\n        TestDebugReporter.deleteOldFiles()\n        assertThat(Files.exists(oldDir)).isFalse() // Verify that the old directory was deleted\n        assertThat(TestDebugReporter.getDebugOutputPath().exists()).isTrue() // Verify that the logs from this run still exist\n    }\n\n}"
  },
  {
    "path": "maestro-cli/src/test/kotlin/maestro/cli/report/TestSuiteReporterTest.kt",
    "content": "package maestro.cli.report\n\nimport maestro.cli.model.FlowStatus\nimport maestro.cli.model.TestExecutionSummary\nimport java.time.OffsetDateTime\nimport java.time.format.DateTimeFormatter\nimport java.time.temporal.ChronoUnit\nimport kotlin.time.Duration.Companion.milliseconds\n\nabstract class TestSuiteReporterTest {\n\n    // Since timestamps we get from the server have milliseconds precision (specifically epoch millis)\n    // we need to truncate off nanoseconds (and any higher) precision.\n    val now = OffsetDateTime.now().truncatedTo(ChronoUnit.MILLIS)\n\n    val nowPlus1 = now.plusSeconds(1)\n    val nowPlus2 = now.plusSeconds(2)\n\n    val nowAsIso = now.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)\n    val nowPlus1AsIso = nowPlus1.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)\n    val nowPlus2AsIso = nowPlus2.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)\n\n    val testSuccessWithWarning = TestExecutionSummary(\n        passed = true,\n        suites = listOf(\n            TestExecutionSummary.SuiteResult(\n                passed = true,\n                deviceName = \"iPhone 15\",\n                flows = listOf(\n                    TestExecutionSummary.FlowResult(\n                        name = \"Flow A\",\n                        fileName = \"flow_a\",\n                        status = FlowStatus.SUCCESS,\n                        duration = 421573.milliseconds,\n                        startTime = nowPlus1.toInstant().toEpochMilli()\n                    ),\n                    TestExecutionSummary.FlowResult(\n                        name = \"Flow B\",\n                        fileName = \"flow_b\",\n                        status = FlowStatus.WARNING,\n                        duration = 1494749.milliseconds,\n                        startTime = nowPlus2.toInstant().toEpochMilli()\n                    ),\n                ),\n                duration = 1915947.milliseconds,\n                startTime = now.toInstant().toEpochMilli()\n            )\n        )\n    )\n\n    val testSuccessWithError = TestExecutionSummary(\n        passed = false,\n        suites = listOf(\n            TestExecutionSummary.SuiteResult(\n                passed = false,\n                flows = listOf(\n                    TestExecutionSummary.FlowResult(\n                        name = \"Flow A\",\n                        fileName = \"flow_a\",\n                        status = FlowStatus.SUCCESS,\n                        duration = 421573.milliseconds,\n                        startTime = nowPlus1.toInstant().toEpochMilli()\n                    ),\n                    TestExecutionSummary.FlowResult(\n                        name = \"Flow B\",\n                        fileName = \"flow_b\",\n                        status = FlowStatus.ERROR,\n                        failure = TestExecutionSummary.Failure(\"Error message\"),\n                        duration = 131846.milliseconds,\n                        startTime = nowPlus2.toInstant().toEpochMilli()\n                    ),\n                ),\n                duration = 552743.milliseconds,\n                startTime = now.toInstant().toEpochMilli()\n            )\n        )\n    )\n\n    val testSuccessWithSteps = TestExecutionSummary(\n        passed = true,\n        suites = listOf(\n            TestExecutionSummary.SuiteResult(\n                passed = true,\n                flows = listOf(\n                    TestExecutionSummary.FlowResult(\n                        name = \"Flow A\",\n                        fileName = \"flow_a\",\n                        status = FlowStatus.SUCCESS,\n                        duration = 5000.milliseconds,\n                        startTime = nowPlus1.toInstant().toEpochMilli(),\n                        steps = listOf(\n                            TestExecutionSummary.StepResult(\n                                description = \"1. Launch app\",\n                                status = \"COMPLETED\",\n                                duration = \"1.2s\"\n                            ),\n                            TestExecutionSummary.StepResult(\n                                description = \"2. Tap on button\",\n                                status = \"COMPLETED\",\n                                duration = \"500ms\"\n                            ),\n                            TestExecutionSummary.StepResult(\n                                description = \"3. Assert visible\",\n                                status = \"COMPLETED\",\n                                duration = \"100ms\"\n                            ),\n                        )\n                    ),\n                ),\n                duration = 5000.milliseconds,\n                startTime = now.toInstant().toEpochMilli()\n            )\n        )\n    )\n\n    val testErrorWithSteps = TestExecutionSummary(\n        passed = false,\n        suites = listOf(\n            TestExecutionSummary.SuiteResult(\n                passed = false,\n                flows = listOf(\n                    TestExecutionSummary.FlowResult(\n                        name = \"Flow B\",\n                        fileName = \"flow_b\",\n                        status = FlowStatus.ERROR,\n                        failure = TestExecutionSummary.Failure(\"Element not found\"),\n                        duration = 3000.milliseconds,\n                        startTime = nowPlus1.toInstant().toEpochMilli(),\n                        steps = listOf(\n                            TestExecutionSummary.StepResult(\n                                description = \"1. Launch app\",\n                                status = \"COMPLETED\",\n                                duration = \"1.5s\"\n                            ),\n                            TestExecutionSummary.StepResult(\n                                description = \"2. Tap on optional element\",\n                                status = \"WARNED\",\n                                duration = \"<1ms\"\n                            ),\n                            TestExecutionSummary.StepResult(\n                                description = \"3. Tap on button\",\n                                status = \"FAILED\",\n                                duration = \"2.0s\"\n                            ),\n                            TestExecutionSummary.StepResult(\n                                description = \"4. Assert visible\",\n                                status = \"SKIPPED\",\n                                duration = \"0ms\"\n                            ),\n                        )\n                    ),\n                ),\n                duration = 3000.milliseconds,\n                startTime = now.toInstant().toEpochMilli()\n            )\n        )\n    )\n\n    val testWithTagsAndProperties = TestExecutionSummary(\n        passed = true,\n        suites = listOf(\n            TestExecutionSummary.SuiteResult(\n                passed = true,\n                flows = listOf(\n                    TestExecutionSummary.FlowResult(\n                        name = \"Login Flow\",\n                        fileName = \"login_flow\",\n                        status = FlowStatus.SUCCESS,\n                        duration = 2500.milliseconds,\n                        startTime = nowPlus1.toInstant().toEpochMilli(),\n                        tags = listOf(\"smoke\", \"critical\", \"auth\"),\n                        properties = mapOf(\n                            \"testCaseId\" to \"TC-001\",\n                            \"xray-test-key\" to \"PROJ-123\",\n                            \"priority\" to \"P0\"\n                        )\n                    ),\n                    TestExecutionSummary.FlowResult(\n                        name = \"Checkout Flow\",\n                        fileName = \"checkout_flow\",\n                        status = FlowStatus.SUCCESS,\n                        duration = 3500.milliseconds,\n                        startTime = nowPlus2.toInstant().toEpochMilli(),\n                        tags = listOf(\"regression\", \"e2e\"),\n                        properties = mapOf(\n                            \"testCaseId\" to \"TC-002\",\n                            \"testrail-case-id\" to \"C456\"\n                        )\n                    ),\n                ),\n                duration = 6000.milliseconds,\n                startTime = now.toInstant().toEpochMilli()\n            )\n        )\n    )\n}\n"
  },
  {
    "path": "maestro-cli/src/test/kotlin/maestro/cli/runner/resultview/PlainTextResultViewTest.kt",
    "content": "package maestro.cli.runner.resultview\n\nimport com.google.common.truth.Truth.assertThat\nimport maestro.cli.runner.CommandState\nimport maestro.cli.runner.CommandStatus\nimport maestro.orchestra.AssertConditionCommand\nimport maestro.orchestra.Condition\nimport maestro.orchestra.ElementSelector\nimport maestro.orchestra.MaestroCommand\nimport maestro.orchestra.RunFlowCommand\nimport org.junit.jupiter.api.BeforeEach\nimport org.junit.jupiter.api.Test\nimport java.io.ByteArrayOutputStream\nimport java.io.PrintStream\n\nclass PlainTextResultViewTest {\n\n    private lateinit var outputStream: ByteArrayOutputStream\n    private lateinit var originalOut: PrintStream\n\n    @BeforeEach\n    fun setUp() {\n        outputStream = ByteArrayOutputStream()\n        originalOut = System.out\n        System.setOut(PrintStream(outputStream))\n    }\n\n    private fun tearDown() {\n        System.setOut(originalOut)\n    }\n\n    private fun getOutput(): String {\n        return outputStream.toString()\n    }\n\n    /**\n     * This test verifies that deeply nested runFlow commands are all printed correctly.\n     *\n     * Bug description: When using nested complex runFlow inside runFlow (and even deeper),\n     * the --no-ansi option fails to keep track and stops printing. It gets out of sync.\n     *\n     * Example structure that was failing:\n     * main.yml:\n     *   - runFlow: open_app.yml        <- printed fine\n     *   - runFlow: login_to_app.yml    <- printed fine\n     *   - runFlow: tests.yml           <- this flow and everything in it were NOT being printed\n     *\n     * Where tests.yml contains:\n     *   - runFlow: test1.yml\n     *   - runFlow: test2.yml\n     *\n     * And login_to_app.yml contains a conditional runFlow.\n     *\n     * The fix ensures unique keys are generated for each nested command by using\n     * hierarchical prefixes (e.g., \"main:0:sub:0:sub:0\") instead of flat indices.\n     */\n    @Test\n    fun `nested runFlow commands should all be printed correctly`() {\n        // Given\n        val resultView = PlainTextResultView()\n\n        // Create a deeply nested structure similar to the bug scenario:\n        // main flow -> runFlow (tests.yml) -> runFlow (test1.yml) -> assertVisible\n\n        val deepestCommand = MaestroCommand(\n            assertConditionCommand = AssertConditionCommand(\n                condition = Condition(visible = ElementSelector(textRegex = \"hello\"))\n            )\n        )\n\n        val deepestCommandState = CommandState(\n            status = CommandStatus.COMPLETED,\n            command = deepestCommand,\n            subOnStartCommands = null,\n            subOnCompleteCommands = null,\n            subCommands = null\n        )\n\n        // test1.yml - contains assertVisible\n        val test1RunFlow = MaestroCommand(\n            runFlowCommand = RunFlowCommand(\n                commands = listOf(deepestCommand),\n                sourceDescription = \"test1.yml\",\n                config = null\n            )\n        )\n\n        val test1State = CommandState(\n            status = CommandStatus.COMPLETED,\n            command = test1RunFlow,\n            subOnStartCommands = null,\n            subOnCompleteCommands = null,\n            subCommands = listOf(deepestCommandState)\n        )\n\n        // test2.yml - another nested flow\n        val test2RunFlow = MaestroCommand(\n            runFlowCommand = RunFlowCommand(\n                commands = listOf(deepestCommand),\n                sourceDescription = \"test2.yml\",\n                config = null\n            )\n        )\n\n        val test2State = CommandState(\n            status = CommandStatus.COMPLETED,\n            command = test2RunFlow,\n            subOnStartCommands = null,\n            subOnCompleteCommands = null,\n            subCommands = listOf(deepestCommandState.copy())\n        )\n\n        // tests.yml - contains test1 and test2\n        val testsRunFlow = MaestroCommand(\n            runFlowCommand = RunFlowCommand(\n                commands = listOf(test1RunFlow, test2RunFlow),\n                sourceDescription = \"tests.yml\",\n                config = null\n            )\n        )\n\n        val testsState = CommandState(\n            status = CommandStatus.COMPLETED,\n            command = testsRunFlow,\n            subOnStartCommands = null,\n            subOnCompleteCommands = null,\n            subCommands = listOf(test1State, test2State)\n        )\n\n        // open_app.yml - simple flow\n        val openAppRunFlow = MaestroCommand(\n            runFlowCommand = RunFlowCommand(\n                commands = listOf(deepestCommand),\n                sourceDescription = \"open_app.yml\",\n                config = null\n            )\n        )\n\n        val openAppState = CommandState(\n            status = CommandStatus.COMPLETED,\n            command = openAppRunFlow,\n            subOnStartCommands = null,\n            subOnCompleteCommands = null,\n            subCommands = listOf(deepestCommandState.copy())\n        )\n\n        // login_to_app.yml - contains a conditional runFlow\n        val conditionalRunFlow = MaestroCommand(\n            runFlowCommand = RunFlowCommand(\n                commands = listOf(deepestCommand),\n                condition = Condition(visible = ElementSelector(textRegex = \"name@example.com\")),\n                sourceDescription = null,\n                config = null\n            )\n        )\n\n        val conditionalState = CommandState(\n            status = CommandStatus.COMPLETED,\n            command = conditionalRunFlow,\n            subOnStartCommands = null,\n            subOnCompleteCommands = null,\n            subCommands = listOf(deepestCommandState.copy())\n        )\n\n        val loginRunFlow = MaestroCommand(\n            runFlowCommand = RunFlowCommand(\n                commands = listOf(conditionalRunFlow),\n                sourceDescription = \"login_to_app.yml\",\n                config = null\n            )\n        )\n\n        val loginState = CommandState(\n            status = CommandStatus.COMPLETED,\n            command = loginRunFlow,\n            subOnStartCommands = null,\n            subOnCompleteCommands = null,\n            subCommands = listOf(conditionalState)\n        )\n\n        // Main flow state with all commands\n        val state = UiState.Running(\n            flowName = \"main.yml\",\n            commands = listOf(\n                openAppState,\n                loginState,\n                testsState  // This was the problematic flow that wasn't being printed\n            )\n        )\n\n        // When\n        resultView.setState(state)\n\n        // Then\n        val output = getOutput()\n        tearDown()\n\n        // Verify all nested flows are printed\n        assertThat(output).contains(\"Run open_app.yml\")\n        assertThat(output).contains(\"Run login_to_app.yml\")\n        assertThat(output).contains(\"Run tests.yml\")  // This was missing before the fix\n        assertThat(output).contains(\"Run test1.yml\")  // Nested inside tests.yml\n        assertThat(output).contains(\"Run test2.yml\")  // Nested inside tests.yml\n\n        // Verify the deepest commands are also printed (assertVisible)\n        // Count occurrences - we should have multiple \"Assert that\" for each nested flow\n        val assertCount = output.split(\"Assert that\").size - 1\n        assertThat(assertCount).isAtLeast(3)  // At least 3 assertVisible commands should be printed\n    }\n\n    @Test\n    fun `multiple calls with same nested structure should not duplicate output`() {\n        // Given\n        val resultView = PlainTextResultView()\n\n        val command = MaestroCommand(\n            assertConditionCommand = AssertConditionCommand(\n                condition = Condition(visible = ElementSelector(textRegex = \"hello\"))\n            )\n        )\n\n        val commandState = CommandState(\n            status = CommandStatus.COMPLETED,\n            command = command,\n            subOnStartCommands = null,\n            subOnCompleteCommands = null,\n            subCommands = null\n        )\n\n        val runFlowCommand = MaestroCommand(\n            runFlowCommand = RunFlowCommand(\n                commands = listOf(command),\n                sourceDescription = \"test.yml\",\n                config = null\n            )\n        )\n\n        val runFlowState = CommandState(\n            status = CommandStatus.COMPLETED,\n            command = runFlowCommand,\n            subOnStartCommands = null,\n            subOnCompleteCommands = null,\n            subCommands = listOf(commandState)\n        )\n\n        val state = UiState.Running(\n            flowName = \"main.yml\",\n            commands = listOf(runFlowState)\n        )\n\n        // When - call setState multiple times (simulating UI updates)\n        resultView.setState(state)\n        resultView.setState(state)\n        resultView.setState(state)\n\n        // Then\n        val output = getOutput()\n        tearDown()\n\n        // Should only print once despite multiple setState calls\n        val flowNameCount = output.split(\"Flow main.yml\").size - 1\n        assertThat(flowNameCount).isEqualTo(1)\n\n        val runTestCount = output.split(\"Run test.yml\").size - 1\n        assertThat(runTestCount).isEqualTo(2)  // Once for start, once for complete\n    }\n\n    @Test\n    fun `three levels of nested runFlow should all print`() {\n        // Given\n        val resultView = PlainTextResultView()\n\n        // Level 3: deepest assert\n        val assertCommand = MaestroCommand(\n            assertConditionCommand = AssertConditionCommand(\n                condition = Condition(visible = ElementSelector(textRegex = \"deep\"))\n            )\n        )\n        val assertState = CommandState(\n            status = CommandStatus.COMPLETED,\n            command = assertCommand,\n            subOnStartCommands = null,\n            subOnCompleteCommands = null,\n            subCommands = null\n        )\n\n        // Level 2: middle runFlow (level2.yml)\n        val level2Flow = MaestroCommand(\n            runFlowCommand = RunFlowCommand(\n                commands = listOf(assertCommand),\n                sourceDescription = \"level2.yml\",\n                config = null\n            )\n        )\n        val level2State = CommandState(\n            status = CommandStatus.COMPLETED,\n            command = level2Flow,\n            subOnStartCommands = null,\n            subOnCompleteCommands = null,\n            subCommands = listOf(assertState)\n        )\n\n        // Level 1: outer runFlow (level1.yml)\n        val level1Flow = MaestroCommand(\n            runFlowCommand = RunFlowCommand(\n                commands = listOf(level2Flow),\n                sourceDescription = \"level1.yml\",\n                config = null\n            )\n        )\n        val level1State = CommandState(\n            status = CommandStatus.COMPLETED,\n            command = level1Flow,\n            subOnStartCommands = null,\n            subOnCompleteCommands = null,\n            subCommands = listOf(level2State)\n        )\n\n        val state = UiState.Running(\n            flowName = \"main.yml\",\n            commands = listOf(level1State)\n        )\n\n        // When\n        resultView.setState(state)\n\n        // Then\n        val output = getOutput()\n        tearDown()\n\n        // All levels should be printed\n        assertThat(output).contains(\"Run level1.yml\")\n        assertThat(output).contains(\"Run level2.yml\")\n        assertThat(output).contains(\"Assert that\")\n    }\n}\n\n"
  },
  {
    "path": "maestro-cli/src/test/kotlin/maestro/cli/util/ChangeLogUtilsTest.kt",
    "content": "package maestro.cli.util\n\nimport com.google.common.truth.Truth.assertThat\nimport maestro.cli.util.EnvUtils.CLI_VERSION\nimport org.junit.jupiter.api.Test\nimport java.io.File\n\n\nclass ChangeLogUtilsTest {\n\n    private val changelogFile = File(System.getProperty(\"user.dir\"), \"../CHANGELOG.md\")\n\n    @Test\n    fun `test format last version`() {\n        val content = changelogFile.readText()\n\n        val changelog = ChangeLogUtils.formatBody(content, CLI_VERSION.toString())\n\n        assertThat(changelog).isNotNull()\n        assertThat(changelog).isNotEmpty()\n    }\n\n    @Test\n    fun `test format unknown version`() {\n        val content = changelogFile.readText()\n\n        val changelog = ChangeLogUtils.formatBody(content, \"x.yy.z\")\n\n        assertThat(changelog).isNull()\n    }\n\n    @Test\n    fun `test format short version`() {\n        val content = changelogFile.readText()\n\n        val changelog = ChangeLogUtils.formatBody(content, \"1.38.1\")\n\n        assertThat(changelog).containsExactly(\n            \"- New experimental AI-powered commands for screenshot testing: assertWithAI and assertNoDefectsWithAI (#1906)\",\n            \"- Enable basic support for Maestro uploads while keeping Maestro Cloud functioning (#1970)\",\n        )\n    }\n\n    @Test\n    fun `test format link and no paragraph`() {\n        val content = changelogFile.readText()\n\n        val changelog = ChangeLogUtils.formatBody(content, \"1.37.9\")\n\n        assertThat(changelog).containsExactly(\n            \"- Revert iOS landscape mode fix (#1916)\",\n        )\n    }\n\n    @Test\n    fun `test format no subheader`() {\n        val content = changelogFile.readText()\n\n        val changelog = ChangeLogUtils.formatBody(content, \"1.37.1\")\n\n        assertThat(changelog).containsExactly(\n            \"- Fix crash when `flutter` or `xcodebuild` is not installed (#1839)\",\n        )\n    }\n\n    @Test\n    fun `test format strong no paragraph and no sublist`() {\n        val content = changelogFile.readText()\n\n        val changelog = ChangeLogUtils.formatBody(content, \"1.37.0\")\n\n        assertThat(changelog).containsExactly(\n            \"- Sharding tests for parallel execution on many devices 🎉 (#1732 by Kaan)\",\n            \"- Reports in HTML (#1750 by Depa Panjie Purnama)\",\n            \"- Homebrew is back!\",\n            \"- Current platform exposed in JavaScript (#1747 by Dan Caseley)\",\n            \"- Control airplane mode (#1672 by NyCodeGHG)\",\n            \"- New `killApp` command (#1727 by Alexandre Favre)\",\n            \"- Fix cleaning up retries in iOS driver (#1669)\",\n            \"- Fix some commands not respecting custom labels (#1762 by Dan Caseley)\",\n            \"- Fix “Protocol family unavailable” when rerunning iOS tests (#1671 by Stanisław Chmiela)\",\n        )\n    }\n\n    @Test\n    fun `test print`() {\n        val content = changelogFile.readText()\n        val changelog = ChangeLogUtils.formatBody(content, \"1.17.1\")\n\n        val printed = ChangeLogUtils.print(changelog)\n\n        assertThat(printed).isEqualTo(\n            \"\\n\" +\n            \"- Tweak: Remove Maestro Studio icon from Mac dock\\n\" +\n            \"- Tweak: Prefer port 9999 for Maestro Studio app\\n\" +\n            \"- Fix: Fix Maestro Studio conditional code snippet\\n\"\n        )\n    }\n}\n"
  },
  {
    "path": "maestro-cli/src/test/kotlin/maestro/cli/util/DependencyResolverTest.kt",
    "content": "package maestro.cli.util\n\nimport com.google.common.truth.Truth.assertThat\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.io.TempDir\nimport java.nio.file.Path\nimport kotlin.io.path.writeText\n\nclass DependencyResolverTest {\n    \n    @Test\n    fun `test dependency discovery for single flow file`(@TempDir tempDir: Path) {\n        // Create a main flow file\n        val mainFlow = tempDir.resolve(\"main_flow.yaml\")\n        mainFlow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - runFlow: subflow1.yaml\n            - runFlow: subflow2.yaml\n            - runScript: validation.js\n            - addMedia:\n              - \"images/logo.png\"\n        \"\"\".trimIndent())\n        \n        // Create subflow files\n        val subflow1 = tempDir.resolve(\"subflow1.yaml\")\n        subflow1.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - tapOn: \"Button\"\n        \"\"\".trimIndent())\n        \n        val subflow2 = tempDir.resolve(\"subflow2.yaml\")\n        subflow2.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - runFlow: nested_subflow.yaml\n        \"\"\".trimIndent())\n        \n        // Create nested subflow\n        val nestedSubflow = tempDir.resolve(\"nested_subflow.yaml\")\n        nestedSubflow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - assertVisible: \"Text\"\n        \"\"\".trimIndent())\n        \n        // Create script file\n        val script = tempDir.resolve(\"validation.js\")\n        script.writeText(\"console.log('validation script');\")\n        \n        // Create media file\n        val mediaDir = tempDir.resolve(\"images\")\n        mediaDir.toFile().mkdirs()\n        val mediaFile = mediaDir.resolve(\"logo.png\")\n        mediaFile.writeText(\"fake png content\")\n        \n        // Test dependency discovery\n        val dependencies = DependencyResolver.discoverAllDependencies(mainFlow)\n        \n        // Should include all files\n        assertThat(dependencies).hasSize(6)\n        assertThat(dependencies).contains(mainFlow)\n        assertThat(dependencies).contains(subflow1)\n        assertThat(dependencies).contains(subflow2)\n        assertThat(dependencies).contains(nestedSubflow)\n        assertThat(dependencies).contains(script)\n        assertThat(dependencies).contains(mediaFile)\n    }\n    \n    @Test\n    fun `test dependency summary generation`(@TempDir tempDir: Path) {\n        val mainFlow = tempDir.resolve(\"main_flow.yaml\")\n        mainFlow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - runFlow: subflow.yaml\n            - runScript: script.js\n            - addMedia:\n              - \"images/logo.png\"\n        \"\"\".trimIndent())\n        \n        val subflow = tempDir.resolve(\"subflow.yaml\")\n        subflow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - tapOn: \"Button\"\n        \"\"\".trimIndent())\n        \n        val script = tempDir.resolve(\"script.js\")\n        script.writeText(\"console.log('test');\")\n        \n        val mediaDir = tempDir.resolve(\"images\")\n        mediaDir.toFile().mkdirs()\n        val mediaFile = mediaDir.resolve(\"logo.png\")\n        mediaFile.writeText(\"fake png content\")\n        \n        val summary = DependencyResolver.getDependencySummary(mainFlow)\n        \n        assertThat(summary).contains(\"Total files: 4\")\n        assertThat(summary).contains(\"Subflows: 1\")\n        assertThat(summary).contains(\"Scripts: 1\")\n        assertThat(summary).contains(\"Other files: 1\")\n    }\n    \n    @Test\n    fun `test enhanced dependency discovery finds all types`(@TempDir tempDir: Path) {\n        // Create a main flow file with runScript and addMedia\n        val mainFlow = tempDir.resolve(\"main_flow.yaml\")\n        mainFlow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - runFlow: subflow.yaml\n            - runScript: script.js\n            - addMedia:\n              - \"images/logo.png\"\n        \"\"\".trimIndent())\n        \n        val subflow = tempDir.resolve(\"subflow.yaml\")\n        subflow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - tapOn: \"Button\"\n        \"\"\".trimIndent())\n        \n        val script = tempDir.resolve(\"script.js\")\n        script.writeText(\"console.log('test');\")\n        \n        val mediaDir = tempDir.resolve(\"images\")\n        mediaDir.toFile().mkdirs()\n        val mediaFile = mediaDir.resolve(\"logo.png\")\n        mediaFile.writeText(\"fake png content\")\n        \n        // Test enhanced discovery (should find all dependencies)\n        val enhancedDependencies = DependencyResolver.discoverAllDependencies(mainFlow)\n        assertThat(enhancedDependencies).hasSize(4)\n        assertThat(enhancedDependencies).contains(script)\n        assertThat(enhancedDependencies).contains(mediaFile)\n        assertThat(enhancedDependencies).contains(subflow)\n    }\n    \n    @Test\n    fun `test composite commands - repeat with nested dependencies`(@TempDir tempDir: Path) {\n        // Create main flow with repeat command containing nested dependencies\n        val mainFlow = tempDir.resolve(\"main_flow.yaml\")\n        mainFlow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - repeat:\n                times: 3\n                commands:\n                  - runFlow: nested_subflow.yaml\n                  - runScript: validation.js\n                  - addMedia:\n                    - \"images/repeat_logo.png\"\n        \"\"\".trimIndent())\n        \n        // Create nested dependencies\n        val nestedSubflow = tempDir.resolve(\"nested_subflow.yaml\")\n        nestedSubflow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - tapOn: \"Button\"\n        \"\"\".trimIndent())\n        \n        val script = tempDir.resolve(\"validation.js\")\n        script.writeText(\"console.log('repeat validation');\")\n        \n        val mediaDir = tempDir.resolve(\"images\")\n        mediaDir.toFile().mkdirs()\n        val mediaFile = mediaDir.resolve(\"repeat_logo.png\")\n        mediaFile.writeText(\"fake png content\")\n        \n        // Test dependency discovery\n        val dependencies = DependencyResolver.discoverAllDependencies(mainFlow)\n        \n        // Should find all nested dependencies within repeat command\n        assertThat(dependencies).hasSize(4)\n        assertThat(dependencies).contains(mainFlow)\n        assertThat(dependencies).contains(nestedSubflow)\n        assertThat(dependencies).contains(script)\n        assertThat(dependencies).contains(mediaFile)\n    }\n    \n    @Test\n    fun `test composite commands - retry with file reference`(@TempDir tempDir: Path) {\n        // Create main flow with retry command referencing external file\n        val mainFlow = tempDir.resolve(\"main_flow.yaml\")\n        mainFlow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - retry:\n                file: external_retry.yaml\n                maxRetries: 3\n        \"\"\".trimIndent())\n        \n        // Create external retry file\n        val retryFile = tempDir.resolve(\"external_retry.yaml\")\n        retryFile.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - tapOn: \"Retry Button\"\n            - runFlow: nested_flow.yaml\n        \"\"\".trimIndent())\n        \n        // Create nested flow referenced by retry file\n        val nestedFlow = tempDir.resolve(\"nested_flow.yaml\")\n        nestedFlow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - assertVisible: \"Success\"\n        \"\"\".trimIndent())\n        \n        // Test dependency discovery\n        val dependencies = DependencyResolver.discoverAllDependencies(mainFlow)\n        \n        // Should find retry file and its nested dependencies\n        assertThat(dependencies).hasSize(3)\n        assertThat(dependencies).contains(mainFlow)\n        assertThat(dependencies).contains(retryFile)\n        assertThat(dependencies).contains(nestedFlow)\n    }\n    \n    @Test\n    fun `test composite commands - retry with inline commands`(@TempDir tempDir: Path) {\n        // Create main flow with retry command containing inline nested dependencies\n        val mainFlow = tempDir.resolve(\"main_flow.yaml\")\n        mainFlow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - retry:\n                maxRetries: 2\n                commands:\n                  - runFlow: retry_subflow.yaml\n                  - runScript: cleanup.js\n                  - addMedia:\n                    - \"images/retry_media.png\"\n        \"\"\".trimIndent())\n        \n        // Create nested dependencies\n        val retrySubflow = tempDir.resolve(\"retry_subflow.yaml\")\n        retrySubflow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - tapOn: \"Retry Action\"\n        \"\"\".trimIndent())\n        \n        val cleanupScript = tempDir.resolve(\"cleanup.js\")\n        cleanupScript.writeText(\"console.log('cleanup after retry');\")\n        \n        val mediaDir = tempDir.resolve(\"images\")\n        mediaDir.toFile().mkdirs()\n        val retryMedia = mediaDir.resolve(\"retry_media.png\")\n        retryMedia.writeText(\"fake retry media content\")\n        \n        // Test dependency discovery\n        val dependencies = DependencyResolver.discoverAllDependencies(mainFlow)\n        \n        // Should find all nested dependencies within retry command\n        assertThat(dependencies).hasSize(4)\n        assertThat(dependencies).contains(mainFlow)\n        assertThat(dependencies).contains(retrySubflow)\n        assertThat(dependencies).contains(cleanupScript)\n        assertThat(dependencies).contains(retryMedia)\n    }\n    \n    @Test\n    fun `test deeply nested composite commands`(@TempDir tempDir: Path) {\n        // Create complex nested structure: runFlow -> repeat -> retry -> runFlow\n        val mainFlow = tempDir.resolve(\"main_flow.yaml\")\n        mainFlow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - runFlow:\n                commands:\n                  - repeat:\n                      times: 2\n                      commands:\n                        - retry:\n                            maxRetries: 3\n                            commands:\n                              - runFlow: deeply_nested.yaml\n                              - runScript: deep_script.js\n        \"\"\".trimIndent())\n        \n        // Create deeply nested dependencies\n        val deeplyNested = tempDir.resolve(\"deeply_nested.yaml\")\n        deeplyNested.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - tapOn: \"Deep Button\"\n            - addMedia:\n              - \"images/deep_media.png\"\n        \"\"\".trimIndent())\n        \n        val deepScript = tempDir.resolve(\"deep_script.js\")\n        deepScript.writeText(\"console.log('deeply nested script');\")\n        \n        val mediaDir = tempDir.resolve(\"images\")\n        mediaDir.toFile().mkdirs()\n        val deepMedia = mediaDir.resolve(\"deep_media.png\")\n        deepMedia.writeText(\"deep media content\")\n        \n        // Test dependency discovery\n        val dependencies = DependencyResolver.discoverAllDependencies(mainFlow)\n        \n        // Should find all dependencies at any nesting level\n        assertThat(dependencies).hasSize(4)\n        assertThat(dependencies).contains(mainFlow)\n        assertThat(dependencies).contains(deeplyNested)\n        assertThat(dependencies).contains(deepScript)\n        assertThat(dependencies).contains(deepMedia)\n    }\n    \n    @Test\n    fun `test mixed composite commands with external and inline`(@TempDir tempDir: Path) {\n        // Create flow mixing external file references and inline commands\n        val mainFlow = tempDir.resolve(\"main_flow.yaml\")\n        mainFlow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - runFlow: external_flow.yaml\n            - repeat:\n                times: 2\n                commands:\n                  - runScript: inline_script.js\n            - retry:\n                file: external_retry.yaml\n        \"\"\".trimIndent())\n        \n        // Create external flow\n        val externalFlow = tempDir.resolve(\"external_flow.yaml\")\n        externalFlow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - tapOn: \"External Button\"\n        \"\"\".trimIndent())\n        \n        // Create inline script\n        val inlineScript = tempDir.resolve(\"inline_script.js\")\n        inlineScript.writeText(\"console.log('inline script in repeat');\")\n        \n        // Create external retry\n        val externalRetry = tempDir.resolve(\"external_retry.yaml\")\n        externalRetry.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - assertVisible: \"Retry Success\"\n            - runFlow: retry_nested.yaml\n        \"\"\".trimIndent())\n        \n        // Create retry nested flow\n        val retryNested = tempDir.resolve(\"retry_nested.yaml\")\n        retryNested.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - tapOn: \"Final Button\"\n        \"\"\".trimIndent())\n        \n        // Test dependency discovery\n        val dependencies = DependencyResolver.discoverAllDependencies(mainFlow)\n        \n        // Should find all dependencies from mixed sources\n        assertThat(dependencies).hasSize(5)\n        assertThat(dependencies).contains(mainFlow)\n        assertThat(dependencies).contains(externalFlow)\n        assertThat(dependencies).contains(inlineScript)\n        assertThat(dependencies).contains(externalRetry)\n        assertThat(dependencies).contains(retryNested)\n    }\n    \n    @Test\n    fun `test circular dependency prevention`(@TempDir tempDir: Path) {\n        // Create circular dependency: flow1 -> flow2 -> flow1\n        val flow1 = tempDir.resolve(\"flow1.yaml\")\n        flow1.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - runFlow: flow2.yaml\n            - tapOn: \"Button1\"\n        \"\"\".trimIndent())\n        \n        val flow2 = tempDir.resolve(\"flow2.yaml\")\n        flow2.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - runFlow: flow1.yaml\n            - tapOn: \"Button2\"\n        \"\"\".trimIndent())\n        \n        // Test dependency discovery should handle circular references\n        val dependencies = DependencyResolver.discoverAllDependencies(flow1)\n        \n        // Should include both files but not loop infinitely\n        // Note: The exact count may vary based on parsing, but should include at least flow1\n        // and should not hang due to circular reference\n        assertThat(dependencies.size).isAtLeast(1)\n        assertThat(dependencies).contains(flow1)\n        // flow2 might not be included if parsing fails, but the important thing is no infinite loop\n    }\n    \n    @Test\n    fun `test dependency summary with composite commands`(@TempDir tempDir: Path) {\n        val mainFlow = tempDir.resolve(\"main_flow.yaml\")\n        mainFlow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - repeat:\n                times: 2\n                commands:\n                  - runFlow: repeat_subflow.yaml\n                  - runScript: repeat_script.js\n            - retry:\n                file: retry_flow.yaml\n            - addMedia:\n              - \"images/main_logo.png\"\n        \"\"\".trimIndent())\n        \n        // Create all dependencies\n        val repeatSubflow = tempDir.resolve(\"repeat_subflow.yaml\")\n        repeatSubflow.writeText(\"appId: com.example.app\\n---\\n- tapOn: 'Button'\")\n        \n        val repeatScript = tempDir.resolve(\"repeat_script.js\")\n        repeatScript.writeText(\"console.log('repeat');\")\n        \n        val retryFlow = tempDir.resolve(\"retry_flow.yaml\")\n        retryFlow.writeText(\"appId: com.example.app\\n---\\n- assertVisible: 'Text'\")\n        \n        val mediaDir = tempDir.resolve(\"images\")\n        mediaDir.toFile().mkdirs()\n        val mainLogo = mediaDir.resolve(\"main_logo.png\")\n        mainLogo.writeText(\"logo content\")\n        \n        val summary = DependencyResolver.getDependencySummary(mainFlow)\n        \n        assertThat(summary).contains(\"Total files: 5\")\n        assertThat(summary).contains(\"Subflows: 2\") // repeat_subflow.yaml + retry_flow.yaml\n        assertThat(summary).contains(\"Scripts: 1\")  // repeat_script.js\n        assertThat(summary).contains(\"Other files: 1\") // main_logo.png\n    }\n    \n    @Test\n    fun `test configuration commands - onFlowStart and onFlowComplete with dependencies`(@TempDir tempDir: Path) {\n        // Create main flow with onFlowStart and onFlowComplete containing file dependencies\n        val mainFlow = tempDir.resolve(\"main_flow.yaml\")\n        mainFlow.writeText(\"\"\"\n            appId: com.example.app\n            onFlowStart:\n              - runFlow: startup_flow.yaml\n              - runScript: startup_script.js\n              - addMedia:\n                - \"images/startup_logo.png\"\n            onFlowComplete:\n              - runFlow: cleanup_flow.yaml  \n              - runScript: cleanup_script.js\n              - addMedia:\n                - \"images/completion_badge.png\"\n            ---\n            - tapOn: \"Main Button\"\n        \"\"\".trimIndent())\n        \n        // Create startup dependencies\n        val startupFlow = tempDir.resolve(\"startup_flow.yaml\")\n        startupFlow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - tapOn: \"Startup Button\"\n            - runFlow: nested_startup.yaml\n        \"\"\".trimIndent())\n        \n        val nestedStartup = tempDir.resolve(\"nested_startup.yaml\")\n        nestedStartup.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - assertVisible: \"Startup Complete\"\n        \"\"\".trimIndent())\n        \n        val startupScript = tempDir.resolve(\"startup_script.js\")\n        startupScript.writeText(\"console.log('startup initialization');\")\n        \n        val imagesDir = tempDir.resolve(\"images\")\n        imagesDir.toFile().mkdirs()\n        val startupLogo = imagesDir.resolve(\"startup_logo.png\")\n        startupLogo.writeText(\"startup logo content\")\n        \n        // Create cleanup dependencies\n        val cleanupFlow = tempDir.resolve(\"cleanup_flow.yaml\")\n        cleanupFlow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - tapOn: \"Cleanup Button\"\n        \"\"\".trimIndent())\n        \n        val cleanupScript = tempDir.resolve(\"cleanup_script.js\")\n        cleanupScript.writeText(\"console.log('cleanup finalization');\")\n        \n        val completionBadge = imagesDir.resolve(\"completion_badge.png\")\n        completionBadge.writeText(\"completion badge content\")\n        \n        // Test dependency discovery\n        val dependencies = DependencyResolver.discoverAllDependencies(mainFlow)\n        \n        // Should find all dependencies from onFlowStart and onFlowComplete\n        assertThat(dependencies).hasSize(8) // main + 2 startup flows + 2 scripts + 2 images + 1 cleanup flow\n        assertThat(dependencies).contains(mainFlow)\n        assertThat(dependencies).contains(startupFlow)\n        assertThat(dependencies).contains(nestedStartup)\n        assertThat(dependencies).contains(startupScript)\n        assertThat(dependencies).contains(startupLogo)\n        assertThat(dependencies).contains(cleanupFlow)\n        assertThat(dependencies).contains(cleanupScript)\n        assertThat(dependencies).contains(completionBadge)\n    }\n    \n    @Test\n    fun `test mixed configuration and composite commands`(@TempDir tempDir: Path) {\n        // Create complex flow with both configuration commands and composite commands\n        val mainFlow = tempDir.resolve(\"main_flow.yaml\")\n        mainFlow.writeText(\"\"\"\n            appId: com.example.app\n            onFlowStart:\n              - repeat:\n                  times: 2\n                  commands:\n                    - runFlow: repeated_startup.yaml\n            onFlowComplete:\n              - retry:\n                  maxRetries: 3\n                  commands:\n                    - runScript: retry_cleanup.js\n            ---\n            - tapOn: \"Main Action\"\n            - runFlow: main_subflow.yaml\n        \"\"\".trimIndent())\n        \n        // Create all dependencies\n        val repeatedStartup = tempDir.resolve(\"repeated_startup.yaml\")\n        repeatedStartup.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - tapOn: \"Repeated Action\"\n        \"\"\".trimIndent())\n        \n        val retryCleanup = tempDir.resolve(\"retry_cleanup.js\")\n        retryCleanup.writeText(\"console.log('retry cleanup');\")\n        \n        val mainSubflow = tempDir.resolve(\"main_subflow.yaml\")\n        mainSubflow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - assertVisible: \"Main Complete\"\n        \"\"\".trimIndent())\n        \n        // Test dependency discovery\n        val dependencies = DependencyResolver.discoverAllDependencies(mainFlow)\n        \n        // Should find dependencies from configuration commands AND main flow\n        assertThat(dependencies).hasSize(4)\n        assertThat(dependencies).contains(mainFlow)\n        assertThat(dependencies).contains(repeatedStartup) // From onFlowStart -> repeat -> runFlow\n        assertThat(dependencies).contains(retryCleanup)    // From onFlowComplete -> retry -> runScript  \n        assertThat(dependencies).contains(mainSubflow)     // From main flow -> runFlow\n    }\n\n    @Test\n    fun `test dependency discovery for repeated flow references`(@TempDir tempDir: Path) {\n        // Create a main flow file\n        val mainFlow = tempDir.resolve(\"main_flow.yaml\")\n        mainFlow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - runFlow: subflow.yaml\n            - runFlow: subflow.yaml\n            - runFlow:\n                commands:\n                  - runFlow: subflow.yaml\n        \"\"\".trimIndent())\n\n        // Create subflow files\n        val subflow1 = tempDir.resolve(\"subflow.yaml\")\n        subflow1.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - tapOn: \"Button\"\n        \"\"\".trimIndent())\n\n        // Test dependency discovery\n        val dependencies = DependencyResolver.discoverAllDependencies(mainFlow)\n\n        // Should include all files\n        assertThat(dependencies).hasSize(2)\n        assertThat(dependencies).contains(mainFlow)\n        assertThat(dependencies).contains(subflow1)\n\n    }\n\n    @Test\n    fun `deduplicates same script referenced via different relative paths`(@TempDir tempDir: Path) {\n        // Directory layout:\n        // tempDir/\n        //   main_flow.yaml\n        //   sub/\n        //     subflow.yaml\n        //   scripts/\n        //     createAccount.js\n\n        // Create directories\n        tempDir.resolve(\"sub\").toFile().mkdirs()\n        tempDir.resolve(\"scripts\").toFile().mkdirs()\n\n        // Create shared script\n        val script = tempDir.resolve(\"scripts/createAccount.js\")\n        script.writeText(\"console.log('create account');\")\n\n        // Subflow that references script with a simple relative path\n        val subflow = tempDir.resolve(\"sub/subflow.yaml\")\n        subflow.writeText(\n            \"\"\"\n            appId: com.example.app\n            ---\n            - runScript: ../scripts/createAccount.js\n            \"\"\".trimIndent()\n        )\n\n        // Main flow references the same script using a different path notation and the subflow\n        val mainFlow = tempDir.resolve(\"main_flow.yaml\")\n        mainFlow.writeText(\n            \"\"\"\n            appId: com.example.app\n            ---\n            - runFlow: sub/subflow.yaml\n            - runScript: ./sub/../scripts/createAccount.js\n            \"\"\".trimIndent()\n        )\n\n        val dependencies = DependencyResolver.discoverAllDependencies(mainFlow)\n\n        // Expect exactly one instance of the script, despite two different references\n        val scriptCount = dependencies.count { it.fileName.toString() == \"createAccount.js\" }\n        assertThat(scriptCount).isEqualTo(1)\n\n        // Also expect both flows to be included\n        assertThat(dependencies.any { it.fileName.toString() == \"main_flow.yaml\" }).isTrue()\n        assertThat(dependencies.any { it.fileName.toString() == \"subflow.yaml\" }).isTrue()\n    }\n\n    @Test\n    fun `test assertScreenshot reference image is discovered as a dependency`(@TempDir tempDir: Path) {\n        val referenceImage = tempDir.resolve(\"reference.png\")\n        referenceImage.writeText(\"fake png content\")\n\n        val mainFlow = tempDir.resolve(\"main_flow.yaml\")\n        mainFlow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - assertScreenshot: reference.png\n        \"\"\".trimIndent())\n\n        val dependencies = DependencyResolver.discoverAllDependencies(mainFlow)\n\n        assertThat(dependencies).hasSize(2)\n        assertThat(dependencies).contains(mainFlow)\n        assertThat(dependencies).contains(referenceImage)\n    }\n\n    @Test\n    fun `test assertScreenshot inside repeat block is discovered as a dependency`(@TempDir tempDir: Path) {\n        val referenceImage = tempDir.resolve(\"reference.png\")\n        referenceImage.writeText(\"fake png content\")\n\n        val mainFlow = tempDir.resolve(\"main_flow.yaml\")\n        mainFlow.writeText(\"\"\"\n            appId: com.example.app\n            ---\n            - repeat:\n                times: 3\n                commands:\n                  - assertScreenshot: reference.png\n        \"\"\".trimIndent())\n\n        val dependencies = DependencyResolver.discoverAllDependencies(mainFlow)\n\n        assertThat(dependencies).hasSize(2)\n        assertThat(dependencies).contains(mainFlow)\n        assertThat(dependencies).contains(referenceImage)\n    }\n\n    @Test\n    fun `treats files with same name but different real paths as different dependencies`(@TempDir tempDir: Path) {\n        // Directory layout:\n        // tempDir/\n        //   main_flow.yaml\n        //   scripts/\n        //     createUser.js\n        //     usa/\n        //       createUser.js\n\n        // Create directories\n        tempDir.resolve(\"scripts\").toFile().mkdirs()\n        tempDir.resolve(\"scripts/usa\").toFile().mkdirs()\n\n        // Create two different scripts with the same filename\n        val script1 = tempDir.resolve(\"scripts/createUser.js\")\n        script1.writeText(\"console.log('create user - default');\")\n\n        val script2 = tempDir.resolve(\"scripts/usa/createUser.js\")\n        script2.writeText(\"console.log('create user - USA');\")\n\n        // Main flow references both scripts\n        val mainFlow = tempDir.resolve(\"main_flow.yaml\")\n        mainFlow.writeText(\n            \"\"\"\n            appId: com.example.app\n            ---\n            - runScript: scripts/createUser.js\n            - runScript: scripts/usa/createUser.js\n            \"\"\".trimIndent()\n        )\n\n        val dependencies = DependencyResolver.discoverAllDependencies(mainFlow)\n\n        // Expect both scripts to be included as separate dependencies\n        assertThat(dependencies).hasSize(3) // main_flow.yaml + 2 scripts\n        assertThat(dependencies).contains(mainFlow)\n        assertThat(dependencies).contains(script1)\n        assertThat(dependencies).contains(script2)\n\n        // Verify both are present by checking their real paths\n        val scriptPaths = dependencies.filter { it.fileName.toString() == \"createUser.js\" }\n        assertThat(scriptPaths).hasSize(2)\n        assertThat(scriptPaths).contains(script1)\n        assertThat(scriptPaths).contains(script2)\n    }\n\n}\n"
  },
  {
    "path": "maestro-cli/src/test/kotlin/maestro/cli/util/WorkspaceUtilsTest.kt",
    "content": "package maestro.cli.util\n\nimport com.google.common.truth.Truth.assertThat\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.io.TempDir\nimport java.net.URI\nimport java.nio.file.FileSystems\nimport java.nio.file.Files\nimport java.nio.file.Path\nimport kotlin.io.path.readText\n\nclass WorkspaceUtilsTest {\n\n    @Test\n    fun `includes files outside workspace directory using path traversal`(@TempDir tempDir: Path) {\n        // Layout:\n        // tempDir/\n        //   flows/main.yaml\n        //   scripts/outside.js\n        val flowsDir = tempDir.resolve(\"flows\").toFile()\n        flowsDir.mkdirs()\n        val scriptsDir = tempDir.resolve(\"scripts\").toFile()\n        scriptsDir.mkdirs()\n\n        val outsideScript = tempDir.resolve(\"scripts/outside.js\")\n        Files.writeString(outsideScript, \"console.log('outside');\")\n\n        val mainFlow = tempDir.resolve(\"flows/main.yaml\")\n        Files.writeString(\n            mainFlow,\n            \"\"\"\n            appId: com.example.app\n            ---\n            - runScript: ../scripts/outside.js\n            \"\"\".trimIndent()\n        )\n\n        // Create ZIP\n        val outZip = tempDir.resolve(\"workspace.zip\")\n        WorkspaceUtils.createWorkspaceZip(mainFlow, outZip)\n\n        // Open ZIP FS and collect entry names\n        val zipUri = URI.create(\"jar:${outZip.toUri()}\")\n        val entryNames = mutableListOf<String>()\n        FileSystems.newFileSystem(zipUri, emptyMap<String, Any>()).use { fs ->\n            Files.walk(fs.getPath(\"/\")).use { paths ->\n                paths.filter { Files.isRegularFile(it) }\n                    .forEach { entryNames.add(it.toString().removePrefix(\"/\")) }\n            }\n        }\n\n        // Current behavior: Path traversal entries are NOT rejected\n        // The script is outside the flows/ directory, so relativize produces \"../scripts/outside.js\"\n        // This entry IS created in the ZIP (no validation/rejection happens)\n        val hasTraversalEntry = entryNames.any { it.contains(\"..\") && it.contains(\"scripts/outside.js\") }\n        val hasScriptEntry = entryNames.any { it.endsWith(\"outside.js\") || it.endsWith(\"scripts/outside.js\") }\n        \n        // Either the traversal path is preserved OR normalization resolves it - both are acceptable\n        // The key point: NO rejection happens, the ZIP is created successfully\n        assertThat(hasTraversalEntry || hasScriptEntry).isTrue()\n        assertThat(entryNames.size).isAtLeast(2) // Should have at least main.yaml and the script\n    }\n\n    @Test\n    fun `handles symlinks correctly`(@TempDir tempDir: Path) {\n        // Layout:\n        // tempDir/\n        //   flows/main.yaml\n        //   scripts/real.js (actual file)\n        //   scripts/link.js -> real.js (symlink pointing to real.js)\n        \n        // The flow references link.js normally, but link.js is a symlink\n        // This tests what happens when a dependency file is actually a symlink\n        \n        val flowsDir = tempDir.resolve(\"flows\").toFile()\n        flowsDir.mkdirs()\n        val scriptsDir = tempDir.resolve(\"scripts\").toFile()\n        scriptsDir.mkdirs()\n\n        val realScript = tempDir.resolve(\"scripts/real.js\")\n        Files.writeString(realScript, \"console.log('real');\")\n\n        // Create symlink: link.js is a symlink that points to real.js\n        val linkScript = tempDir.resolve(\"scripts/link.js\")\n        Files.createSymbolicLink(linkScript, realScript)\n\n        val mainFlow = tempDir.resolve(\"flows/main.yaml\")\n        Files.writeString(\n            mainFlow,\n            \"\"\"\n            appId: com.example.app\n            ---\n            - runScript: ../scripts/link.js\n            \"\"\".trimIndent()\n        )\n\n        val outZip = tempDir.resolve(\"workspace.zip\")\n        WorkspaceUtils.createWorkspaceZip(mainFlow, outZip)\n\n        // With normalization using toRealPath(NOFOLLOW_LINKS), symlink paths are preserved\n        // The ZIP should include the script (as link.js or normalized to real.js)\n        val zipUri = URI.create(\"jar:${outZip.toUri()}\")\n        val entryNames = mutableListOf<String>()\n        FileSystems.newFileSystem(zipUri, emptyMap<String, Any>()).use { fs ->\n            Files.walk(fs.getPath(\"/\")).use { paths ->\n                paths.filter { Files.isRegularFile(it) }\n                    .forEach { entryNames.add(it.toString().removePrefix(\"/\")) }\n            }\n        }\n\n        // Should have main.yaml and the script (either link.js or real.js, depending on normalization)\n        assertThat(entryNames.size).isAtLeast(2)\n        assertThat(entryNames.any { it.endsWith(\"main.yaml\") }).isTrue()\n        // Script should be included (either as link.js or normalized to real.js)\n        assertThat(entryNames.any { it.contains(\"real.js\") || it.contains(\"link.js\") }).isTrue()\n    }\n\n    @Test\n    fun `handles special characters in file paths`(@TempDir tempDir: Path) {\n        // Test paths with spaces, unicode, and other special characters\n        val mainFlow = tempDir.resolve(\"main.yaml\")\n        Files.writeString(\n            mainFlow,\n            \"\"\"\n            appId: com.example.app\n            ---\n            - runScript: \"scripts/script with spaces.js\"\n            - addMedia:\n              - \"images/émoji🎉.png\"\n            \"\"\".trimIndent()\n        )\n\n        val scriptsDir = tempDir.resolve(\"scripts\").toFile()\n        scriptsDir.mkdirs()\n        val scriptWithSpaces = tempDir.resolve(\"scripts/script with spaces.js\")\n        Files.writeString(scriptWithSpaces, \"console.log('spaces');\")\n\n        val imagesDir = tempDir.resolve(\"images\").toFile()\n        imagesDir.mkdirs()\n        val emojiFile = tempDir.resolve(\"images/émoji🎉.png\")\n        Files.writeString(emojiFile, \"fake png\")\n\n        val outZip = tempDir.resolve(\"workspace.zip\")\n        WorkspaceUtils.createWorkspaceZip(mainFlow, outZip)\n\n        // ZIP should be created successfully with special characters\n        assertThat(outZip.toFile().exists()).isTrue()\n        \n        val zipUri = URI.create(\"jar:${outZip.toUri()}\")\n        val entryNames = mutableListOf<String>()\n        FileSystems.newFileSystem(zipUri, emptyMap<String, Any>()).use { fs ->\n            Files.walk(fs.getPath(\"/\")).use { paths ->\n                paths.filter { Files.isRegularFile(it) }\n                    .forEach { entryNames.add(it.toString().removePrefix(\"/\")) }\n            }\n        }\n\n        // All files should be included\n        assertThat(entryNames.size).isAtLeast(3)\n        assertThat(entryNames.any { it.contains(\"script with spaces.js\") }).isTrue()\n        assertThat(entryNames.any { it.contains(\"émoji\") || it.contains(\"🎉\") }).isTrue()\n    }\n\n    @Test\n    fun `handles empty files`(@TempDir tempDir: Path) {\n        val mainFlow = tempDir.resolve(\"main.yaml\")\n        Files.writeString(\n            mainFlow,\n            \"\"\"\n            appId: com.example.app\n            ---\n            - runScript: empty.js\n            \"\"\".trimIndent()\n        )\n\n        val emptyScript = tempDir.resolve(\"empty.js\")\n        Files.createFile(emptyScript) // Create empty file\n\n        val outZip = tempDir.resolve(\"workspace.zip\")\n        WorkspaceUtils.createWorkspaceZip(mainFlow, outZip)\n\n        // Should handle empty files gracefully\n        assertThat(outZip.toFile().exists()).isTrue()\n        \n        val zipUri = URI.create(\"jar:${outZip.toUri()}\")\n        val entryNames = mutableListOf<String>()\n        FileSystems.newFileSystem(zipUri, emptyMap<String, Any>()).use { fs ->\n            Files.walk(fs.getPath(\"/\")).use { paths ->\n                paths.filter { Files.isRegularFile(it) }\n                    .forEach { entryNames.add(it.toString().removePrefix(\"/\")) }\n            }\n        }\n\n        assertThat(entryNames.size).isAtLeast(2)\n        assertThat(entryNames.any { it.endsWith(\"empty.js\") }).isTrue()\n    }\n}\n\n"
  },
  {
    "path": "maestro-cli/src/test/mcp/README.md",
    "content": "# MCP Testing Framework\n\nThis directory contains testing infrastructure for Maestro's MCP (Model Context Protocol) server.\n\n## Quick Start\n\n```bash\n# Test tool functionality (API validation)\n./run_mcp_tool_tests.sh ios\n\n# Test LLM behavior evaluations\n./run_mcp_evals.sh ios\n```\n\n## Testing Types\n\n### Tool Functionality Tests (`run_mcp_tool_tests.sh`)\n- **Purpose**: Validate that MCP tools execute without errors and return expected data types\n- **Speed**: Fast (no complex setup required)\n- **Use case**: CI/CD gating, quick smoke tests during development\n\n### LLM Behavior Evaluations (`run_mcp_evals.sh`)\n- **Purpose**: Validate that LLMs can properly use MCP tools to complete tasks\n- **Speed**: Slower (includes LLM reasoning evaluation)\n- **Use case**: Behavior validation, regression testing of LLM interactions\n"
  },
  {
    "path": "maestro-cli/src/test/mcp/full-evals.yaml",
    "content": "# yaml-language-server: $schema=https://raw.githubusercontent.com/steviec/mcp-server-tester/refs/heads/main/src/schemas/tests-schema.json\n# MCP LLM Evaluations (evals) Test Configuration\n# Updated to current YAML format syntax\n\nevals:\n  models:\n    - claude-3-5-haiku-latest\n  timeout: 30000\n  max_steps: 3\n  tests:\n    - name: Lists all available tools\n      prompt: Please list all available maestro tools you have access to.\n      expected_tool_calls:\n        allowed: []\n      response_scorers:\n        - type: llm-judge\n          criteria: >\n            The assistant makes no tool calls and instead provides a list of all\n            14 available tools: list_devices, start_device, launch_app, take_screenshot,\n            tap_on, input_text, back, stop_app, run_flow, run_flow_files, check_flow_syntax,\n            inspect_view_hierarchy, cheat_sheet, and query_docs. The response should be\n            comprehensive and not mention any other tool names.\n          threshold: 1.0\n\n    - name: Lists all devices\n      prompt: Please list all available devices for testing\n      expected_tool_calls:\n        required:\n          - list_devices\n\n    - name: Starts iOS device\n      prompt: Start an iOS device for testing\n      expected_tool_calls:\n        required:\n          - start_device\n      response_scorers:\n        - type: llm-judge\n          criteria: Did the assistant correctly start an iOS device and mention the device ID or confirm successful startup?\n\n    - name: Queries Maestro documentation\n      prompt: How do I tap on an element with specific text in Maestro?\n      expected_tool_calls:\n        required:\n          - query_docs\n        allowed:\n          - cheat_sheet\n      response_scorers:\n        - type: regex\n          pattern: '(tap|tapOn|text)'\n        - type: llm-judge\n          criteria: >\n            Did the assistant provide accurate information about tapping elements\n            with text in Maestro, including proper syntax or examples?\n          threshold: 0.8\n\n    - name: Validates flow syntax\n      prompt: 'Check if this Maestro flow syntax is valid: ''- tapOn: \"Submit button\"'''\n      expected_tool_calls:\n        required:\n          - check_flow_syntax\n        allowed:\n          - cheat_sheet\n      response_scorers:\n        - type: regex\n          pattern: '(valid|invalid|syntax|error)'\n\n    - name: Runs flow file by description\n      prompt: Please run our \"launch-safari-ios\" flow file in the setup/flows folder\n      expected_tool_calls:\n        required:\n          - run_flow_files\n"
  },
  {
    "path": "maestro-cli/src/test/mcp/inspect-view-hierarchy-evals.yaml",
    "content": "\n# yaml-language-server: $schema=https://raw.githubusercontent.com/steviec/mcp-server-tester/refs/heads/main/src/schemas/tests-schema.json\nevals:\n  models:\n  - claude-3-7-sonnet-latest\n  - claude-3-5-haiku-latest\n  timeout: 40000\n  max_steps: 10\n  tests:\n    - name: LLM can understand csv-based view hierarchy format\n      prompt: >\n        Using the inspect_view_hierarchy tool, describe the number of clickable elements and their position on the screen. Do not take a screenshot.\n      expected_tool_calls:\n        required:\n        - inspect_view_hierarchy\n        allowed:\n        - list_devices\n        - start_device\n        - launch_app\n      response_scorers:\n      - type: llm-judge\n        criteria: >\n          The assistant successfully uses the inspect_view_hierarchy tool with format=csv.\n          The assistant correctly identifies two buttons: \"allow\", and \"don't allow\" in the middle of the screen.\n        threshold: 0.6\n\n  # NOTE: Below are old tests when we were doing evals on various format types. We might want to resuscitate these at some point\n  # to make the format even more optimal, but for now we'll \n  # - name: yaml_compact_format_comprehension\n  #   description: Test if LLM can understand and extract information from yaml compact view hierarchy format\n  #   prompt: >\n  #      Using the inspect_view_hierarchy tool with the \"yaml\" format, describe the number of clickable elements on the screen, and their position on the screen. Do not take a screenshot.\n  #   expectedToolCalls:\n  #     required:\n  #     - inspect_view_hierarchy\n  #     allowed:\n  #     - list_devices\n  #     - start_device\n  #     - launch_app\n  #   responseScorers:\n  #   - type: llm-judge\n  #     criteria: >\n  #       The assistant successfully uses the inspect_view_hierarchy tool with format=yaml.\n  #       The assistant correctly identifies exactly 8 clickable elements: 7 buttons in a column, and a final button in the bottom right corner.\n  #     threshold: 1.0\n\n  # - name: csv_original_format_comprehension\n  #   description: Test if LLM can understand and extract information from csv compact view hierarchy format\n  #   prompt: >\n  #     Using the inspect_view_hierarchy tool with the \"csv-original\" format, describe the number of clickable elements and their position on the screen. Do not take a screenshot.\n  #   expectedToolCalls:\n  #     required:\n  #     - inspect_view_hierarchy\n  #     allowed:\n  #     - list_devices\n  #     - start_device\n  #     - launch_app\n  #   responseScorers:\n  #   - type: llm-judge\n  #     criteria: >\n  #       The assistant successfully uses the inspect_view_hierarchy tool.\n  #       The assistant correctly identifies exactly 8 clickable elements: 7 buttons in a column, and a final button in the bottom right corner.\n  #     threshold: 1.0\n      \n  # - name: json_compact_format_comprehension\n  #   description: Test if LLM can understand and extract information from json compact view hierarchy format\n  #   prompt: >\n  #     Using the inspect_view_hierarchy tool with the \"json\" format, describe the number of clickable elements and their position on the screen. Do not take a screenshot.\n  #   expectedToolCalls:\n  #     required:\n  #     - inspect_view_hierarchy\n  #     allowed:\n  #     - list_devices\n  #     - start_device\n  #     - launch_app\n  #   responseScorers:\n  #   - type: llm-judge\n  #     criteria: >\n  #       The assistant successfully uses the inspect_view_hierarchy tool with format=json.\n  #       The assistant correctly identifies exactly 8 clickable elements: 7 buttons in a column, and a final button in the bottom right corner.\n  #     threshold: 0.8    \n\n  # - name: schema_guided_navigation\n  #   description: Test if LLM can use the YAML compact schema to understand abbreviations\n  #   prompt: >\n  #     Use the yaml compact format to get the view hierarchy. Using the schema provided in the output,\n  #     find an element that has both text content and is clickable. Explain how you identified it.\n  #   expectedToolCalls:\n  #     required:\n  #     - inspect_view_hierarchy\n  #   responseScorers:\n  #   - type: llm-judge\n  #     criteria: >\n  #       The assistant uses inspect_view_hierarchy with yaml compact format, references the\n  #       ui_schema section to understand abbreviations, correctly identifies that 'txt' means text\n  #       and 'clickable: true' indicates an interactive element, and explains the process clearly.\n  #     threshold: 0.8\n\n  # - name: multi_format_comparison\n  #   description: Test LLM ability to work with multiple formats and choose the best one for a task\n  #   prompt: >\n  #     I want to get a quick overview of all interactive elements on the screen for automated testing.\n  #     Which format would be most suitable and why? Demonstrate by getting the hierarchy in that format.\n  #   expectedToolCalls:\n  #     required:\n  #     - inspect_view_hierarchy\n  #   responseScorers:\n  #   - type: llm-judge\n  #     criteria: >\n  #       The assistant analyzes the different format options, makes a reasoned choice \n  #       (likely yaml compact or json compact for efficiency, or CSV for structured processing),\n  #       explains the reasoning, and demonstrates by calling inspect_view_hierarchy with\n  #       the chosen format.\n  #     threshold: 0.8\n\n  # - name: error_handling_invalid_format\n  #   description: Test how LLM handles invalid format parameters\n  #   prompt: >\n  #     Try to get the view hierarchy using an invalid format parameter like \"xml\".\n  #     What happens and how would you handle this?\n  #   expectedToolCalls:\n  #     required:\n  #     - inspect_view_hierarchy\n  #   responseScorers:\n  #   - type: llm-judge\n  #     criteria: >\n  #       The assistant attempts inspect_view_hierarchy with an invalid format,\n  #       observes that it falls back to the default (yaml compact), and explains\n  #       that the tool has built-in error handling with sensible defaults.\n  #     threshold: 0.8"
  },
  {
    "path": "maestro-cli/src/test/mcp/launch_app_with_env_replacement.yaml",
    "content": "appId: ${APP_ID}\n---\n- launchApp"
  },
  {
    "path": "maestro-cli/src/test/mcp/maestro-mcp.json",
    "content": "{\n  \"mcpServers\": {\n    \"maestro-mcp\": {\n      \"command\": \"../../../build/install/maestro/bin/maestro\",\n      \"args\": [\n        \"mcp\",\n        \"--working-dir\",\n        \".\"\n      ],\n      \"env\": {\n        \"MAESTRO_API_KEY\": \"ADD_YOUR_API_KEY_HERE\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "maestro-cli/src/test/mcp/mcp-server-config.json",
    "content": "{\n  \"mcpServers\": {\n    \"maestro-mcp\": {\n      \"command\": \"../../../../maestro-cli/build/install/maestro/bin/maestro\",\n      \"args\": [\n        \"mcp\",\n        \"--working-dir\",\n        \".\"\n      ],\n      \"env\": {\n        \"MAESTRO_API_KEY\": \"ADD_YOUR_API_KEY_HERE\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "maestro-cli/src/test/mcp/run_mcp_evals.sh",
    "content": "#!/bin/bash\nset -e\n\n# Run MCP evaluation tests\n#\n# These tests validate MCP server behavior using LLM-based evaluations.\n# They test actual task completion and response quality.\n#\n# Usage: ./run_mcp_evals.sh [ios|android]\n\nplatform=\"${1:-ios}\"\n\nif [ \"$platform\" != \"android\" ] && [ \"$platform\" != \"ios\" ]; then\n    echo \"usage: $0 [ios|android]\"\n    exit 1\nfi\n\necho \"🔧 Running MCP evaluation tests for $platform\"\n\n# Get the script directory for relative paths\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\n# Check if Maestro CLI is built\n\"$SCRIPT_DIR/setup/check-maestro-cli-built.sh\"\n\n# Run the evaluation tests (from mcp directory so paths work correctly)\necho \"🧪 Executing MCP evaluation tests...\"\ncd \"$SCRIPT_DIR\"\nnpx -y mcp-server-tester@1.3.1 evals full-evals.yaml --server-config maestro-mcp.json || true\n\necho \"✅ MCP evaluation tests completed!\""
  },
  {
    "path": "maestro-cli/src/test/mcp/run_mcp_tool_tests.sh",
    "content": "#!/bin/bash\nset -e\n\n# Run MCP tool functionality tests\n#\n# These tests validate that tools execute without errors and return expected data types.\n# They test the API functionality, not LLM behavior.\n#\n# Usage: ./run_tool_tests.sh [ios|android]\n\nplatform=\"${1:-ios}\"\n\nif [ \"$platform\" != \"android\" ] && [ \"$platform\" != \"ios\" ]; then\n    echo \"usage: $0 [ios|android]\"\n    exit 1\nfi\n\n# Get the script directory for relative paths\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\n# Check if Maestro CLI is built\n\"$SCRIPT_DIR/setup/check-maestro-cli-built.sh\"\n\n# Run the tests that do not require a simulator\necho \"🧪 Executing tool functionality tests that do not require a simulator...\"\nnpx -y mcp-server-tester@1.3.1 tools tool-tests-without-device.yaml --server-config maestro-mcp.json || true\n\n# Ensure simulator/emulator is running (required for tool tests)\necho \"🧪 Launching simulator...\"\nDEVICE_ID=$(\"$SCRIPT_DIR/setup/launch-simulator.sh\" \"$platform\")\n\n# Run the tool tests (from mcp directory so paths work correctly)\necho \"🧪 Executing tool functionality tests...\"\ncd \"$SCRIPT_DIR\"\nDEVICE_ID=\"$DEVICE_ID\" npx -y mcp-server-tester@1.3.1 tools tool-tests-with-device.yaml --server-config maestro-mcp.json || true\n\necho \"✅ Tool functionality tests completed!\"\n"
  },
  {
    "path": "maestro-cli/src/test/mcp/setup/check-maestro-cli-built.sh",
    "content": "#!/bin/bash\nset -e\n\n# Check if Maestro CLI is built for MCP testing\n#\n# This script verifies that the Maestro CLI has been built and is available\n# at the expected location for MCP testing.\n#\n# Usage: ./check-maestro-cli-built.sh\n\n# Get the script directory and find the repo root\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nMAESTRO_ROOT=\"$(cd \"$SCRIPT_DIR/../../../../..\" && pwd)\"\n\n# Check if Maestro CLI is built\nMAESTRO_CLI_PATH=\"$MAESTRO_ROOT/maestro-cli/build/install/maestro/bin/maestro\"\nif [ ! -f \"$MAESTRO_CLI_PATH\" ]; then\n    echo \"❌ Error: Maestro CLI not found at expected location.\"\n    echo \"   MCP tests require the Maestro CLI to be built first.\"\n    echo \"   Please run: ./gradlew :maestro-cli:installDist\"\n    echo \"   From the repository root: $MAESTRO_ROOT\"\n    exit 1\nfi\n"
  },
  {
    "path": "maestro-cli/src/test/mcp/setup/download-and-install-apps.sh",
    "content": "#!/bin/bash\nset -e\n\n# Download and install apps for MCP testing\n#\n# Uses the existing e2e infrastructure to download and install test apps\n# on the specified platform.\n#\n# Usage: ./download-and-install-apps.sh <android|ios>\n\nplatform=\"${1:-}\"\n\nif [ \"$platform\" != \"android\" ] && [ \"$platform\" != \"ios\" ]; then\n    echo \"usage: $0 <android|ios>\"\n    exit 1\nfi\n\necho \"📥 Setting up apps for MCP testing on $platform\"\n\n# Get the script directory and find e2e directory\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nMAESTRO_ROOT=\"$(cd \"$SCRIPT_DIR/../../../../..\" && pwd)\"\nE2E_DIR=\"$MAESTRO_ROOT/e2e\"\n\n# Check if we can find the e2e directory\nif [ ! -d \"$E2E_DIR\" ]; then\n    echo \"❌ Error: Could not find e2e directory at $E2E_DIR\"\n    exit 1\nfi\n\necho \"📂 Using e2e directory: $E2E_DIR\"\n\n# Step 1: Download apps using e2e infrastructure\necho \"📥 Downloading test apps...\"\ncd \"$E2E_DIR\"\n./download_apps\n\n# Step 2: Install apps for the specified platform\necho \"📱 Installing apps on $platform...\"\n./install_apps \"$platform\"\n\necho \"✅ Apps ready for MCP testing on $platform\""
  },
  {
    "path": "maestro-cli/src/test/mcp/setup/flows/launch-demo-app-ios.yaml",
    "content": "appId: com.example.example\n---\n# Launch Demo App with clear state for evaluations\n\n- launchApp:\n    appId: com.example.example\n    clearState: true\n\n# Wait for app to load\n- waitForAnimationToEnd:\n    timeout: 3000"
  },
  {
    "path": "maestro-cli/src/test/mcp/setup/flows/launch-safari-ios.yaml",
    "content": "appId: com.apple.mobilesafari\n---\n# Simple Safari launch for view hierarchy testing\n- launchApp:\n    clearState: true\n\n# Wait for Safari to load\n- waitForAnimationToEnd:\n    timeout: 2000"
  },
  {
    "path": "maestro-cli/src/test/mcp/setup/flows/setup-wikipedia-search-android.yaml",
    "content": "appId: org.wikipedia\n---\n# Navigate Wikipedia to search screen for view hierarchy testing\n# Assumes onboarding is already completed\n\n# Dismiss any auth modals that might appear\n- tapOn:\n    text: \"Continue without logging in\"\n    optional: true\n\n# Navigate to search to get an interesting hierarchy\n- tapOn:\n    text: \"Search Wikipedia\"\n    optional: true\n\n# Enter a search term to populate results\n- inputText:\n    text: \"Artificial Intelligence\"\n    optional: true\n\n# Wait for search results to load\n- waitForAnimationToEnd:\n    timeout: 3000\n\n# App should now show search results with rich hierarchy"
  },
  {
    "path": "maestro-cli/src/test/mcp/setup/flows/setup-wikipedia-search-ios.yaml",
    "content": "appId: org.wikimedia.wikipedia\n---\n# Navigate Wikipedia to search screen for view hierarchy testing\n# Assumes onboarding is already completed\n\n# Dismiss any auth modals that might appear (from e2e advanced flow)\n- tapOn:\n    text: \"Continue without logging in\"\n    optional: true\n\n# Navigate to search to get an interesting hierarchy\n- tapOn:\n    text: \"Search Wikipedia\"\n    optional: true\n\n# Enter a search term to populate results\n- inputText:\n    text: \"Artificial Intelligence\"\n    optional: true\n\n# Wait for search results to load\n- waitForAnimationToEnd:\n    timeout: 3000\n\n# App should now show search results with rich hierarchy"
  },
  {
    "path": "maestro-cli/src/test/mcp/setup/flows/verify-ready-state.yaml",
    "content": "appId: org.wikimedia.wikipedia\n---\n# Verify Wikipedia is in a good state for view hierarchy testing\n# Should be showing search results or main interface\n\n# Check that we can see some expected elements\n- assertVisible:\n    text: \"Search\"\n    optional: true\n\n# Or search results if we're on search page\n- assertVisible:\n    text: \"Intelligence\"\n    optional: true\n\n# Or main page elements\n- assertVisible:\n    text: \"Wikipedia\"\n    optional: true\n\n# Just ensure the app is responsive and loaded\n- waitForAnimationToEnd:\n    timeout: 2000"
  },
  {
    "path": "maestro-cli/src/test/mcp/setup/launch-simulator.sh",
    "content": "#!/bin/bash\nset -e\n\n# Launch simulator/emulator for MCP testing\n#\n# Checks if a simulator is running and launches if it's not running\n#\n# Usage: ./launch-simulator.sh <android|ios>\n\nplatform=\"${1:-}\"\n\nif [ \"$platform\" != \"android\" ] && [ \"$platform\" != \"ios\" ]; then\n    echo \"usage: $0 <android|ios> [--auto-launch]\"\n    exit 1\nfi\n\nif [ \"$platform\" = \"ios\" ]; then\n    if xcrun simctl list devices | grep -q \"(Booted)\"; then\n        echo \"✅ iOS simulator is already running\" >&2\n        device_id=$(xcrun simctl list devices | grep \"(Booted)\" | head -1 | grep -o '[A-F0-9-]\\{36\\}')\n        echo \"$device_id\"\n        exit 0\n    fi\n    \n    # Find the first available iPhone simulator\n    available_sim=$(xcrun simctl list devices | grep \"iPhone\" | grep -v \"unavailable\" | head -1 | sed 's/.*iPhone \\([^(]*\\).*/iPhone \\1/' | sed 's/ *$//')\n    \n    if [ -n \"$available_sim\" ]; then\n        echo \"📱 Booting: $available_sim\" >&2\n        device_id=$(xcrun simctl list devices | grep \"iPhone\" | grep -v \"unavailable\" | head -1 | grep -o '[A-F0-9-]\\{36\\}')\n        xcrun simctl boot \"$device_id\"\n        xcrun simctl bootstatus \"$device_id\" > /dev/null\n        echo \"✅ iOS simulator launched successfully\" >&2\n    else\n        echo \"❌ Error: No available iOS simulators found\" >&2\n        exit 1\n    fi\n    \nelif [ \"$platform\" = \"android\" ]; then\n    if adb devices | grep -q \"device$\"; then\n        echo \"✅ Android emulator/device is connected\" >&2\n        device_id=$(adb devices | grep \"device$\" | head -1 | awk '{print $1}')\n    elif [ \"$auto_launch\" = true ]; then\n        echo \"🚀 Auto-launching Android emulator not implemented yet\" >&2\n        echo \"   Please start an Android emulator manually\" >&2\n        exit 1\n    else\n        echo \"❌ No Android emulator/device is connected\" >&2\n        echo \"   Please start an Android emulator first\" >&2\n        echo \"   Or connect a physical device\" >&2\n        exit 1\n    fi\nfi\n\necho \"$device_id\""
  },
  {
    "path": "maestro-cli/src/test/mcp/setup/setup_and_run_eval.sh",
    "content": "#!/bin/bash\nset -e\n\n# Run MCP LLM behavior evaluations\n#\n# These tests validate that LLMs can properly use MCP tools, including reasoning,\n# safety, and interaction patterns. They test client/server interaction and LLM capabilities.\n#\n# Usage: ./run_mcp_evals.sh [--app mobilesafari|wikipedia|demo_app] <eval-file1.yaml> [eval-file2.yaml] [...]\n\n# Parse arguments\napp_setup=\"none\"  # Default to clean home screen\neval_files=()\n\nwhile [[ $# -gt 0 ]]; do\n    case $1 in\n        --app)\n            app_setup=\"$2\"\n            if [[ ! \"$app_setup\" =~ ^(none|mobilesafari|wikipedia|demo_app)$ ]]; then\n                echo \"Error: --app must be one of: none, mobilesafari, wikipedia, demo_app\"\n                exit 1\n            fi\n            shift 2\n            ;;\n        *.yaml)\n            eval_files+=(\"$1\")\n            shift\n            ;;\n        *)\n            echo \"Unknown argument: $1\"\n            echo \"usage: $0 [--app mobilesafari|wikipedia|demo_app] <eval-file1.yaml> [eval-file2.yaml] [...]\"\n            exit 1\n            ;;\n    esac\ndone\n\nif [ ${#eval_files[@]} -eq 0 ]; then\n    echo \"❌ Error: No eval files provided\"\n    echo \"usage: $0 [--app mobilesafari|wikipedia|demo_app] <eval-file1.yaml> [eval-file2.yaml] [...]\"\n    echo \"       Default app setup: none (clean home screen)\"\n    echo \"\"\n    echo \"Available eval files:\"\n    find evals/ -name \"*.yaml\" 2>/dev/null | sed 's/^/  /' || echo \"  (none found)\"\n    exit 1\nfi\n\n# Get the script directory for relative paths\nSCRIPT_DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nCONFIG=\"$SCRIPT_DIR/mcp-server-config.json\"\n\n# Check if Maestro CLI is built\n\"$SCRIPT_DIR/setup/check-maestro-cli-built.sh\"\n\n# Ensure simulator is running (required for MCP evals that test device tools)\nplatform=\"ios\"\n\"$SCRIPT_DIR/setup/launch-simulator.sh\" \"$platform\"\n\n# App setup based on chosen option\ncase \"$app_setup\" in\n    \"none\")\n        echo \"📱 No app setup - using clean simulator home screen\"\n        ;;\n    \"mobilesafari\")\n        echo \"📱 Launching Mobile Safari for evaluations...\"\n        cd \"$(dirname \"$SCRIPT_DIR\")/../../..\"\n        maestro test \"$SCRIPT_DIR/setup/flows/launch-safari-ios.yaml\"\n        echo \"✅ Mobile Safari ready for evaluations\"\n        ;;\n    \"wikipedia\")\n        echo \"📱 Setting up Wikipedia app environment for complex evaluations...\"\n        \n        # Use setup utilities for app environment\n        \"$SCRIPT_DIR/setup/download-and-install-apps.sh\" ios\n        \n        # Setup Wikipedia in a good state for hierarchy testing\n        cd \"$(dirname \"$SCRIPT_DIR\")/../../..\"\n        maestro test \"$SCRIPT_DIR/setup/flows/setup-wikipedia-search-ios.yaml\"\n        maestro test \"$SCRIPT_DIR/setup/flows/verify-ready-state.yaml\"\n        \n        echo \"✅ Wikipedia app environment ready for evaluations\"\n        ;;\n    \"demo_app\")\n        echo \"📱 Launching Demo App for evaluations...\"\n\n                # Use setup utilities for app environment\n        \"$SCRIPT_DIR/setup/download-and-install-apps.sh\" ios\n\n        cd \"$(dirname \"$SCRIPT_DIR\")/../../..\"\n        maestro test \"$SCRIPT_DIR/setup/flows/launch-demo-app-ios.yaml\"\n        echo \"✅ Demo App ready for evaluations\"\n        ;;\nesac\n\n# Run each eval file (from mcp directory so paths work correctly)\ncd \"$SCRIPT_DIR\"\n\neval_count=0\nfor eval_file in \"${eval_files[@]}\"; do\n    eval_count=$((eval_count + 1))\n    echo \"📋 Running eval $eval_count: $eval_file\"\n    \n    # Check if file exists, try relative to evals/ if not absolute\n    if [ ! -f \"$eval_file\" ]; then\n        if [ -f \"evals/$eval_file\" ]; then\n            eval_file=\"evals/$eval_file\"\n        else\n            echo \"❌ Error: Eval file not found: $eval_file\"\n            exit 1\n        fi\n    fi\n    \n    # Run the evals using MCP inspector\n    npx -y mcp-server-tester@1.3.1 evals \"$eval_file\" --server-config \"$CONFIG\"\ndone\n"
  },
  {
    "path": "maestro-cli/src/test/mcp/tool-tests-with-device.yaml",
    "content": "# yaml-language-server: $schema=https://raw.githubusercontent.com/steviec/mcp-server-tester/refs/heads/main/src/schemas/tests-schema.json\ntools:\n  tests:\n    - name: \"Test take_screenshot\"\n      tool: \"take_screenshot\"\n      params:\n        device_id: \"${DEVICE_ID}\"\n      expect:\n        success: true\n\n    - name: \"Test inspect_view_hierarchy\"\n      tool: \"inspect_view_hierarchy\"\n      params:\n        device_id: \"${DEVICE_ID}\"\n      expect:\n        success: true\n\n    - name: \"Test launch_app\"\n      tool: \"launch_app\"\n      params:\n        device_id: \"${DEVICE_ID}\"\n        appId: \"com.apple.mobilesafari\"\n      expect:\n        success: true\n\n    - name: \"Test tap_on\"\n      tool: \"tap_on\"\n      params:\n        device_id: \"${DEVICE_ID}\"\n        text: \"Search\"\n      expect:\n        success: true\n\n    - name: \"Test input_text\"\n      tool: \"input_text\"\n      params:\n        device_id: \"${DEVICE_ID}\"\n        text: \"hello\"\n      expect:\n        success: true\n\n    - name: \"Test stop_app\"\n      tool: \"stop_app\"\n      params:\n        device_id: \"${DEVICE_ID}\"\n        appId: \"com.apple.mobilesafari\"\n      expect:\n        success: true\n\n    - name: \"Test run_flow\"\n      tool: \"run_flow\"\n      params:\n        device_id: \"${DEVICE_ID}\"\n        flow_yaml: |\n          appId: com.apple.mobilesafari\n          ---\n          - launchApp\n      expect:\n        success: true\n    \n    - name: \"Test run_flow_files (expect failure - nonexistent file)\"\n      tool: \"run_flow_files\"\n      params:\n        device_id: \"${DEVICE_ID}\"\n        flow_files: \"nonexistent.yaml\"\n      expect:\n        success: false\n        error:\n          contains: \"not found\"\n\n    - name: \"Test run_flow_files with env replacement\"\n      tool: \"run_flow_files\"\n      params:\n        device_id: \"${DEVICE_ID}\"\n        flow_files: \"launch_app_with_env_replacement.yaml\"\n        env: \n          APP_ID: \"com.apple.mobilesafari\"\n      expect:\n        success: true\n"
  },
  {
    "path": "maestro-cli/src/test/mcp/tool-tests-without-device.yaml",
    "content": "# yaml-language-server: $schema=https://raw.githubusercontent.com/steviec/mcp-server-tester/refs/heads/main/src/schemas/tests-schema.json\ntools:\n  expected_tool_list:\n    - list_devices\n    - start_device\n    - launch_app\n    - take_screenshot\n    - tap_on\n    - input_text\n    - back\n    - stop_app\n    - run_flow\n    - run_flow_files\n    - check_flow_syntax\n    - inspect_view_hierarchy\n    - cheat_sheet\n    - query_docs\n\n  tests:\n    - name: \"List available devices\"\n      tool: \"list_devices\"\n      params: {}\n      expect:\n        success: true\n        result:\n          contains: \"device\"\n\n    - name: \"Get Maestro cheat sheet (expect API key required)\"\n      tool: \"cheat_sheet\"\n      params: {}\n      expect:\n        success: false\n        error:\n          contains: \"MAESTRO_CLOUD_API_KEY\"\n\n    - name: \"Query Maestro docs (expect API key required)\"\n      tool: \"query_docs\"\n      params:\n        question: \"How do I tap on an element?\"\n      expect:\n        success: false\n        error:\n          contains: \"MAESTRO_CLOUD_API_KEY\"\n\n    - name: \"Check valid flow syntax\"\n      tool: \"check_flow_syntax\"\n      params:\n        flow_yaml: |\n          appId: com.apple.mobilesafari\n          ---\n          - tapOn: \"Search\"\n          - inputText: \"hello world\"\n      expect:\n        success: true\n        result:\n          contains: \"valid\"\n\n    - name: \"Check invalid flow syntax\"\n      tool: \"check_flow_syntax\"\n      params:\n        flow_yaml: \"invalid[yaml\"\n      expect:\n        success: true\n        result:\n          contains: \"invalid\"\n\n    - name: \"Start iOS device\"\n      tool: \"start_device\"\n      params:\n        platform: \"ios\"\n      expect:\n        success: true\n        result:\n          contains: \"device_id\""
  },
  {
    "path": "maestro-cli/src/test/resources/apps/web-manifest.json",
    "content": "{\"url\":\"https://example.com\"}"
  },
  {
    "path": "maestro-cli/src/test/resources/location/assert_multiple_locations.yaml",
    "content": "appId: \"com.google.android.apps.maps\"\n---\n- launchApp\n- tapOn:\n    text: Skip\n    optional: true\n- setLocation:\n    longitude: 2.295188\n    latitude: 48.8578065\n- assertVisible: .*Eiffel.*\n- setLocation:\n    latitude: 43.7230\n    longitude: 10.3966\n- assertVisible: .*(Piazza|Pisa).*"
  },
  {
    "path": "maestro-cli/src/test/resources/travel/assert_travel_command.yaml",
    "content": "appId: \"com.google.android.apps.maps\"\n---\n- launchApp\n- tapOn:\n    text: Skip\n    optional: true\n- setLocation:\n    latitude: 48.8578065\n    longitude: 2.295188\n- assertVisible: .*Eiffel.*\n- travel:\n    points:\n      - 48.8578065, 2.295188\n      - 46.2276, 5.9900\n      - 43.7230, 10.3966\n      - 41.8902, 12.4922\n    speed: 1000\n- assertVisible: .*Colosseo.*\n\n\n\n\n\n\n\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/cloud_test/android/flow.yaml",
    "content": "appId: com.example.maestro.orientation\n---\n- launchApp\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/cloud_test/ios/flow.yaml",
    "content": "appId: com.example.SimpleWebViewApp\n---\n- launchApp\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/cloud_test/tagged/regression.yaml",
    "content": "appId: com.example.SimpleWebViewApp\ntags:\n  - regression\n---\n- launchApp\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/cloud_test/tagged/smoke.yaml",
    "content": "appId: com.example.SimpleWebViewApp\ntags:\n  - smoke\n---\n- launchApp\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/cloud_test/web/flow.yaml",
    "content": "appId: https://example.com\nurl: https://example.com\n---\n- launchApp\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/test_command_test/00_mixed_web_mobile_flow_tests/mobileflow.yaml",
    "content": "appId: com.example.mobileapp\n---\n- launchApp\n- tapOn: 'Button'\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/test_command_test/00_mixed_web_mobile_flow_tests/mobileflow2.yaml",
    "content": "appId: com.example.mobileapp2\n---\n- launchApp\n- tapOn: 'Submit'\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/test_command_test/00_mixed_web_mobile_flow_tests/webflow.yaml",
    "content": "url: https://example.com\n---\n- launchApp\n- tapOn: 'Button'\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/test_command_test/00_mixed_web_mobile_flow_tests/webflow2.yaml",
    "content": "url: https://example2.com\n---\n- launchApp\n- tapOn: 'Button'\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/test_command_test/01_web_only/webflow.yaml",
    "content": "appId: com.example.webapp\nurl: https://example.com\n---\n- launchApp\n- tapOn: 'Button'\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/test_command_test/01_web_only/webflow2.yaml",
    "content": "url: https://example2.com\n---\n- launchApp\n- tapOn: 'Button'\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/test_command_test/02_mobile_only/mobileflow1.yaml",
    "content": "appId: com.example.mobileapp1\n---\n- launchApp\n- tapOn: 'Button'\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/test_command_test/02_mobile_only/mobileflow2.yaml",
    "content": "appId: com.example.mobileapp2\n---\n- launchApp\n- tapOn: 'Submit'\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/test_command_test/03_mixed_with_config_execution_order/config.yaml",
    "content": "flows:\n  - 'subFolder/*'\nincludeTags:\n  - tagNameToInclude\nexcludeTags:\n  - tagNameToExclude\nexecutionOrder:\n  continueOnFailure: false\n  flowsOrder:\n    - mobileflow\n    - mobileflow2\n    - webflow\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/test_command_test/03_mixed_with_config_execution_order/subFolder/mobileflow.yaml",
    "content": "appId: com.example.mobileapp\nname: mobileflow\ntags:\n  - tagNameToInclude\n---\n- launchApp\n- tapOn: 'Button'\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/test_command_test/03_mixed_with_config_execution_order/subFolder/mobileflow2.yaml",
    "content": "appId: com.example.mobileapp2\nname: mobileflow2\nproperties:\n  property-1: property1 value\n  property-2: property2 value\ntags:\n  - tagNameToInclude\n---\n- launchApp\n- tapOn: 'Submit'\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/test_command_test/03_mixed_with_config_execution_order/subFolder/webflow.yaml",
    "content": "url: https://example.com\nname: webflow\ntags:\n  - tagNameToInclude\n---\n- launchApp\n- tapOn: 'Button'\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/test_command_test/03_mixed_with_config_execution_order/subFolder/webflow2.yaml",
    "content": "url: https://example2.com\nname: webflow2\ntags:\n  - tagNameToExclude\n---\n- launchApp\n- tapOn: 'Button'\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/test_command_test/04_web_only_with_config_execution_order/config.yaml",
    "content": "flows:\n  - 'subFolder/*'\nexcludeTags:\n  - tagNameToExclude\nexecutionOrder:\n  continueOnFailure: false\n  flowsOrder:\n    - mobileflow\n    - mobileflow2\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/test_command_test/04_web_only_with_config_execution_order/subFolder/mobileflow.yaml",
    "content": "appId: com.example.mobileapp\nname: mobileflow\ntags:\n  - tagNameToInclude\n---\n- launchApp\n- tapOn: 'Button'\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/test_command_test/04_web_only_with_config_execution_order/subFolder/mobileflow2.yaml",
    "content": "appId: com.example.mobileapp2\nname: mobileflow2\ntags:\n  - tagNameToInclude\n---\n- launchApp\n- tapOn: 'Submit'\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/test_command_test/04_web_only_with_config_execution_order/subFolder/webflow.yaml",
    "content": "url: https://example.com\nname: webflow\ntags:\n  - tagNameToExclude\n---\n- launchApp\n- tapOn: 'Button'\n"
  },
  {
    "path": "maestro-cli/src/test/resources/workspaces/test_command_test/04_web_only_with_config_execution_order/subFolder/webflow2.yaml",
    "content": "url: https://example2.com\nname: webflow2\ntags:\n  - tagNameToExclude\n---\n- launchApp\n- tapOn: 'Button'\n"
  },
  {
    "path": "maestro-client/build.gradle.kts",
    "content": "import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask\n\nplugins {\n    id(\"maven-publish\")\n    alias(libs.plugins.kotlin.jvm)\n    alias(libs.plugins.mavenPublish)\n    alias(libs.plugins.protobuf)\n}\n\nprotobuf {\n    protoc {\n        artifact = \"com.google.protobuf:protoc:${libs.versions.googleProtobuf.get()}\"\n    }\n\n    plugins {\n        create(\"grpc\") {\n            artifact = \"io.grpc:protoc-gen-grpc-java:${libs.versions.grpc.get()}\"\n        }\n    }\n\n    generateProtoTasks {\n        all().forEach { task ->\n            task.plugins {\n                create(\"grpc\")\n            }\n\n            task.builtins {\n                create(\"kotlin\")\n            }\n        }\n    }\n}\n\ntasks.named(\"compileKotlin\") {\n    dependsOn(\"generateProto\")\n}\n\ntasks.named(\"processResources\") {\n    dependsOn(\":maestro-android:copyMaestroAndroid\")\n}\n\ntasks.whenTaskAdded {\n    if (name == \"sourcesJar\" && this is Jar) {\n        dependsOn(\":maestro-android:copyMaestroAndroid\")\n        duplicatesStrategy = DuplicatesStrategy.EXCLUDE\n    }\n}\n\nkotlin.sourceSets.all {\n    // Prevent build warnings for grpc's generated opt-in code\n    languageSettings.optIn(\"kotlin.RequiresOptIn\")\n}\n\nsourceSets {\n    main {\n        java {\n            srcDirs(\n                \"build/generated/source/proto/main/grpc\",\n                \"build/generated/source/proto/main/java\",\n                \"build/generated/source/proto/main/kotlin\"\n            )\n        }\n    }\n}\n\ndependencies {\n    protobuf(project(\":maestro-proto\"))\n    implementation(project(\":maestro-utils\"))\n    implementation(project(\":maestro-ios-driver\"))\n\n    api(libs.graaljs)\n    api(libs.graaljsEngine)\n    api(libs.graaljsLanguage)\n\n    api(libs.grpc.kotlin.stub)\n    api(libs.grpc.stub)\n    api(libs.grpc.netty)\n    api(libs.grpc.protobuf)\n    api(libs.grpc.okhttp)\n    api(libs.google.protobuf.kotlin)\n    api(libs.kotlin.result)\n    api(libs.dadb)\n    api(libs.square.okio)\n    api(libs.square.okio.jvm)\n    api(libs.image.comparison)\n    api(libs.mozilla.rhino)\n    api(libs.square.okhttp)\n    api(libs.jarchivelib)\n    api(libs.jackson.core.databind)\n    api(libs.jackson.module.kotlin)\n    api(libs.jackson.dataformat.yaml)\n    api(libs.jackson.dataformat.xml)\n    api(libs.apk.parser)\n\n    implementation(project(\":maestro-ios\"))\n    implementation(project(\":maestro-web\"))\n    implementation(libs.google.findbugs)\n    implementation(libs.axml)\n    implementation(libs.selenium)\n    implementation(libs.selenium.devtools)\n    implementation(libs.jcodec)\n    implementation(libs.datafaker)\n\n    api(libs.logging.sl4j)\n    api(libs.logging.api)\n    api(libs.logging.layout.template)\n    api(libs.log4j.core)\n\n    testImplementation(libs.junit.jupiter.api)\n    testRuntimeOnly(libs.junit.jupiter.engine)\n    testImplementation(libs.google.truth)\n    testImplementation(libs.square.mock.server)\n    testImplementation(libs.junit.jupiter.params)\n}\n\njava {\n    sourceCompatibility = JavaVersion.VERSION_17\n    targetCompatibility = JavaVersion.VERSION_17\n}\n\nkotlin {\n    jvmToolchain {\n        languageVersion.set(JavaLanguageVersion.of(17))\n    }\n}\n\ntasks.named(\"compileKotlin\", KotlinCompilationTask::class.java) {\n    compilerOptions {\n        freeCompilerArgs.addAll(\"-Xjdk-release=17\")\n    }\n}\n\nmavenPublishing {\n    publishToMavenCentral(true)\n    signAllPublications()\n}\n\ntasks.named<Test>(\"test\") {\n    useJUnitPlatform()\n}\n"
  },
  {
    "path": "maestro-client/gradle.properties",
    "content": "POM_NAME=Maestro Client\nPOM_ARTIFACT_ID=maestro-client\nPOM_PACKAGING=jar"
  },
  {
    "path": "maestro-client/src/main/java/maestro/Bounds.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro\n\ndata class Bounds(\n    val x: Int,\n    val y: Int,\n    val width: Int,\n    val height: Int\n) {\n\n    fun center(): Point {\n        return Point(\n            x = x + width / 2,\n            y = y + height / 2\n        )\n    }\n\n    fun area(): Int {\n        return width * height\n    }\n\n    fun contains(x: Int, y: Int): Boolean {\n        return x in this.x until this.x + width\n            && y in this.y until this.y + height\n    }\n\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/Capability.kt",
    "content": "package maestro\n\nenum class Capability {\n    FAST_HIERARCHY,\n}"
  },
  {
    "path": "maestro-client/src/main/java/maestro/DeviceInfo.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro\n\nimport maestro.device.Platform\n\ndata class DeviceInfo(\n    val platform: Platform,\n    val widthPixels: Int,\n    val heightPixels: Int,\n    val widthGrid: Int,\n    val heightGrid: Int,\n)\n\nfun xcuitest.api.DeviceInfo.toCommonDeviceInfo(): DeviceInfo {\n    return DeviceInfo(\n        platform = Platform.IOS,\n        widthPixels = widthPixels,\n        heightPixels = heightPixels,\n        widthGrid = widthPoints,\n        heightGrid = heightPoints,\n    )\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/Driver.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro\n\nimport maestro.device.DeviceOrientation\nimport okio.Sink\nimport java.io.File\n\ninterface Driver {\n\n    fun name(): String\n\n    fun open()\n\n    fun close()\n\n    fun deviceInfo(): DeviceInfo\n\n    fun launchApp(\n        appId: String,\n        launchArguments: Map<String, Any>,\n    )\n\n    fun stopApp(appId: String)\n\n    fun killApp(appId: String)\n\n    fun clearAppState(appId: String)\n\n    fun clearKeychain()\n\n    fun tap(point: Point)\n\n    fun longPress(point: Point)\n\n    fun pressKey(code: KeyCode)\n\n    fun contentDescriptor(excludeKeyboardElements: Boolean = false): TreeNode\n\n    fun scrollVertical()\n\n    fun isKeyboardVisible(): Boolean\n\n    fun swipe(start: Point, end: Point, durationMs: Long)\n\n    fun swipe(swipeDirection: SwipeDirection, durationMs: Long)\n\n    fun swipe(elementPoint: Point, direction: SwipeDirection, durationMs: Long)\n\n    fun backPress()\n\n    fun inputText(text: String)\n\n    fun openLink(link: String, appId: String?, autoVerify: Boolean, browser: Boolean)\n\n    fun hideKeyboard()\n\n    fun takeScreenshot(out: Sink, compressed: Boolean)\n\n    fun startScreenRecording(out: Sink): ScreenRecording\n\n    fun setLocation(latitude: Double, longitude: Double)\n\n    fun setOrientation(orientation: DeviceOrientation)\n\n    fun eraseText(charactersToErase: Int)\n\n    fun setProxy(host: String, port: Int)\n\n    fun resetProxy()\n\n    fun isShutdown(): Boolean\n\n    fun isUnicodeInputSupported(): Boolean\n\n    fun waitUntilScreenIsStatic(timeoutMs: Long): Boolean\n\n    fun waitForAppToSettle(initialHierarchy: ViewHierarchy?, appId: String?, timeoutMs: Int? = null): ViewHierarchy?\n\n    fun capabilities(): List<Capability>\n\n    fun setPermissions(appId: String, permissions: Map<String, String>)\n\n    fun addMedia(mediaFiles: List<File>)\n\n    fun isAirplaneModeEnabled(): Boolean\n\n    fun setAirplaneMode(enabled: Boolean)\n\n    fun setAndroidChromeDevToolsEnabled(enabled: Boolean) = Unit\n\n    fun queryOnDeviceElements(query: OnDeviceElementQuery): List<TreeNode> {\n        return listOf()\n    }\n\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/Errors.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro\n\nsealed class MaestroException(override val message: String, cause: Throwable? = null) : RuntimeException(message, cause) {\n\n    class UnableToLaunchApp(message: String, cause: Throwable? = null) : MaestroException(message, cause)\n\n    class UnableToClearState(message: String, cause: Throwable? = null) : MaestroException(message, cause)\n\n    class UnableToSetPermissions(message: String, cause: Throwable? = null) : MaestroException(message, cause)\n\n    class AppCrash(message: String, cause: Throwable? = null): MaestroException(message, cause)\n\n    class DriverTimeout(message: String, val debugMessage: String? = null, cause: Throwable? = null): MaestroException(message, cause)\n\n    open class AssertionFailure(\n        message: String,\n        val hierarchyRoot: TreeNode,\n        val debugMessage: String,\n        cause: Throwable? = null,\n    ) : MaestroException(message, cause)\n\n    class ElementNotFound(\n        message: String,\n        hierarchyRoot: TreeNode,\n        debugMessage: String,\n        cause: Throwable? = null,\n    ) : AssertionFailure(message, hierarchyRoot, debugMessage, cause)\n\n    class CloudApiKeyNotAvailable(message: String, cause: Throwable? = null) : MaestroException(message, cause)\n\n    class DestinationIsNotWritable(message: String, cause: Throwable? = null) : MaestroException(message, cause)\n\n    class UnableToCopyTextFromElement(message: String, cause: Throwable? = null): MaestroException(message, cause)\n\n    class InvalidCommand(\n        message: String,\n        cause: Throwable? = null,\n    ) : MaestroException(message, cause)\n\n    class HideKeyboardFailure(message: String, cause: Throwable? = null, val debugMessage: String) : MaestroException(message, cause)\n\n    class NoRootAccess(message: String, cause: Throwable? = null) : MaestroException(message, cause)\n\n    class UnsupportedJavaVersion(message: String, cause: Throwable? = null) : MaestroException(message, cause)\n\n    class MissingAppleTeamId(message: String, cause: Throwable? = null): MaestroException(message, cause)\n\n    class IOSDeviceDriverSetupException(message: String, cause: Throwable? = null): MaestroException(message, cause)\n}\n\nsealed class MaestroDriverStartupException(override val message: String, cause: Throwable? = null): RuntimeException(message, cause) {\n    class AndroidDriverTimeoutException(message: String, cause: Throwable? = null): MaestroDriverStartupException(message, cause)\n    class AndroidInstrumentationSetupFailure(message: String, cause: Throwable? = null): MaestroDriverStartupException(message, cause)\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/Filters.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro\n\nimport maestro.UiElement.Companion.toUiElement\nimport maestro.UiElement.Companion.toUiElementOrNull\nimport kotlin.math.abs\n\ntypealias ElementFilter = (List<TreeNode>) -> List<TreeNode>\n\ntypealias ElementLookupPredicate = (TreeNode) -> Boolean\n\nobject Filters {\n\n    val INDEX_COMPARATOR: Comparator<TreeNode> = compareBy(\n        { it.toUiElementOrNull()?.bounds?.y ?: Int.MAX_VALUE },\n        { it.toUiElementOrNull()?.bounds?.x ?: Int.MAX_VALUE },\n    )\n\n    fun intersect(filters: List<ElementFilter>): ElementFilter = { nodes ->\n        filters\n            .map { it(nodes).toSet() }\n            .reduceOrNull { a, b -> a.intersect(b) }\n            ?.toList() ?: nodes\n    }\n\n    fun compose(first: ElementFilter, second: ElementFilter): ElementFilter = compose(listOf(first, second))\n\n    fun compose(filters: List<ElementFilter>): ElementFilter = { nodes ->\n        filters\n            .fold(nodes) { acc, filter ->\n                filter(acc)\n            }\n    }\n\n    fun ElementLookupPredicate.asFilter(): ElementFilter = { nodes ->\n        nodes.filter { this(it) }\n    }\n\n    fun nonClickable(): ElementFilter {\n        return { nodes -> nodes.filter { it.clickable == false } }\n    }\n\n    fun textMatches(regex: Regex): ElementFilter {\n        return { nodes ->\n            val textMatches = nodes.filter {\n                it.attributes[\"text\"]?.let { value ->\n                    val strippedValue = value.replace('\\n', ' ')\n\n                    regex.matches(value)\n                            || regex.pattern == value\n                            || regex.matches(strippedValue)\n                            || regex.pattern == strippedValue\n                } ?: false\n            }.toSet()\n\n            val hintTextMatches = nodes.filter {\n                it.attributes[\"hintText\"]?.let { value ->\n                    val strippedValue = value.replace('\\n', ' ')\n\n                    regex.matches(value)\n                            || regex.pattern == value\n                            || regex.matches(strippedValue)\n                            || regex.pattern == strippedValue\n                } ?: false\n            }\n\n            val accessibilityTextMatches = nodes.filter {\n                it.attributes[\"accessibilityText\"]?.let { value ->\n                    val strippedValue = value.replace('\\n', ' ')\n\n                    regex.matches(value)\n                            || regex.pattern == value\n                            || regex.matches(strippedValue)\n                            || regex.pattern == strippedValue\n                } ?: false\n            }.toSet()\n\n            textMatches.union(hintTextMatches).union(accessibilityTextMatches).toList()\n        }\n    }\n\n    fun idMatches(regex: Regex): ElementFilter {\n        return { nodes ->\n            val exactMatches = nodes\n                .filter {\n                    it.attributes[\"resource-id\"]?.let { value ->\n                        regex.matches(value)\n                    } ?: false\n                }\n                .toSet()\n\n            val idWithoutPrefixMatches = nodes\n                .filter {\n                    it.attributes[\"resource-id\"]?.let { value ->\n                        regex.matches(value.substringAfterLast('/'))\n                    } ?: false\n                }\n                .toSet()\n\n            exactMatches\n                .union(idWithoutPrefixMatches)\n                .toList()\n        }\n    }\n\n    fun sizeMatches(\n        width: Int? = null,\n        height: Int? = null,\n        tolerance: Int? = null,\n    ): ElementLookupPredicate {\n        fun predicate(it: TreeNode): Boolean {\n            if (it.attributes[\"bounds\"] == null) {\n                return false\n            }\n\n            val uiElement = it.toUiElement()\n\n            val finalTolerance = tolerance ?: 0\n            if (width != null) {\n                if (abs(uiElement.bounds.width - width) > finalTolerance) {\n                    return false\n                }\n            }\n\n            if (height != null) {\n                if (abs(uiElement.bounds.height - height) > finalTolerance) {\n                    return false\n                }\n            }\n\n            return true\n        }\n\n        return { predicate(it) }\n    }\n\n    fun below(otherFilter: ElementFilter): ElementFilter {\n        return relativeTo(otherFilter) { it, other -> it.bounds.y > other.bounds.y }\n    }\n\n    fun above(otherFilter: ElementFilter): ElementFilter {\n        return relativeTo(otherFilter) { it, other -> it.bounds.y < other.bounds.y }\n    }\n\n    fun leftOf(otherFilter: ElementFilter): ElementFilter {\n        return relativeTo(otherFilter) { it, other -> it.bounds.x < other.bounds.x }\n    }\n\n    fun rightOf(otherFilter: ElementFilter): ElementFilter {\n        return relativeTo(otherFilter) { it, other -> it.bounds.x > other.bounds.x }\n    }\n\n    fun relativeTo(otherFilter: ElementFilter, predicate: (UiElement, UiElement) -> Boolean): ElementFilter {\n        return { nodes ->\n            val matchingOthers = otherFilter(nodes)\n                .mapNotNull { it.toUiElementOrNull() }\n\n            nodes\n                .mapNotNull { it.toUiElementOrNull() }\n                .flatMap {\n                    matchingOthers\n                        .filter { other -> predicate(it, other) }\n                        .map { other -> it to it.distanceTo(other) }\n                }\n                .sortedBy { (_, distance) -> distance }\n                .map { (element, _) -> element.treeNode }\n        }\n    }\n\n    fun containsChild(other: UiElement): ElementLookupPredicate {\n        val otherNode = other.treeNode\n        return {\n            it.children\n                .any { child -> child == otherNode }\n        }\n    }\n\n    fun containsDescendants(filters: List<ElementFilter>): ElementFilter {\n        fun ElementFilter.matches(node: TreeNode): Boolean {\n            return invoke(listOf(node)).isNotEmpty() || node.children.any { matches(it) }\n        }\n        return { nodes ->\n            nodes.filter { node ->\n                filters.all { filter ->\n                    node.children.any { filter.matches(it) }\n                }\n            }\n        }\n    }\n\n    fun hasText(): ElementLookupPredicate {\n        return {\n            it.attributes[\"text\"] != null\n        }\n    }\n\n    fun isSquare(): ElementLookupPredicate {\n        return {\n            it.toUiElementOrNull()\n                ?.let { element ->\n                    abs(1.0f - (element.bounds.width / element.bounds.height.toFloat())) < 0.03f\n                } ?: false\n        }\n    }\n\n    fun hasLongText(): ElementLookupPredicate {\n        return {\n            (it.attributes[\"text\"]?.length ?: 0) > 200\n        }\n    }\n\n    fun index(idx: Int): ElementFilter {\n        return { nodes ->\n            val sortedNodes = nodes.sortedWith(INDEX_COMPARATOR)\n            val resolvedIndex = if (idx >= 0) idx else sortedNodes.size + idx\n\n            if (resolvedIndex < 0) {\n                emptyList()\n            } else {\n                listOfNotNull(sortedNodes.getOrNull(resolvedIndex))\n            }\n        }\n    }\n\n    fun clickableFirst(): ElementFilter {\n        return { nodes ->\n            nodes.sortedByDescending { it.clickable }\n        }\n    }\n\n    fun enabled(expected: Boolean): ElementFilter {\n        return { nodes ->\n            nodes.filter { it.enabled == expected }\n        }\n    }\n\n    fun selected(expected: Boolean): ElementFilter {\n        return { nodes ->\n            nodes.filter { it.selected == expected }\n        }\n    }\n\n    fun checked(expected: Boolean): ElementFilter {\n        return { nodes ->\n            nodes.filter { it.checked == expected }\n        }\n    }\n\n    fun focused(expected: Boolean): ElementFilter {\n        return { nodes ->\n            nodes.filter { it.focused == expected }\n        }\n    }\n\n    fun deepestMatchingElement(filter: ElementFilter): ElementFilter {\n        return { nodes ->\n            nodes.flatMap { node ->\n                val matchingChildren = deepestMatchingElement(filter)(node.children)\n                if (matchingChildren.isNotEmpty()) {\n                    matchingChildren\n                } else if (filter(listOf(node)).isNotEmpty()) {\n                    listOf(node)\n                } else {\n                    emptyList()\n                }\n            }.distinct()\n        }\n    }\n\n    fun css(maestro: Maestro, cssSelector: String): ElementFilter {\n        return { nodes ->\n            val matchingNodes = maestro.findElementsByOnDeviceQuery(\n                timeoutMs = 5000,\n                query = OnDeviceElementQuery.Css(css = cssSelector),\n            )?.elements?.map { it.treeNode } ?: emptyList()\n\n            nodes.filter { node ->\n                matchingNodes.any { it == node }\n            }\n        }\n    }\n\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/FindElementResult.kt",
    "content": "package maestro\n\ndata class FindElementResult(val element: UiElement, val hierarchy: ViewHierarchy)"
  },
  {
    "path": "maestro-client/src/main/java/maestro/KeyCode.kt",
    "content": "package maestro\n\nenum class KeyCode(\n    val description: String,\n) {\n\n    ENTER(\"Enter\"),\n    BACKSPACE(\"Backspace\"),\n    BACK(\"Back\"),\n    HOME(\"Home\"),\n    LOCK(\"Lock\"),\n    VOLUME_UP(\"Volume Up\"),\n    VOLUME_DOWN(\"Volume Down\"),\n    REMOTE_UP(\"Remote Dpad Up\"),\n    REMOTE_DOWN(\"Remote Dpad Down\"),\n    REMOTE_LEFT(\"Remote Dpad Left\"),\n    REMOTE_RIGHT(\"Remote Dpad Right\"),\n    REMOTE_CENTER(\"Remote Dpad Center\"),\n    REMOTE_PLAY_PAUSE(\"Remote Media Play Pause\"),\n    REMOTE_STOP(\"Remote Media Stop\"),\n    REMOTE_NEXT(\"Remote Media Next\"),\n    REMOTE_PREVIOUS(\"Remote Media Previous\"),\n    REMOTE_REWIND(\"Remote Media Rewind\"),\n    REMOTE_FAST_FORWARD(\"Remote Media Fast Forward\"),\n    ESCAPE(\"Escape\"),\n    POWER(\"Power\"),\n    TAB(\"Tab\"),\n    REMOTE_SYSTEM_NAVIGATION_UP(\"Remote System Navigation Up\"),\n    REMOTE_SYSTEM_NAVIGATION_DOWN(\"Remote System Navigation Down\"),\n    REMOTE_BUTTON_A(\"Remote Button A\"),\n    REMOTE_BUTTON_B(\"Remote Button B\"),\n    REMOTE_MENU(\"Remote Menu\"),\n    TV_INPUT(\"TV Input\"),\n    TV_INPUT_HDMI_1(\"TV Input HDMI 1\"),\n    TV_INPUT_HDMI_2(\"TV Input HDMI 2\"),\n    TV_INPUT_HDMI_3(\"TV Input HDMI 3\");\n\n    companion object {\n        fun getByName(name: String): KeyCode? {\n            val lowercaseName = name.lowercase()\n            return values().find { it.description.lowercase() == lowercaseName }\n        }\n    }\n\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/Maestro.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro\n\nimport com.github.romankh3.image.comparison.ImageComparison\nimport maestro.UiElement.Companion.toUiElementOrNull\nimport maestro.device.DeviceOrientation\nimport maestro.drivers.CdpWebDriver\nimport maestro.utils.MaestroTimer\nimport maestro.utils.ScreenshotUtils\nimport maestro.utils.SocketUtils\nimport okio.Buffer\nimport okio.Sink\nimport okio.buffer\nimport okio.sink\nimport okio.use\nimport org.slf4j.LoggerFactory\nimport java.awt.image.BufferedImage\nimport java.io.File\nimport javax.imageio.ImageIO\nimport kotlin.system.measureTimeMillis\n\n@Suppress(\"unused\", \"MemberVisibilityCanBePrivate\")\nclass Maestro(\n    val driver: Driver,\n) : AutoCloseable {\n\n    val deviceName: String\n        get() = driver.name()\n\n    val cachedDeviceInfo by lazy {\n        LOGGER.info(\"Getting device info\")\n        val deviceInfo = driver.deviceInfo()\n        LOGGER.info(\"Got device info: $deviceInfo\")\n        deviceInfo\n    }\n\n    @Deprecated(\"This function should be removed and its usages refactored. See issue #2031\")\n    fun deviceInfo() = driver.deviceInfo()\n\n    private var screenRecordingInProgress = false\n\n    fun launchApp(\n        appId: String,\n        launchArguments: Map<String, Any> = emptyMap(),\n        stopIfRunning: Boolean = true\n    ) {\n        LOGGER.info(\"Launching app $appId\")\n\n        if (stopIfRunning) {\n            LOGGER.info(\"Stopping $appId app during launch\")\n            driver.stopApp(appId)\n        }\n        driver.launchApp(appId, launchArguments)\n    }\n\n    fun stopApp(appId: String) {\n        LOGGER.info(\"Stopping app $appId\")\n\n        driver.stopApp(appId)\n    }\n\n    fun killApp(appId: String) {\n        LOGGER.info(\"Killing app $appId\")\n\n        driver.killApp(appId)\n    }\n\n    fun clearAppState(appId: String) {\n        LOGGER.info(\"Clearing app state $appId\")\n\n        driver.clearAppState(appId)\n    }\n\n    fun setPermissions(appId: String, permissions: Map<String, String>) {\n        driver.setPermissions(appId, permissions)\n    }\n\n    fun clearKeychain() {\n        LOGGER.info(\"Clearing keychain\")\n\n        driver.clearKeychain()\n    }\n\n    fun backPress() {\n        LOGGER.info(\"Pressing back\")\n\n        driver.backPress()\n        waitForAppToSettle()\n    }\n\n    fun hideKeyboard() {\n        LOGGER.info(\"Hiding Keyboard\")\n\n        driver.hideKeyboard()\n    }\n\n    fun isKeyboardVisible(): Boolean {\n        return driver.isKeyboardVisible()\n    }\n\n    fun swipe(\n        swipeDirection: SwipeDirection? = null,\n        startPoint: Point? = null,\n        endPoint: Point? = null,\n        startRelative: String? = null,\n        endRelative: String? = null,\n        duration: Long,\n        waitToSettleTimeoutMs: Int? = null\n    ) {\n        val deviceInfo = deviceInfo()\n\n        when {\n            swipeDirection != null -> driver.swipe(swipeDirection, duration)\n            startPoint != null && endPoint != null -> driver.swipe(startPoint, endPoint, duration)\n            startRelative != null && endRelative != null -> {\n                val startPoints = startRelative.replace(\"%\", \"\")\n                    .split(\",\").map { it.trim().toInt() }\n                val startX = deviceInfo.widthGrid * startPoints[0] / 100\n                val startY = deviceInfo.heightGrid * startPoints[1] / 100\n                val start = Point(startX, startY)\n\n                val endPoints = endRelative.replace(\"%\", \"\")\n                    .split(\",\").map { it.trim().toInt() }\n                val endX = deviceInfo.widthGrid * endPoints[0] / 100\n                val endY = deviceInfo.heightGrid * endPoints[1] / 100\n                val end = Point(endX, endY)\n\n                driver.swipe(start, end, duration)\n            }\n        }\n\n        waitForAppToSettle(waitToSettleTimeoutMs = waitToSettleTimeoutMs)\n    }\n\n    fun swipe(swipeDirection: SwipeDirection, uiElement: UiElement, durationMs: Long, waitToSettleTimeoutMs: Int?) {\n        LOGGER.info(\"Swiping ${swipeDirection.name} on element: $uiElement\")\n        driver.swipe(uiElement.bounds.center(), swipeDirection, durationMs)\n\n        waitForAppToSettle(waitToSettleTimeoutMs = waitToSettleTimeoutMs)\n    }\n\n    fun swipeFromCenter(swipeDirection: SwipeDirection, durationMs: Long, waitToSettleTimeoutMs: Int?) {\n        val deviceInfo = deviceInfo()\n\n        LOGGER.info(\"Swiping ${swipeDirection.name} from center\")\n        val center = Point(x = deviceInfo.widthGrid / 2, y = deviceInfo.heightGrid / 2)\n        driver.swipe(center, swipeDirection, durationMs)\n        waitForAppToSettle(waitToSettleTimeoutMs = waitToSettleTimeoutMs)\n    }\n\n    fun scrollVertical() {\n        LOGGER.info(\"Scrolling vertically\")\n\n        driver.scrollVertical()\n        waitForAppToSettle()\n    }\n\n    fun tap(\n        element: UiElement,\n        initialHierarchy: ViewHierarchy,\n        retryIfNoChange: Boolean = false,\n        waitUntilVisible: Boolean = false,\n        longPress: Boolean = false,\n        appId: String? = null,\n        tapRepeat: TapRepeat? = null,\n        waitToSettleTimeoutMs: Int? = null\n    ) {\n        LOGGER.info(\"Tapping on element: ${tapRepeat ?: \"\"} $element\")\n\n        val hierarchyBeforeTap = waitForAppToSettle(initialHierarchy, appId, waitToSettleTimeoutMs) ?: initialHierarchy\n\n        val center = (\n                hierarchyBeforeTap\n                    .refreshElement(element.treeNode)\n                    ?.also { LOGGER.info(\"Refreshed element\") }\n                    ?.toUiElementOrNull()\n                    ?: element\n                ).bounds\n            .center()\n        performTap(\n            x = center.x,\n            y = center.y,\n            retryIfNoChange = retryIfNoChange,\n            longPress = longPress,\n            initialHierarchy = hierarchyBeforeTap,\n            tapRepeat = tapRepeat,\n            waitToSettleTimeoutMs = waitToSettleTimeoutMs\n        )\n\n        if (waitUntilVisible) {\n            val hierarchyAfterTap = viewHierarchy()\n\n            if (hierarchyBeforeTap == hierarchyAfterTap\n                && !hierarchyAfterTap.isVisible(element.treeNode)\n            ) {\n                LOGGER.info(\"Still no change in hierarchy. Wait until element is visible and try again.\")\n\n                val hierarchy = waitUntilVisible(element)\n\n                tap(\n                    element = element,\n                    initialHierarchy = hierarchy,\n                    retryIfNoChange = false,\n                    waitUntilVisible = false,\n                    longPress = longPress,\n                    tapRepeat = tapRepeat\n                )\n            }\n        }\n    }\n\n    fun tapOnRelative(\n        percentX: Int,\n        percentY: Int,\n        retryIfNoChange: Boolean = false,\n        longPress: Boolean = false,\n        tapRepeat: TapRepeat? = null,\n        waitToSettleTimeoutMs: Int? = null\n    ) {\n        val deviceInfo = driver.deviceInfo()\n        val x = deviceInfo.widthGrid * percentX / 100\n        val y = deviceInfo.heightGrid * percentY / 100\n        tap(\n            x = x,\n            y = y,\n            retryIfNoChange = retryIfNoChange,\n            longPress = longPress,\n            tapRepeat = tapRepeat,\n            waitToSettleTimeoutMs = waitToSettleTimeoutMs\n        )\n    }\n\n    fun tap(\n        x: Int,\n        y: Int,\n        retryIfNoChange: Boolean = false,\n        longPress: Boolean = false,\n        tapRepeat: TapRepeat? = null,\n        waitToSettleTimeoutMs: Int? = null\n    ) {\n        performTap(\n            x = x,\n            y = y,\n            retryIfNoChange = retryIfNoChange,\n            longPress = longPress,\n            tapRepeat = tapRepeat,\n            waitToSettleTimeoutMs = waitToSettleTimeoutMs\n        )\n    }\n\n    private fun getNumberOfRetries(retryIfNoChange: Boolean): Int {\n        return if (retryIfNoChange) 2 else 1\n    }\n\n    private fun performTap(\n        x: Int,\n        y: Int,\n        retryIfNoChange: Boolean = false,\n        longPress: Boolean = false,\n        initialHierarchy: ViewHierarchy? = null,\n        tapRepeat: TapRepeat? = null,\n        waitToSettleTimeoutMs: Int? = null\n    ) {\n        val capabilities = driver.capabilities()\n\n        if (Capability.FAST_HIERARCHY in capabilities) {\n            hierarchyBasedTap(x, y, retryIfNoChange, longPress, initialHierarchy, tapRepeat, waitToSettleTimeoutMs)\n        } else {\n            screenshotBasedTap(x, y, retryIfNoChange, longPress, initialHierarchy, tapRepeat, waitToSettleTimeoutMs)\n        }\n    }\n\n    private fun hierarchyBasedTap(\n        x: Int,\n        y: Int,\n        retryIfNoChange: Boolean = false,\n        longPress: Boolean = false,\n        initialHierarchy: ViewHierarchy? = null,\n        tapRepeat: TapRepeat? = null,\n        waitToSettleTimeoutMs: Int? = null\n    ) {\n        LOGGER.info(\"Tapping at ($x, $y) using hierarchy based logic for wait\")\n\n        val hierarchyBeforeTap = initialHierarchy ?: viewHierarchy()\n\n        val retries = getNumberOfRetries(retryIfNoChange)\n        repeat(retries) {\n            if (longPress) {\n                driver.longPress(Point(x, y))\n            } else if (tapRepeat != null) {\n                for (i in 0 until tapRepeat.repeat) {\n\n                    // subtract execution duration from tap delay\n                    val duration = measureTimeMillis { driver.tap(Point(x, y)) }\n                    val delay = if (duration >= tapRepeat.delay) 0 else tapRepeat.delay - duration\n\n                    if (tapRepeat.repeat > 1) Thread.sleep(delay) // do not wait for single taps\n                }\n            } else driver.tap(Point(x, y))\n            val hierarchyAfterTap = waitForAppToSettle(waitToSettleTimeoutMs = waitToSettleTimeoutMs)\n\n            if (hierarchyAfterTap == null || hierarchyBeforeTap != hierarchyAfterTap) {\n                LOGGER.info(\"Something has changed in the UI judging by view hierarchy. Proceed.\")\n                return\n            }\n        }\n    }\n\n    private fun screenshotBasedTap(\n        x: Int,\n        y: Int,\n        retryIfNoChange: Boolean = false,\n        longPress: Boolean = false,\n        initialHierarchy: ViewHierarchy? = null,\n        tapRepeat: TapRepeat? = null,\n        waitToSettleTimeoutMs: Int? = null\n    ) {\n        LOGGER.info(\"Try tapping at ($x, $y) using hierarchy based logic for wait\")\n\n        val hierarchyBeforeTap = initialHierarchy ?: viewHierarchy()\n        val screenshotBeforeTap: BufferedImage? = ScreenshotUtils.tryTakingScreenshot(driver)\n\n        val retries = getNumberOfRetries(retryIfNoChange)\n        repeat(retries) {\n            if (longPress) {\n                driver.longPress(Point(x, y))\n            } else if (tapRepeat != null) {\n                for (i in 0 until tapRepeat.repeat) {\n\n                    // subtract execution duration from tap delay\n                    val duration = measureTimeMillis { driver.tap(Point(x, y)) }\n                    val delay = if (duration >= tapRepeat.delay) 0 else tapRepeat.delay - duration\n\n                    if (tapRepeat.repeat > 1) Thread.sleep(delay) // do not wait for single taps\n                }\n            } else {\n                driver.tap(Point(x, y))\n            }\n            val hierarchyAfterTap = waitForAppToSettle(waitToSettleTimeoutMs = waitToSettleTimeoutMs)\n\n            if (hierarchyBeforeTap != hierarchyAfterTap) {\n                LOGGER.info(\"Something have changed in the UI judging by view hierarchy. Proceed.\")\n                return\n            }\n\n            LOGGER.info(\"Tapping at ($x, $y) using screenshot based logic for wait\")\n\n            val screenshotAfterTap: BufferedImage? = ScreenshotUtils.tryTakingScreenshot(driver)\n            if (screenshotBeforeTap != null &&\n                screenshotAfterTap != null &&\n                screenshotBeforeTap.width == screenshotAfterTap.width &&\n                screenshotBeforeTap.height == screenshotAfterTap.height\n            ) {\n                val imageDiff = ImageComparison(\n                    screenshotBeforeTap,\n                    screenshotAfterTap\n                ).compareImages().differencePercent\n\n                if (imageDiff > SCREENSHOT_DIFF_THRESHOLD) {\n                    LOGGER.info(\"Something have changed in the UI judging by screenshot (d=$imageDiff). Proceed.\")\n                    return\n                } else {\n                    LOGGER.info(\"Screenshots are not different enough (d=$imageDiff)\")\n                }\n            } else {\n                LOGGER.info(\"Skipping screenshot comparison\")\n            }\n\n            LOGGER.info(\"Nothing changed in the UI.\")\n        }\n    }\n\n    private fun waitUntilVisible(element: UiElement): ViewHierarchy {\n        var hierarchy = ViewHierarchy(TreeNode())\n        repeat(10) {\n            hierarchy = viewHierarchy()\n            if (!hierarchy.isVisible(element.treeNode)) {\n                LOGGER.info(\"Element is not visible yet. Waiting.\")\n                MaestroTimer.sleep(MaestroTimer.Reason.WAIT_UNTIL_VISIBLE, 1000)\n            } else {\n                LOGGER.info(\"Element became visible.\")\n                return hierarchy\n            }\n        }\n\n        return hierarchy\n    }\n\n    fun pressKey(code: KeyCode, waitForAppToSettle: Boolean = true) {\n        LOGGER.info(\"Pressing key $code\")\n\n        driver.pressKey(code)\n\n        if (waitForAppToSettle) {\n            waitForAppToSettle()\n        }\n    }\n\n    fun viewHierarchy(excludeKeyboardElements: Boolean = false): ViewHierarchy {\n        return ViewHierarchy.from(driver, excludeKeyboardElements)\n    }\n\n    fun findElementWithTimeout(\n        timeoutMs: Long,\n        filter: ElementFilter,\n        viewHierarchy: ViewHierarchy? = null\n    ): FindElementResult? {\n        var hierarchy = viewHierarchy ?: ViewHierarchy(TreeNode())\n        val element = MaestroTimer.withTimeout(timeoutMs) {\n            hierarchy = viewHierarchy ?: viewHierarchy()\n            filter(hierarchy.aggregate()).firstOrNull()\n        }?.toUiElementOrNull()\n\n        return if (element == null) {\n            null\n        } else {\n            if (viewHierarchy != null) {\n                hierarchy = ViewHierarchy(element.treeNode)\n            }\n            return FindElementResult(element, hierarchy)\n        }\n    }\n\n    fun findElementsByOnDeviceQuery(\n        timeoutMs: Long,\n        query: OnDeviceElementQuery\n    ): OnDeviceElementQueryResult? {\n        return MaestroTimer.withTimeout(timeoutMs) {\n            val elements = driver.queryOnDeviceElements(query)\n\n            OnDeviceElementQueryResult(\n                elements = elements.mapNotNull { it.toUiElementOrNull() },\n            )\n        }\n    }\n\n    fun allElementsMatching(filter: ElementFilter): List<TreeNode> {\n        return filter(viewHierarchy().aggregate())\n    }\n\n    fun waitForAppToSettle(\n        initialHierarchy: ViewHierarchy? = null,\n        appId: String? = null,\n        waitToSettleTimeoutMs: Int? = null\n    ): ViewHierarchy? {\n        return driver.waitForAppToSettle(initialHierarchy, appId, waitToSettleTimeoutMs)\n    }\n\n    fun inputText(text: String) {\n        LOGGER.info(\"Inputting text: $text\")\n\n        driver.inputText(text)\n        waitForAppToSettle()\n    }\n\n    fun openLink(link: String, appId: String?, autoVerify: Boolean, browser: Boolean) {\n        LOGGER.info(\"Opening link $link for app: $appId with autoVerify config as $autoVerify\")\n\n        driver.openLink(link, appId, autoVerify, browser)\n        waitForAppToSettle()\n    }\n\n    fun addMedia(fileNames: List<String>) {\n        val mediaFiles = fileNames.map { File(it) }\n        driver.addMedia(mediaFiles)\n    }\n\n    override fun close() {\n        driver.close()\n    }\n\n    @Deprecated(\"Use takeScreenshot(Sink, Boolean) instead\")\n    fun takeScreenshot(outFile: File, compressed: Boolean) {\n        LOGGER.info(\"Taking screenshot to a file: $outFile\")\n\n        val absoluteOutFile = outFile.absoluteFile\n\n        if (absoluteOutFile.parentFile.exists() || absoluteOutFile.parentFile.mkdirs()) {\n            outFile\n                .sink()\n                .buffer()\n                .use {\n                    ScreenshotUtils.takeScreenshot(it, compressed, driver)\n                }\n        } else {\n            throw MaestroException.DestinationIsNotWritable(\n                \"Failed to create directory for screenshot: ${absoluteOutFile.parentFile}\"\n            )\n        }\n    }\n\n    fun takeScreenshot(sink: Sink, compressed: Boolean, bounds: Bounds? = null) {\n        if (bounds == null) {\n            LOGGER.info(\"Taking screenshot\")\n            sink\n                .buffer()\n                .use {\n                    ScreenshotUtils.takeScreenshot(it, compressed, driver)\n                }\n        } else {\n            LOGGER.info(\"Taking screenshot (cropped to bounds)\")\n            val (x, y, width, height) = bounds\n\n            val originalImage = Buffer().apply {\n                ScreenshotUtils.takeScreenshot(this, compressed, driver)\n            }.let { buffer ->\n                buffer.inputStream().use { ImageIO.read(it) }\n            }\n\n            val info = cachedDeviceInfo\n            val scale = if (info.heightGrid > 0) {\n                info.heightPixels.toDouble() / info.heightGrid\n            } else {\n                1.0\n            }\n            val startX = (x * scale).toInt().coerceIn(0, originalImage.width)\n            val startY = (y * scale).toInt().coerceIn(0, originalImage.height)\n            val cropWidthPx = (width * scale).toInt()\n                .coerceIn(0, originalImage.width - startX)\n            val cropHeightPx = (height * scale).toInt()\n                .coerceIn(0, originalImage.height - startY)\n\n            if (cropWidthPx <= 0 || cropHeightPx <= 0) {\n                throw MaestroException.AssertionFailure(\n                    message = \"Cannot crop screenshot: invalid dimensions (width: $cropWidthPx, height: $cropHeightPx).\",\n                    hierarchyRoot = viewHierarchy(excludeKeyboardElements = false).root,\n                    debugMessage = \"Bounds (grid units) x=$x, y=$y, width=$width, height=$height with scale=$scale produced non-positive crop size.\"\n                )\n            }\n\n            val croppedImage = originalImage.getSubimage(\n                startX, startY, cropWidthPx, cropHeightPx\n            )\n\n            sink\n                .buffer()\n                .use {\n                    ImageIO.write(croppedImage, \"png\", it.outputStream())\n                }\n        }\n    }\n\n    fun startScreenRecording(out: Sink): ScreenRecording {\n        LOGGER.info(\"Starting screen recording\")\n\n        if (screenRecordingInProgress) {\n            LOGGER.info(\"Screen recording not started: Already in progress\")\n            return object : ScreenRecording {\n                override fun close() {\n                    // No-op\n                }\n            }\n        }\n        screenRecordingInProgress = true\n\n        LOGGER.info(\"Starting screen recording\")\n        val screenRecording = driver.startScreenRecording(out)\n        val startTimestamp = System.currentTimeMillis()\n        return object : ScreenRecording {\n            override fun close() {\n                LOGGER.info(\"Stopping screen recording\")\n                // Ensure minimum screen recording duration of 3 seconds.\n                // This addresses an edge case where the launch command completes too quickly.\n                val durationPadding = 3000 - (System.currentTimeMillis() - startTimestamp)\n                if (durationPadding > 0) {\n                    Thread.sleep(durationPadding)\n                }\n                screenRecording.close()\n                screenRecordingInProgress = false\n            }\n        }\n    }\n\n    fun setLocation(latitude: String, longitude: String) {\n        LOGGER.info(\"Setting location: ($latitude, $longitude)\")\n\n        driver.setLocation(latitude.toDouble(), longitude.toDouble())\n    }\n\n    fun setOrientation(orientation: DeviceOrientation, waitForAppToSettle: Boolean = true) {\n        LOGGER.info(\"Setting orientation: $orientation\")\n\n        driver.setOrientation(orientation)\n\n        if (waitForAppToSettle) {\n            waitForAppToSettle()\n        }\n    }\n\n    fun eraseText(charactersToErase: Int) {\n        LOGGER.info(\"Erasing $charactersToErase characters\")\n\n        driver.eraseText(charactersToErase)\n    }\n\n    fun waitForAnimationToEnd(timeout: Long?) {\n        @Suppress(\"NAME_SHADOWING\")\n        val timeout = timeout ?: ANIMATION_TIMEOUT_MS\n        LOGGER.info(\"Waiting for animation to end with timeout $timeout\")\n\n        ScreenshotUtils.waitUntilScreenIsStatic(timeout, SCREENSHOT_DIFF_THRESHOLD, driver)\n    }\n\n    fun setProxy(\n        host: String = SocketUtils.localIp(),\n        port: Int\n    ) {\n        LOGGER.info(\"Setting proxy: $host:$port\")\n\n        driver.setProxy(host, port)\n    }\n\n    fun resetProxy() {\n        LOGGER.info(\"Resetting proxy\")\n\n        driver.resetProxy()\n    }\n\n    fun isShutDown(): Boolean {\n        return driver.isShutdown()\n    }\n\n    fun isUnicodeInputSupported(): Boolean {\n        return driver.isUnicodeInputSupported()\n    }\n\n    fun isAirplaneModeEnabled(): Boolean {\n        return driver.isAirplaneModeEnabled()\n    }\n\n    fun setAirplaneModeState(enabled: Boolean) {\n        driver.setAirplaneMode(enabled)\n    }\n\n    fun setAndroidChromeDevToolsEnabled(enabled: Boolean) {\n        driver.setAndroidChromeDevToolsEnabled(enabled)\n    }\n\n    companion object {\n\n        private val LOGGER = LoggerFactory.getLogger(Maestro::class.java)\n\n        private const val SCREENSHOT_DIFF_THRESHOLD = 0.005 // 0.5%\n        private const val ANIMATION_TIMEOUT_MS: Long = 15000\n\n        fun ios(driver: Driver, openDriver: Boolean = true): Maestro {\n            if (openDriver) {\n                driver.open()\n            }\n            return Maestro(driver)\n        }\n\n        fun android(driver: Driver, openDriver: Boolean = true): Maestro {\n            if (openDriver) {\n                driver.open()\n            }\n            return Maestro(driver)\n        }\n\n        fun web(\n            isStudio: Boolean,\n            isHeadless: Boolean,\n            screenSize: String?,\n        ): Maestro {\n            // Check that JRE is at least 11\n            val version = System.getProperty(\"java.version\")\n            if (version.startsWith(\"1.\")) {\n                val majorVersion = version.substring(2, 3).toInt()\n                if (majorVersion < 11) {\n                    throw MaestroException.UnsupportedJavaVersion(\n                        \"Maestro Web requires Java 11 or later. Current version: $version\"\n                    )\n                }\n            }\n\n            val driver = CdpWebDriver(\n                isStudio = isStudio,\n                isHeadless = isHeadless,\n                screenSize = screenSize,\n            )\n            driver.open()\n            return Maestro(driver)\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/Media.kt",
    "content": "package maestro\n\nimport okio.Source\n\nclass NamedSource(val name: String, val source: Source, val extension: String, val path: String)\n\nenum class MediaExt(val extName: String) {\n    PNG(\"png\"),\n    JPEG(\"jpeg\"),\n    JPG(\"jpg\"),\n    GIF(\"gif\"),\n    MP4(\"mp4\"),\n}"
  },
  {
    "path": "maestro-client/src/main/java/maestro/OnDeviceElementQuery.kt",
    "content": "package maestro\n\nsealed class OnDeviceElementQuery {\n\n    data class Css(\n        val css: String,\n    ) : OnDeviceElementQuery()\n\n}"
  },
  {
    "path": "maestro-client/src/main/java/maestro/OnDeviceElementQueryResult.kt",
    "content": "package maestro\n\ndata class OnDeviceElementQueryResult(\n    val elements: List<UiElement>,\n)\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/Point.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro\n\nimport kotlin.math.pow\nimport kotlin.math.sqrt\n\nfun <T : Number> distance(\n    fromX: T,\n    fromY: T,\n    toX: T,\n    toY: T\n): Float {\n    return sqrt(\n        (fromX.toDouble() - toX.toDouble()).pow(2.0) + (fromY.toDouble() - toY.toDouble()).pow(2.0)\n    ).toFloat()\n}\n\ndata class Point(\n    val x: Int,\n    val y: Int\n) {\n\n    fun distance(other: Point): Float {\n        return distance(x, y, other.x, other.y)\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/ScreenRecording.kt",
    "content": "package maestro\n\ninterface ScreenRecording : AutoCloseable"
  },
  {
    "path": "maestro-client/src/main/java/maestro/ScrollDirection.kt",
    "content": "package maestro\n\nenum class ScrollDirection {\n    UP,\n    DOWN,\n    RIGHT,\n    LEFT\n}\n\nfun ScrollDirection.toSwipeDirection(): SwipeDirection = when (this) {\n    ScrollDirection.DOWN -> SwipeDirection.UP\n    ScrollDirection.UP -> SwipeDirection.DOWN\n    ScrollDirection.LEFT -> SwipeDirection.RIGHT\n    ScrollDirection.RIGHT -> SwipeDirection.LEFT\n}"
  },
  {
    "path": "maestro-client/src/main/java/maestro/SwipeDirection.kt",
    "content": "package maestro\n\nenum class SwipeDirection {\n    UP,\n    DOWN,\n    RIGHT,\n    LEFT\n}\n\ninline fun <reified SwipeDirection : Enum<SwipeDirection>> directionValueOfOrNull(input: String): SwipeDirection? {\n    return enumValues<SwipeDirection>().find { it.name == input || it.name.lowercase() == input }\n}"
  },
  {
    "path": "maestro-client/src/main/java/maestro/TapRepeat.kt",
    "content": "package maestro\n\ndata class TapRepeat(\n    val repeat: Int,\n    val delay: Long // millis\n)"
  },
  {
    "path": "maestro-client/src/main/java/maestro/TreeNode.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro\n\ndata class TreeNode(\n    val attributes: MutableMap<String, String> = mutableMapOf(),\n    val children: List<TreeNode> = emptyList(),\n    val clickable: Boolean? = null,\n    val enabled: Boolean? = null,\n    val focused: Boolean? = null,\n    val checked: Boolean? = null,\n    val selected: Boolean? = null,\n) {\n\n    fun aggregate(): List<TreeNode> {\n        return listOf(this) + children.flatMap { it.aggregate() }\n    }\n\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/UiElement.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro\n\ndata class UiElement(\n    val treeNode: TreeNode,\n    val bounds: Bounds,\n) {\n\n    fun distanceTo(other: UiElement): Float {\n        return bounds.center().distance(other.bounds.center())\n    }\n\n    fun getVisiblePercentage(screenWidth: Int, screenHeight: Int): Double {\n        if (bounds.width == 0 && bounds.height == 0) {\n            return 0.0\n        }\n\n        val overflow = (bounds.x <= 0) && (bounds.y <= 0) && (bounds.x + bounds.width >= screenWidth) && (bounds.y + bounds.height >= screenHeight)\n        if (overflow) {\n            return 1.0\n        }\n\n        val visibleX = maxOf(0, minOf(bounds.x + bounds.width, screenWidth) - maxOf(bounds.x, 0))\n        val visibleY = maxOf(0, minOf(bounds.y + bounds.height, screenHeight) - maxOf(bounds.y, 0))\n        val visibleArea = visibleX * visibleY\n        val totalArea = bounds.width * bounds.height\n\n        return visibleArea.toDouble() / totalArea.toDouble()\n    }\n\n    fun isElementNearScreenCenter(direction: SwipeDirection, screenWidth: Int, screenHeight: Int): Boolean {\n        val centerX = screenWidth / 2\n        val centerY = screenHeight / 2\n\n        val elementCenterX = bounds.x + (bounds.width / 2)\n        val elementCenterY = bounds.y + (bounds.height / 2)\n\n        val margin = when(direction) {\n            SwipeDirection.DOWN, SwipeDirection.UP -> screenHeight / 5\n            SwipeDirection.LEFT, SwipeDirection.RIGHT -> screenWidth / 5\n        }\n\n        // return true when the element center is within the <direction> half of the screen bounds plus margin\n        return when(direction) {\n            SwipeDirection.RIGHT -> elementCenterX > centerX - margin\n            SwipeDirection.LEFT -> elementCenterX < centerX + margin\n            SwipeDirection.UP -> elementCenterY < centerY + margin\n            SwipeDirection.DOWN -> elementCenterY > centerY - margin\n        }\n    }\n\n    fun isWithinViewPortBounds(info: DeviceInfo, paddingHorizontal: Float = 0f, paddingVertical: Float = 0f): Boolean {\n        val paddingX = (info.widthGrid * paddingHorizontal).toInt()\n        val paddingY = (info.heightGrid * paddingVertical).toInt()\n        val xEnd = info.widthGrid - paddingX\n        val yEnd = info.heightGrid - paddingY\n\n        val isXWithinBounds = bounds.x in paddingX..xEnd\n        val isYWithinBounds = bounds.y in paddingY..yEnd\n\n        return isXWithinBounds && isYWithinBounds\n    }\n\n    companion object {\n\n        fun TreeNode.toUiElement(): UiElement {\n            return toUiElementOrNull()\n                ?: throw IllegalStateException(\"Node has no bounds\")\n        }\n\n        fun TreeNode.toUiElementOrNull(): UiElement? {\n            // TODO needs different impl for iOS\n            val boundsStr = attributes[\"bounds\"]\n                ?: return null\n\n            val boundsArr = boundsStr\n                .replace(\"][\", \",\")\n                .removePrefix(\"[\")\n                .removeSuffix(\"]\")\n                .split(\",\")\n                .map { it.toInt() }\n\n            return UiElement(\n                this,\n                Bounds(\n                    x = boundsArr[0],\n                    y = boundsArr[1],\n                    width = boundsArr[2] - boundsArr[0],\n                    height = boundsArr[3] - boundsArr[1]\n                ),\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/ViewHierarchy.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro\n\nimport maestro.UiElement.Companion.toUiElement\n\n@JvmInline\nvalue class ViewHierarchy(val root: TreeNode) {\n    companion object {\n        fun from(driver: Driver, excludeKeyboardElements: Boolean): ViewHierarchy {\n            val deviceInfo = driver.deviceInfo()\n            val root = driver.contentDescriptor(excludeKeyboardElements).let {\n                val filtered = it.filterOutOfBounds(\n                    width = deviceInfo.widthGrid,\n                    height = deviceInfo.heightGrid\n                )\n                filtered ?: it\n            }\n            return ViewHierarchy(root)\n        }\n    }\n\n    fun isVisible(node: TreeNode): Boolean {\n        if (!node.attributes.containsKey(\"bounds\")) {\n            return false\n        }\n\n        val center = node.toUiElement().bounds.center()\n\n        val elementAtPosition = getElementAt(root, center.x, center.y)\n\n        return node == elementAtPosition\n    }\n\n    fun refreshElement(node: TreeNode): TreeNode? {\n        val matches = root.aggregate()\n            .filter {\n                (it.attributes - \"bounds\") == (node.attributes - \"bounds\")\n            }\n\n        if (matches.size != 1) {\n            return null\n        }\n\n        return matches[0]\n    }\n\n    fun getElementAt(\n        node: TreeNode,\n        x: Int,\n        y: Int\n    ): TreeNode? {\n        return node\n            .children\n            .asReversed()\n            .asSequence()\n            .mapNotNull {\n                val elementWithinChild = if (it.children.isNotEmpty()) {\n                    getElementAt(it, x, y)\n                } else {\n                    null\n                }\n\n                elementWithinChild\n                    ?: if (it.attributes.containsKey(\"bounds\")) {\n                        val bounds = it.toUiElement().bounds\n\n                        if (bounds.contains(x, y)) {\n                            it\n                        } else {\n                            null\n                        }\n                    } else {\n                        null\n                    }\n            }\n            .firstOrNull()\n    }\n\n    fun aggregate(): List<TreeNode> {\n        return root.aggregate()\n    }\n}\n\nfun TreeNode.filterOutOfBounds(width: Int, height: Int): TreeNode? {\n    if (attributes.containsKey(\"ignoreBoundsFiltering\") && attributes[\"ignoreBoundsFiltering\"] == \"true\") {\n        return this\n    }\n\n    val filtered = children.mapNotNull {\n        it.filterOutOfBounds(width, height)\n    }.toList()\n\n    // parent can have missing bounds\n    val element = kotlin.runCatching { toUiElement() }.getOrNull()\n    val visiblePercentage = element?.getVisiblePercentage(width, height) ?: 0.0\n\n    if (visiblePercentage < 0.1 && filtered.isEmpty()) {\n        return null\n    }\n\n    return TreeNode(\n        attributes = attributes,\n        children = filtered,\n        clickable = clickable,\n        enabled = enabled,\n        focused = focused,\n        checked = checked,\n        selected = selected\n    )\n}\n\n\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/android/AndroidAppFiles.kt",
    "content": "package maestro.android\n\nimport dadb.Dadb\nimport java.io.File\nimport java.io.IOException\nimport java.net.URI\nimport java.nio.file.FileSystems\nimport java.nio.file.Files\nimport java.nio.file.Path\nimport kotlin.io.path.createDirectories\n\nobject AndroidAppFiles {\n\n    fun pull(dadb: Dadb, packageName: String, zipOutFile: File) {\n        zipOutFile.delete()\n        val zipOutUri = URI(\"jar:${zipOutFile.toURI().scheme}\", zipOutFile.absolutePath, null)\n        FileSystems.newFileSystem(zipOutUri, mapOf(\"create\" to \"true\")).use { fs ->\n            // Create zip directories first\n            listRemoteFiles(dadb, packageName, \"-type d\").forEach { remoteDir ->\n                val dstLocation = remoteDir\n                    .removePrefix(\"/\")\n                    .removeSuffix(\"/.\")\n                    .removeSuffix(\"/\")\n                val dstPath = fs.getPath(dstLocation)\n                dstPath.createDirectories()\n            }\n\n            // Create zip files\n            listRemoteFiles(dadb, packageName, \"-type f\").forEach { remoteFile ->\n                val dstLocation = remoteFile\n                    .removePrefix(\"/\")\n                    .removeSuffix(\"/.\")\n                    .removeSuffix(\"/\")\n                val dstPath = fs.getPath(dstLocation)\n                pullAppFile(dadb, packageName, dstPath, remoteFile)\n            }\n        }\n    }\n\n    fun getApkFile(dadb: Dadb, appId: String): File {\n        val apkPath = dadb.shell(\"pm list packages -f --user 0 | grep $appId | head -1\")\n            .output.substringAfterLast(\"package:\").substringBefore(\"=$appId\")\n        apkPath.substringBefore(\"=$appId\")\n        val dst = File.createTempFile(\"tmp\", \".apk\")\n        dadb.pull(dst, apkPath)\n        return dst\n    }\n\n    fun push(dadb: Dadb, packageName: String, appFilesZip: File) {\n        val remoteZip = \"/data/local/tmp/app.zip\"\n        dadb.push(appFilesZip, remoteZip)\n        try {\n            shell(dadb, \"run-as $packageName unzip -o -d / $remoteZip\")\n        } finally {\n            shell(dadb, \"rm $remoteZip\")\n        }\n    }\n\n    private fun pullAppFile(dadb: Dadb, packageName: String, localPath: Path, remotePath: String) {\n        dadb.open(\"exec:run-as $packageName cat $remotePath\").use { stream ->\n            Files.copy(stream.source.inputStream(), localPath)\n        }\n    }\n\n    private fun listRemoteFiles(dadb: Dadb, packageName: String, options: String): List<String> {\n        val result = shell(dadb, \"run-as $packageName find $options\")\n        val appDataDir = \"/data/data/$packageName\"\n        return result.lines()\n            .filter { it.isNotBlank() }\n            .map { \"$appDataDir/${it.removePrefix(\"./\")}\" }\n    }\n\n    private fun shell(dadb: Dadb, command: String): String {\n        val response = dadb.shell(command)\n        if (response.exitCode != 0) throw IOException(\"Shell command failed ($command):\\n${response.allOutput}\")\n        return response.allOutput\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/android/AndroidBuildToolsDirectory.kt",
    "content": "package maestro.android\n\nimport java.io.File\nimport java.util.regex.Pattern\n\ninternal object AndroidBuildToolsDirectory {\n\n    fun findBuildToolsDir(androidHome: File): File {\n        val buildToolsParent = File(androidHome, \"build-tools\")\n        if (!buildToolsParent.exists()) {\n            throw IllegalStateException(\"build-tools directory does not exist: $buildToolsParent\")\n        }\n\n        val latestBuildToolsVersion = getLatestToolsVersion(buildToolsParent)\n            ?: throw IllegalStateException(\"Could not find a valid build-tools subdirectory in $buildToolsParent\")\n\n        return File(buildToolsParent, latestBuildToolsVersion.toString())\n    }\n\n    private fun getLatestToolsVersion(buildToolsParent: File): BuildToolsVersion? {\n        return buildToolsParent.listFiles()!!\n            .mapNotNull { BuildToolsVersion.parse(it.name) }\n            .maxOfOrNull { it }\n    }\n\n    private class BuildToolsVersion(\n        val major: Int,\n        val minor: Int,\n        val patch: Int,\n    ) : Comparable<BuildToolsVersion> {\n        override fun toString(): String {\n            return \"$major.$minor.$patch\"\n        }\n\n        override fun compareTo(other: BuildToolsVersion): Int {\n            return VERSION_COMPARATOR.compare(this, other)\n        }\n\n        companion object {\n\n            private val VERSION_PATTERN = Pattern.compile(\"([0-9]+)\\\\.([0-9]+)\\\\.([0-9]+)\")\n\n            private val VERSION_COMPARATOR = compareBy<BuildToolsVersion>(\n                { v -> v.major },\n                { v -> v.minor },\n                { v -> v.patch },\n            )\n\n            fun parse(name: String): BuildToolsVersion? {\n                val m = VERSION_PATTERN.matcher(name)\n                if (!m.matches()) return null\n                return BuildToolsVersion(\n                    major = m.group(1).toInt(),\n                    minor = m.group(2).toInt(),\n                    patch = m.group(3).toInt(),\n                )\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/android/AndroidLaunchArguments.kt",
    "content": "package maestro.android\n\nimport maestro_android.MaestroAndroid\n\nobject AndroidLaunchArguments {\n\n    fun Map<String, Any>.toAndroidLaunchArguments(): List<MaestroAndroid.ArgumentValue> {\n        return toList().map {\n            when (val value = it.second) {\n                is Boolean -> MaestroAndroid.ArgumentValue.newBuilder()\n                    .setKey(it.first)\n                    .setValue(value.toString())\n                    .setType(Boolean::class.java.name)\n                    .build()\n                is Int -> MaestroAndroid.ArgumentValue.newBuilder()\n                    .setKey(it.first)\n                    .setValue(value.toString())\n                    .setType(Int::class.java.name)\n                    .build()\n                is Double -> MaestroAndroid.ArgumentValue.newBuilder()\n                    .setKey(it.first)\n                    .setValue(value.toString())\n                    .setType(Double::class.java.name)\n                    .build()\n                is Long -> MaestroAndroid.ArgumentValue.newBuilder()\n                    .setKey(it.first)\n                    .setValue(value.toString())\n                    .setType(Long::class.java.name)\n                    .build()\n                is String -> MaestroAndroid.ArgumentValue.newBuilder()\n                    .setKey(it.first)\n                    .setValue(value.toString())\n                    .setType(String::class.java.name)\n                    .build()\n                else -> MaestroAndroid.ArgumentValue.newBuilder()\n                    .setKey(it.first)\n                    .setValue(value.toString())\n                    .setType(String::class.java.name)\n                    .build()\n            }\n        }\n    }\n}"
  },
  {
    "path": "maestro-client/src/main/java/maestro/android/chromedevtools/AndroidWebViewHierarchyClient.kt",
    "content": "package maestro.android.chromedevtools\n\nimport dadb.Dadb\nimport maestro.Bounds\nimport maestro.TreeNode\nimport maestro.UiElement\nimport maestro.UiElement.Companion.toUiElementOrNull\nimport java.io.Closeable\n\nclass AndroidWebViewHierarchyClient(dadb: Dadb): Closeable {\n\n    private val devToolsClient = DadbChromeDevToolsClient(dadb)\n\n    fun augmentHierarchy(baseHierarchy: TreeNode, chromeDevToolsEnabled: Boolean): TreeNode {\n        if (!chromeDevToolsEnabled) return baseHierarchy\n        if (!hasWebView(baseHierarchy)) return baseHierarchy\n        // TODO: Adapt to handle chrome in the same way\n        val webViewHierarchy = devToolsClient.getWebViewTreeNodes()\n        val merged = mergeHierarchies(baseHierarchy, webViewHierarchy)\n        return merged\n    }\n\n    override fun close() {\n        devToolsClient.close()\n    }\n\n    companion object {\n\n        fun mergeHierarchies(baseHierarchy: TreeNode, webViewHierarchy: List<TreeNode>): TreeNode {\n            if (webViewHierarchy.isEmpty()) return baseHierarchy\n            val newNodes = mutableListOf<TreeNode>()\n            val baseNodes = baseHierarchy.aggregate().mapNotNull { it.toUiElementOrNull() }\n            // We can use a quadtree here if this is too slow\n            val webViewNodes = webViewHierarchy.flatMap { it.aggregate() }.filter {\n                it.attributes[\"text\"]?.isNotBlank() == true\n                        || it.attributes[\"resource-id\"]?.isNotBlank() == true\n                        || it.attributes[\"hintText\"]?.isNotBlank() == true\n                        || it.attributes[\"accessibilityText\"]?.isNotBlank() == true\n            }.mapNotNull { it.toUiElementOrNull() }.filter {\n                it.bounds.width > 0 && it.bounds.height > 0\n            }\n            webViewNodes.forEach { webViewNode ->\n                if (!baseNodes.any { webViewNode.mergeWith(it) }) {\n                    newNodes.add(webViewNode.treeNode)\n                }\n            }\n            if (newNodes.isEmpty()) return baseHierarchy\n            return TreeNode(children = listOf(baseHierarchy) + newNodes)\n        }\n\n        private fun UiElement.mergeWith(base: UiElement): Boolean {\n            if (!this.bounds.intersects(base.bounds)) return false\n            val thisTexts = this.treeNode.texts()\n            val baseTexts = base.treeNode.texts()\n            val thisId = this.treeNode.attributes[\"resource-id\"]\n            val baseId = base.treeNode.attributes[\"resource-id\"]\n\n            // web view text is a substring of base text\n            val mergeableText = thisTexts.any { baseTexts.any { baseText -> baseText.contains(it) } }\n\n            // web view id matches base id\n            val mergeableId = thisId?.isNotEmpty() == true && baseId?.isNotEmpty() == true && thisId == baseId\n\n            // web view id matches base text\n            val mergeableId2 = baseTexts.any { it == thisId }\n\n            if (!mergeableText && !mergeableId && !mergeableId2) return false\n\n            val newAttributes = this.treeNode.attributes\n\n            newAttributes.remove(\"bounds\")\n            if (baseTexts.isNotEmpty()) {\n                newAttributes.remove(\"text\")\n                newAttributes.remove(\"hintText\")\n                newAttributes.remove(\"accessibilityText\")\n            }\n            if (baseId?.isNotEmpty() == true) newAttributes.remove(\"resource-id\")\n\n            newAttributes.entries.removeIf { it.value.isEmpty() }\n\n            base.treeNode.attributes += newAttributes\n\n            return true\n        }\n\n        private fun TreeNode.texts(): List<String> {\n            return listOfNotNull(attributes[\"text\"], attributes[\"hintText\"], attributes[\"accessibilityText\"]).filter { it.isNotEmpty() }\n        }\n\n        private fun Bounds.intersects(other: Bounds): Boolean {\n            return this.x < other.x + other.width && this.x + this.width > other.x && this.y < other.y + other.height && this.y + this.height > other.y\n        }\n\n        private fun hasWebView(node: TreeNode): Boolean {\n            if (isWebView(node)) return true\n            for (child in node.children) {\n                if (hasWebView(child)) return true\n            }\n            return false\n        }\n\n        private fun isWebView(node: TreeNode): Boolean {\n            return node.attributes[\"class\"] == \"android.webkit.WebView\"\n        }\n    }\n}"
  },
  {
    "path": "maestro-client/src/main/java/maestro/android/chromedevtools/DadbChromeDevToolsClient.kt",
    "content": "package maestro.android.chromedevtools\n\nimport com.fasterxml.jackson.core.JsonProcessingException\nimport com.fasterxml.jackson.core.type.TypeReference\nimport com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES\nimport com.fasterxml.jackson.databind.type.TypeFactory\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport com.fasterxml.jackson.module.kotlin.readValue\nimport dadb.Dadb\nimport maestro.Maestro\nimport maestro.TreeNode\nimport maestro.utils.HttpClient\nimport okhttp3.HttpUrl\nimport okhttp3.HttpUrl.Companion.toHttpUrl\nimport okhttp3.OkHttpClient\nimport okhttp3.Request\nimport okhttp3.Response\nimport okhttp3.WebSocket\nimport okhttp3.WebSocketListener\nimport okio.use\nimport org.slf4j.LoggerFactory\nimport java.io.Closeable\nimport java.io.IOException\nimport java.util.concurrent.CompletableFuture\nimport java.util.concurrent.ExecutionException\nimport java.util.concurrent.TimeUnit\nimport java.util.concurrent.TimeoutException\nimport kotlin.system.measureTimeMillis\n\nprivate data class RuntimeResponse<T>(\n    val result: RemoteObject<T>\n)\n\nprivate data class RemoteObject<T>(\n    val type: String,\n    val value: T,\n)\n\ndata class WebViewInfo(\n    val socketName: String,\n    val webSocketDebuggerUrl: String,\n    val visible: Boolean,\n    val attached: Boolean,\n    val empty: Boolean,\n    val screenX: Int,\n    val screenY: Int,\n    val width: Int,\n    val height: Int,\n)\n\nprivate data class WebViewResponse(\n    val description: String,\n    val webSocketDebuggerUrl: String,\n)\n\nprivate data class WebViewDescription(\n    val visible: Boolean,\n    val attached: Boolean,\n    val empty: Boolean,\n    val screenX: Int,\n    val screenY: Int,\n    val width: Int,\n    val height: Int,\n)\n\nprivate data class DevToolsResponse<T>(\n    val id: Int,\n    val result: T,\n)\n\nclass DadbChromeDevToolsClient(private val dadb: Dadb): Closeable {\n\n    private val json = jacksonObjectMapper().configure(FAIL_ON_UNKNOWN_PROPERTIES, false)\n\n    private val okhttp = HttpClient.build(\"DadbChromeDevToolsClient\").newBuilder()\n        .dadb(dadb)\n        .build()\n\n    private val script = Maestro::class.java.getResourceAsStream(\"/maestro-web.js\")?.let {\n        it.bufferedReader().use { br ->\n            br.readText()\n        }\n    } ?: error(\"Could not read maestro web script\")\n\n    override fun close() {\n        okhttp.dispatcher.executorService.shutdown()\n        okhttp.connectionPool.evictAll()\n        okhttp.cache?.close()\n    }\n\n    fun getWebViewTreeNodes(): List<TreeNode> {\n        return getWebViewInfos()\n            .filter { it.visible }\n            .mapNotNull { info ->\n                try {\n                    evaluateScript<RuntimeResponse<TreeNode>>(info.socketName, info.webSocketDebuggerUrl, \"$script; maestro.viewportX = ${info.screenX}; maestro.viewportY = ${info.screenY}; maestro.viewportWidth = ${info.width}; maestro.viewportHeight = ${info.height}; window.maestro.getContentDescription();\").result.value\n                } catch (e: IOException) {\n                    logger.warn(\"Failed to retrieve WebView hierarchy from chrome devtools: ${info.socketName} ${info.webSocketDebuggerUrl}\", e)\n                    null\n                }\n            }\n    }\n\n    inline fun <reified T> evaluateScript(socketName: String, webSocketDebuggerUrl: String, script: String) = makeRequest<T>(\n        socketName = socketName,\n        webSocketDebuggerUrl = webSocketDebuggerUrl,\n        method = \"Runtime.evaluate\",\n        params = mapOf(\n            \"expression\" to script,\n            \"returnByValue\" to true,\n        ),\n    )\n\n    inline fun <reified T> makeRequest(socketName: String, webSocketDebuggerUrl: String, method: String, params: Any?): T {\n        val resultTypeReference = object : TypeReference<T>() {}\n        return makeRequest(resultTypeReference, socketName, webSocketDebuggerUrl, method, params)\n    }\n\n    fun <T> makeRequest(resultTypeReference: TypeReference<T>, socketName: String, webSocketDebuggerUrl: String, method: String, params: Any?): T {\n        val request = json.writeValueAsString(mapOf(\"id\" to 1, \"method\" to method, \"params\" to params))\n        val url = webSocketDebuggerUrl.replace(\"ws\", \"http\").toHttpUrl().newBuilder()\n            .host(\"localabstract.$socketName.adb\")\n            .build()\n        val response = makeSingleWebsocketRequest(url, request)\n        return try {\n            val resultType = TypeFactory.defaultInstance().constructType(resultTypeReference)\n            val responseType = TypeFactory.defaultInstance()\n                .constructParametricType(DevToolsResponse::class.java, resultType)\n            json.readValue<DevToolsResponse<T>>(response, responseType).result\n        } catch (e: JsonProcessingException) {\n            throw IOException(\"Failed to parse DOM snapshot: $response\", e)\n        }\n    }\n\n    fun getWebViewInfos(): List<WebViewInfo> {\n        return getWebViewSocketNames().flatMap(::getWebViewInfos)\n    }\n\n    fun makeSingleWebsocketRequest(url: HttpUrl, message: String): String {\n        val future = CompletableFuture<String>()\n        val ws = okhttp.newWebSocket(\n            Request.Builder()\n                .url(url)\n                .build(),\n            object : WebSocketListener() {\n                override fun onMessage(webSocket: WebSocket, text: String) {\n                    future.complete(text)\n                }\n\n                override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {\n                    future.completeExceptionally(t)\n                }\n            }\n        )\n        ws.send(message)\n        val response = try {\n            future.get(5, TimeUnit.SECONDS)\n        } catch (_: TimeoutException) {\n            throw TimeoutException(\"Timed out waiting for websocket response\")\n        } catch (e: ExecutionException) {\n            throw e.cause ?: e\n        }\n        ws.close(1000, null)\n        return response\n    }\n\n    private fun getWebViewInfos(socketName: String): List<WebViewInfo> {\n        val url = \"http://localabstract.$socketName.adb/json\"\n\n        val call = okhttp.newCall(Request.Builder()\n            .url(url)\n            .header(\"Host\", \"localhost:9222\") // Expected by devtools server\n            .build())\n\n        val response = try {\n            call.execute()\n        } catch (e: IOException) {\n            logger.error(\"IOException while getting WebView info from $url. Defaulting to empty list.\", e)\n            return emptyList()\n        }\n\n        if (response.code != 200) {\n            logger.error(\"Request to get WebView infos failed with code ${response.code}. Defaulting to empty list.\")\n            return emptyList()\n        }\n\n        val body = response.body?.string() ?: throw IllegalStateException(\"No body found\")\n\n        return try {\n            json.readValue<List<WebViewResponse>>(body).mapNotNull { parsed ->\n                // Description is empty for eg. service workers\n                if (parsed.description.isBlank()) return@mapNotNull null\n                val description = json.readValue(parsed.description, WebViewDescription::class.java)\n                WebViewInfo(\n                    socketName = socketName,\n                    webSocketDebuggerUrl = parsed.webSocketDebuggerUrl,\n                    visible = description.visible,\n                    attached = description.attached,\n                    empty = description.empty,\n                    screenX = description.screenX,\n                    screenY = description.screenY,\n                    width = description.width,\n                    height = description.height,\n                )\n            }.filter { it.attached && it.visible && !it.empty }\n        } catch (e: JsonProcessingException) {\n            throw IllegalStateException(\"Failed to parse WebView chrome dev tools response:\\n$body\", e)\n        }\n    }\n\n    private fun getWebViewSocketNames(): Set<String> {\n        val response = dadb.shell(\"cat /proc/net/unix\")\n        if (response.exitCode != 0) {\n            throw IllegalStateException(\"Failed get WebView socket names. Command 'cat /proc/net/unix' failed: ${response.allOutput}\")\n        }\n        return response.allOutput.trim().lines().mapNotNull { line ->\n            line.split(Regex(\"\\\\s+\")).lastOrNull()?.takeIf { it.startsWith(WEB_VIEW_SOCKET_PREFIX) }?.substring(1)\n        }.toSet()\n    }\n\n    companion object {\n        private const val WEB_VIEW_SOCKET_PREFIX = \"@webview_devtools_remote_\"\n\n        private val logger = LoggerFactory.getLogger(Maestro::class.java)\n    }\n}\n\nfun main() {\n    (Dadb.discover() ?: throw IllegalStateException(\"No devices found\")).use { dadb ->\n        DadbChromeDevToolsClient(dadb).apply {\n            while (true) {\n                measureTimeMillis {\n                    println(getWebViewTreeNodes().size)\n                }.also { println(\"time: $it\") }\n            }\n        }\n    }\n}"
  },
  {
    "path": "maestro-client/src/main/java/maestro/android/chromedevtools/DadbSocket.kt",
    "content": "package maestro.android.chromedevtools\n\nimport dadb.AdbStream\nimport dadb.Dadb\nimport okhttp3.Dns\nimport okhttp3.OkHttpClient\nimport java.io.FilterOutputStream\nimport java.io.InputStream\nimport java.io.OutputStream\nimport java.net.InetAddress\nimport java.net.InetSocketAddress\nimport java.net.Socket\nimport java.net.SocketAddress\nimport java.net.SocketException\nimport java.nio.channels.SocketChannel\nimport javax.net.SocketFactory\n\nfun OkHttpClient.Builder.dadb(dadb: Dadb): OkHttpClient.Builder {\n    dns(DadbDns())\n    socketFactory(DadbSocketFactory(dadb))\n    return this\n}\n\nclass DadbDns : Dns {\n\n    override fun lookup(hostname: String): List<InetAddress> {\n        if (!hostname.endsWith(\".adb\")) throw IllegalArgumentException(\"Invalid hostname. Eg. tcp.8000.adb, localabstract.chrome_devtools_remote.adb\")\n        return listOf(InetAddress.getByAddress(hostname, byteArrayOf(0, 0, 0, 0)))\n    }\n}\n\nclass DadbSocketFactory(private val dadb: Dadb) : SocketFactory() {\n\n    override fun createSocket(): Socket {\n        return DadbStreamSocket(dadb)\n    }\n\n    override fun createSocket(host: String?, port: Int): Socket {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun createSocket(\n        host: String?,\n        port: Int,\n        localHost: InetAddress?,\n        localPort: Int\n    ): Socket {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun createSocket(host: InetAddress?, port: Int): Socket {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun createSocket(\n        address: InetAddress?,\n        port: Int,\n        localAddress: InetAddress?,\n        localPort: Int\n    ): Socket {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n}\n\nclass DadbStreamSocket(private val dadb: Dadb) : Socket() {\n\n    private var closed = false\n    private var adbStream: AdbStream? = null\n\n    override fun getInputStream(): InputStream {\n        val adbStream = this.adbStream ?: throw SocketException(\"Socket is not connected\")\n        return adbStream.source.inputStream()\n    }\n\n    override fun getOutputStream(): OutputStream {\n        val adbStream = this.adbStream ?: throw SocketException(\"Socket is not connected\")\n        return object : FilterOutputStream(adbStream.sink.outputStream()) {\n\n            override fun write(b: ByteArray) {\n                super.write(b)\n                flush()\n            }\n\n            override fun write(b: ByteArray, off: Int, len: Int) {\n                super.write(b, off, len)\n                flush()\n            }\n        }\n    }\n\n    override fun connect(endpoint: SocketAddress, timeout: Int) {\n        if (endpoint !is InetSocketAddress) throw UnsupportedOperationException(\"Endpoint must be a InetSocketAddress: $endpoint (${endpoint::class})\")\n        val destination = endpoint.hostName.removeSuffix(\".adb\").replace(\".\", \":\")\n        this.adbStream = dadb.open(destination)\n    }\n\n    override fun isClosed() = closed\n\n    override fun close() {\n        if (isClosed()) return\n        adbStream?.close()\n        adbStream = null\n        closed = true\n    }\n\n    override fun setSoTimeout(timeout: Int) = Unit\n\n    override fun isInputShutdown() = false\n\n    override fun isOutputShutdown() = false\n\n\n    override fun connect(endpoint: SocketAddress?) {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun bind(bindpoint: SocketAddress?) {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun getInetAddress(): InetAddress {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun getLocalAddress(): InetAddress {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun getPort(): Int {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun getLocalPort(): Int {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun getRemoteSocketAddress(): SocketAddress {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun getLocalSocketAddress(): SocketAddress {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun getChannel(): SocketChannel {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun setTcpNoDelay(on: Boolean) {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun getTcpNoDelay(): Boolean {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun setSoLinger(on: Boolean, linger: Int) {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun getSoLinger(): Int {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun sendUrgentData(data: Int) {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun setOOBInline(on: Boolean) {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun getOOBInline(): Boolean {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun getSoTimeout(): Int {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun setSendBufferSize(size: Int) {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun getSendBufferSize(): Int {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun setReceiveBufferSize(size: Int) {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun getReceiveBufferSize(): Int {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun setKeepAlive(on: Boolean) {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun getKeepAlive(): Boolean {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun setTrafficClass(tc: Int) {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun getTrafficClass(): Int {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun setReuseAddress(on: Boolean) {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun getReuseAddress(): Boolean {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun shutdownInput() {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun shutdownOutput() {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun isConnected(): Boolean {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun isBound(): Boolean {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n\n    override fun setPerformancePreferences(connectionTime: Int, latency: Int, bandwidth: Int) {\n        throw UnsupportedOperationException(\"Not implemented\")\n    }\n}"
  },
  {
    "path": "maestro-client/src/main/java/maestro/auth/ApiKey.kt",
    "content": "package maestro.auth\n\nimport java.nio.file.Paths\nimport kotlin.io.path.deleteIfExists\nimport kotlin.io.path.exists\nimport kotlin.io.path.isDirectory\nimport kotlin.io.path.readText\n\nclass ApiKey {\n    companion object {\n        private val cachedAuthTokenFile by lazy {\n            Paths.get(\n                System.getProperty(\"user.home\"),\n                \".mobiledev\",\n                \"authtoken\"\n            )\n        }\n\n        private fun maestroCloudApiKey(): String? {\n            return System.getenv(\"MAESTRO_CLOUD_API_KEY\")\n        }\n\n        private fun getCachedAuthToken(): String? {\n            if (!cachedAuthTokenFile.exists()) return null\n            if (cachedAuthTokenFile.isDirectory()) return null\n            val cachedAuthToken = cachedAuthTokenFile.readText()\n            return cachedAuthToken\n        }\n\n        fun getToken(): String? {\n            return maestroCloudApiKey() ?: // Resolve API key from shell if set\n            getCachedAuthToken() // Otherwise, if the user has already logged in, use the cached auth token\n        }\n\n        fun setToken(token: String?) {\n            cachedAuthTokenFile.parent.toFile().mkdirs()\n            if (token == null) {\n                cachedAuthTokenFile.deleteIfExists()\n                return\n            }\n            cachedAuthTokenFile.toFile().writeText(token)\n        }\n    }\n}"
  },
  {
    "path": "maestro-client/src/main/java/maestro/debuglog/DebugLogStore.kt",
    "content": "package maestro.debuglog\n\nimport maestro.Driver\nimport maestro.utils.FileUtils\nimport net.harawata.appdirs.AppDirsFactory\nimport java.io.File\nimport java.time.LocalDateTime\nimport java.time.format.DateTimeFormatter\nimport java.util.Date\nimport java.util.Properties\nimport java.util.logging.ConsoleHandler\nimport java.util.logging.FileHandler\nimport java.util.logging.Level\nimport java.util.logging.LogRecord\nimport java.util.logging.Logger\nimport java.util.logging.SimpleFormatter\n\nobject DebugLogStore {\n\n    private const val APP_NAME = \"maestro\"\n    private const val APP_AUTHOR = \"mobile_dev\"\n    private const val LOG_DIR_DATE_FORMAT = \"yyyy-MM-dd_HHmmss\"\n    private const val KEEP_LOG_COUNT = 6\n    val logDirectory = File(AppDirsFactory.getInstance().getUserLogDir(APP_NAME, null, APP_AUTHOR))\n\n    private val currentRunLogDirectory: File\n    private val consoleHandler: ConsoleHandler\n    private val fileHandler: FileHandler\n\n    init {\n        val dateFormatter = DateTimeFormatter.ofPattern(LOG_DIR_DATE_FORMAT)\n        val date = dateFormatter.format(LocalDateTime.now())\n\n        currentRunLogDirectory = File(logDirectory, date)\n        currentRunLogDirectory.mkdirs()\n        removeOldLogs(logDirectory)\n\n        consoleHandler = ConsoleHandler()\n        consoleHandler.level = Level.WARNING\n        consoleHandler.formatter = object : SimpleFormatter() {\n            override fun format(record: LogRecord): String {\n                val level = if (record.level.intValue() > 900) \"Error: \" else \"\"\n                return \"$level${record.message}\\n\"\n            }\n        }\n\n        val maestroLogFile = logFile(\"maestro\")\n        fileHandler = FileHandler(maestroLogFile.absolutePath)\n        fileHandler.level = Level.ALL\n        fileHandler.formatter = object : SimpleFormatter() {\n            private val format = \"[%1\\$tF %1\\$tT] [%2$-7s] %3\\$s %n\"\n\n            @Suppress(\"DefaultLocale\")\n            @Synchronized\n            override fun format(lr: LogRecord): String {\n                return java.lang.String.format(\n                    format,\n                    Date(lr.millis),\n                    lr.level.localizedName,\n                    lr.message\n                )\n            }\n        }\n    }\n\n    fun copyTo(file: File) {\n        val local = logFile(\"maestro\")\n        local.copyTo(file)\n    }\n\n    fun loggerFor(clazz: Class<*>): Logger {\n        val logger = Logger.getLogger(clazz.name)\n        logger.useParentHandlers = false\n        logger.addHandler(consoleHandler)\n        logger.addHandler(fileHandler)\n        return logger\n    }\n\n    fun logOutputOf(processBuilder: ProcessBuilder) {\n        val command = processBuilder.command().first() ?: \"unknown\"\n        val logFile = logFile(command)\n        val redirect = ProcessBuilder.Redirect.to(logFile)\n        processBuilder\n            .redirectOutput(redirect)\n            .redirectError(redirect)\n    }\n\n    fun finalizeRun() {\n        fileHandler.close()\n        val output = File(currentRunLogDirectory.parent, \"${currentRunLogDirectory.name}.zip\")\n        FileUtils.zipDir(currentRunLogDirectory.toPath(), output.toPath())\n        currentRunLogDirectory.deleteRecursively()\n    }\n\n    private fun logFile(named: String): File {\n        return File(currentRunLogDirectory, \"$named.log\")\n    }\n\n    private fun removeOldLogs(baseDir: File) {\n        if (!baseDir.isDirectory) {\n            return\n        }\n\n        val existing = baseDir.listFiles() ?: return\n        val toDelete = existing.sortedByDescending { it.name }\n            .drop(KEEP_LOG_COUNT)\n            .toList()\n\n        toDelete.forEach { it.deleteRecursively() }\n    }\n\n    fun logSystemInfo() {\n        val logData = \"\"\"\n            Maestro version: ${appVersion()}\n            OS: ${System.getProperty(\"os.name\")}\n            OS version: ${System.getProperty(\"os.version\")}\n            Architecture: ${System.getProperty(\"os.arch\")}\n            \"\"\".trimIndent() + \"\\n\"\n\n        logFile(\"system_info\").writeText(logData)\n    }\n\n    private fun appVersion(): String {\n        try {\n            val props = Driver::class.java.classLoader.getResourceAsStream(\"version.properties\").use {\n                Properties().apply { load(it) }\n            }\n            return props[\"version\"].toString()\n        } catch (ignore: Exception) {\n            // no-action\n        }\n        return \"Undefined\"\n    }\n}\n\nfun Logger.warn(message: String, throwable: Throwable? = null) {\n    if (throwable != null) {\n        log(Level.WARNING, message, throwable)\n    } else log(Level.WARNING, message)\n}\n\nfun Logger.error(message: String, throwable: Throwable? = null) {\n    if (throwable != null) {\n        log(Level.SEVERE, message, throwable)\n    } else log(Level.SEVERE, message)\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/debuglog/LogConfig.kt",
    "content": "package maestro.debuglog\n\nimport org.apache.logging.log4j.core.appender.FileAppender\nimport org.apache.logging.log4j.core.config.Configurator\nimport org.apache.logging.log4j.core.config.builder.api.ConfigurationBuilder\nimport org.apache.logging.log4j.core.config.builder.api.ConfigurationBuilderFactory\nimport org.apache.logging.log4j.core.config.builder.impl.BuiltConfiguration\n\nobject LogConfig {\n\n    private const val DEFAULT_FILE_LOG_PATTERN = \"%d{HH:mm:ss.SSS} [%5level] %logger.%method: %msg%n\"\n    private const val DEFAULT_CONSOLE_LOG_PATTERN = \"%highlight([%5level]) %msg%n\"\n\n    private val FILE_LOG_PATTERN: String = System.getenv(\"MAESTRO_CLI_LOG_PATTERN_FILE\") ?: DEFAULT_FILE_LOG_PATTERN\n    private val CONSOLE_LOG_PATTERN: String = System.getenv(\"MAESTRO_CLI_LOG_PATTERN_CONSOLE\") ?: DEFAULT_CONSOLE_LOG_PATTERN\n\n    fun configure(logFileName: String? = null, printToConsole: Boolean) {\n        val builder = ConfigurationBuilderFactory.newConfigurationBuilder()\n        builder.setStatusLevel(org.apache.logging.log4j.Level.ERROR)\n        builder.setConfigurationName(\"MaestroConfig\")\n\n        // Disable ktor logging completely\n        builder.add(\n            builder.newLogger(\"io.ktor\", org.apache.logging.log4j.Level.OFF)\n                .addAttribute(\"additivity\", false)\n        )\n\n        val rootLogger = builder.newRootLogger(org.apache.logging.log4j.Level.ALL)\n\n        if (logFileName != null) {\n            val fileAppender = createFileAppender(builder, logFileName)\n            rootLogger.add(builder.newAppenderRef(fileAppender.getName()))\n        }\n\n        if (printToConsole) {\n            val consoleAppender = createConsoleAppender(builder)\n            rootLogger.add(builder.newAppenderRef(consoleAppender.getName()))\n        }\n\n\n        builder.add(rootLogger)\n\n        val config = builder.build()\n\n        Configurator.reconfigure(config)\n    }\n\n    private fun createConsoleAppender(builder: ConfigurationBuilder<BuiltConfiguration>): org.apache.logging.log4j.core.config.builder.api.AppenderComponentBuilder {\n        val consoleAppender = builder.newAppender(\"Console\", \"CONSOLE\")\n\n        val consoleLayout = builder.newLayout(\"PatternLayout\")\n        consoleLayout.addAttribute(\"pattern\", CONSOLE_LOG_PATTERN)\n        consoleAppender.add(consoleLayout)\n\n        builder.add(consoleAppender)\n\n        return consoleAppender\n    }\n\n    private fun createFileAppender(builder: ConfigurationBuilder<BuiltConfiguration>, logFileName: String): org.apache.logging.log4j.core.config.builder.api.AppenderComponentBuilder {\n        val fileAppender = builder.newAppender(\"File\", FileAppender.PLUGIN_NAME)\n        fileAppender.addAttribute(\"fileName\", logFileName)\n\n        val fileLayout = builder.newLayout(\"PatternLayout\")\n        fileLayout.addAttribute(\"pattern\", FILE_LOG_PATTERN)\n\n        fileAppender.add(fileLayout)\n        builder.add(fileAppender)\n\n        return fileAppender\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/device/Device.kt",
    "content": "package maestro.device\n\nsealed class Device(\n    open val description: String,\n    open val platform: Platform,\n    open val deviceType: DeviceType,\n    open val deviceSpec: DeviceSpec\n) {\n\n    enum class DeviceType {\n        REAL,\n        SIMULATOR,\n        EMULATOR,\n        BROWSER\n    }\n\n    data class Connected(\n        val instanceId: String,\n        override val deviceSpec: DeviceSpec,\n        override val description: String,\n        override val platform: Platform,\n        override val deviceType: DeviceType,\n    ) : Device(description, platform, deviceType, deviceSpec)\n\n    data class AvailableForLaunch(\n        val modelId: String,\n        override val deviceSpec: DeviceSpec,\n        override val description: String,\n        override val platform: Platform,\n        override val deviceType: DeviceType,\n    ) : Device(description, platform, deviceType, deviceSpec)\n\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/device/DeviceError.kt",
    "content": "package maestro.device\n\n/**\n * Exception class specifically for device-related errors in the client module.\n * Functionally equivalent to CliError in maestro-cli.\n */\nclass DeviceError(override val message: String, override val cause: Throwable? = null) : RuntimeException(message, cause)\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/device/DeviceOrientation.kt",
    "content": "package maestro.device\n\nenum class DeviceOrientation {\n    PORTRAIT,\n    LANDSCAPE_LEFT,\n    LANDSCAPE_RIGHT,\n    UPSIDE_DOWN;\n\n    // Return the camelCase representation of the enum name, for example \"landscapeLeft\"\n    val camelCaseName: String\n        get() = name.split(\"_\")\n            .mapIndexed { index, part ->\n                if (index == 0) part.lowercase()\n                else part.lowercase().capitalize()\n            }\n            .joinToString(\"\")\n\n    companion object {\n        // Support lookup of enum value by name, ignoring underscores and case. This allow inputs like\n        // \"LANDSCAPE_LEFT\" or \"landscapeLeft\" to both be matched to the LANDSCAPE_LEFT enum value.\n        fun getByName(name: String): DeviceOrientation? {\n            return values().find {\n                comparableName(it.name) == comparableName(name)\n            }\n        }\n\n        private fun comparableName(name: String): String {\n            return name.lowercase().replace(\"_\", \"\")\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/device/DeviceService.kt",
    "content": "package maestro.device\n\nimport dadb.Dadb\nimport dadb.adbserver.AdbServer\nimport maestro.device.util.AndroidEnvUtils\nimport maestro.device.util.AvdDevice\nimport maestro.device.util.PrintUtils\nimport maestro.drivers.AndroidDriver\nimport maestro.drivers.CdpWebDriver\nimport maestro.utils.MaestroTimer\nimport maestro.utils.TempFileHandler\nimport okio.buffer\nimport okio.source\nimport org.slf4j.LoggerFactory\nimport util.DeviceCtlResponse\nimport util.LocalIOSDevice\nimport util.LocalSimulatorUtils\nimport util.SimctlList\nimport java.io.File\nimport java.util.UUID\nimport java.util.concurrent.TimeUnit\nimport java.util.concurrent.TimeoutException\n\ndata class AvdInfo(val name: String, val model: String, val os: String)\n\nobject DeviceService {\n    private val logger = LoggerFactory.getLogger(DeviceService::class.java)\n\n    private val tempFileHandler = TempFileHandler()\n    private val localSimulatorUtils = LocalSimulatorUtils(tempFileHandler)\n\n    fun startDevice(\n        device: Device.AvailableForLaunch,\n        driverHostPort: Int?,\n        connectedDevices: Set<String> = setOf()\n    ): Device.Connected {\n        when (device.deviceSpec.platform) {\n            Platform.IOS -> {\n                PrintUtils.message(\"Launching Simulator...\")\n                try {\n                    localSimulatorUtils.bootSimulator(device.modelId)\n                    PrintUtils.message(\"Setting the device locale to ${device.deviceSpec.locale.code}...\")\n                    localSimulatorUtils.setDeviceLanguage(device.modelId, device.deviceSpec.locale.languageCode)\n                    localSimulatorUtils.setDeviceLocale(device.modelId, device.deviceSpec.locale.code)\n                    localSimulatorUtils.reboot(device.modelId)\n                    localSimulatorUtils.launchSimulator(device.modelId)\n                    localSimulatorUtils.awaitLaunch(device.modelId)\n                } catch (e: LocalSimulatorUtils.SimctlError) {\n                    logger.error(\"Failed to launch simulator\", e)\n                    throw DeviceError(e.message)\n                }\n\n                return Device.Connected(\n                    instanceId = device.modelId,\n                    description = device.description,\n                    platform = device.platform,\n                    deviceType = device.deviceType,\n                    deviceSpec = device.deviceSpec,\n                )\n            }\n\n            Platform.ANDROID -> {\n                PrintUtils.message(\"Launching Emulator...\")\n                val emulatorBinary = requireEmulatorBinary()\n\n                ProcessBuilder(\n                    emulatorBinary.absolutePath,\n                    \"-avd\",\n                    device.modelId,\n                    \"-netdelay\",\n                    \"none\",\n                    \"-netspeed\",\n                    \"full\"\n                ).start().waitFor(10, TimeUnit.SECONDS)\n\n                var lastException: Exception? = null\n\n                val dadb = MaestroTimer.withTimeout(60000) {\n                    try {\n                        Dadb.list().lastOrNull { dadb ->\n                            !connectedDevices.contains(dadb.toString())\n                        }\n                    } catch (ignored: Exception) {\n                        Thread.sleep(100)\n                        lastException = ignored\n                        null\n                    }\n                } ?: throw DeviceError(\"Unable to start device: ${device.modelId}\", lastException)\n\n                PrintUtils.message(\"Waiting for emulator ( ${device.modelId} ) to boot...\")\n                while (!bootComplete(dadb)) {\n                    Thread.sleep(1000)\n                }\n\n                PrintUtils.message(\"Setting the device locale to ${device.deviceSpec.locale.code}...\")\n                val driver = AndroidDriver(dadb, driverHostPort)\n                driver.installMaestroDriverApp()\n                val result = driver.setDeviceLocale(\n                    country = device.deviceSpec.locale.countryCode,\n                    language = device.deviceSpec.locale.languageCode,\n                )\n\n                when (result) {\n                    SET_LOCALE_RESULT_SUCCESS -> PrintUtils.message(\"[Done] Setting the device locale to ${device.deviceSpec.locale.code}...\")\n                    SET_LOCALE_RESULT_LOCALE_NOT_VALID -> throw IllegalStateException(\"Failed to set locale ${device.deviceSpec.locale.code}, the locale is not valid for a chosen device\")\n                    SET_LOCALE_RESULT_UPDATE_CONFIGURATION_FAILED -> throw IllegalStateException(\"Failed to set locale ${device.deviceSpec.locale.code}, exception during updating configuration occurred\")\n                    else -> throw IllegalStateException(\"Failed to set locale ${device.deviceSpec.locale.code}, unknown exception happened\")\n                }\n                driver.uninstallMaestroDriverApp()\n\n                return Device.Connected(\n                    instanceId = dadb.toString(),\n                    description = device.description,\n                    platform = device.platform,\n                    deviceType = device.deviceType,\n                    deviceSpec = device.deviceSpec,\n                )\n            }\n\n            Platform.WEB -> {\n                PrintUtils.message(\"Launching Web...\")\n                CdpWebDriver(isStudio = false, isHeadless = false, screenSize = null).open()\n\n                return Device.Connected(\n                    instanceId = \"chromium\",\n                    description = \"Chromium Web Browser\",\n                    platform = device.platform,\n                    deviceType = device.deviceType,\n                    deviceSpec = device.deviceSpec,\n                )\n            }\n        }\n    }\n\n    fun listConnectedDevices(\n        includeWeb: Boolean = false,\n        host: String? = null,\n        port: Int? = null,\n    ): List<Device.Connected> {\n        return listDevices(includeWeb = includeWeb, host, port)\n            .filterIsInstance<Device.Connected>()\n    }\n\n    fun <T : Device> List<T>.withPlatform(platform: Platform?) =\n        filter { platform == null || it.platform == platform }\n\n    fun listAvailableForLaunchDevices(includeWeb: Boolean = false): List<Device.AvailableForLaunch> {\n        return listDevices(includeWeb = includeWeb)\n            .filterIsInstance<Device.AvailableForLaunch>()\n    }\n\n     fun listDevices(includeWeb: Boolean, host: String? = null, port: Int? = null): List<Device> {\n        return listAndroidDevices(host, port) +\n                listIOSDevices() +\n                if (includeWeb) {\n                    listWebDevices()\n                } else {\n                    listOf()\n                }\n    }\n\n    fun listWebDevices(): List<Device> {\n        return listOf(\n            Device.Connected(\n                platform = Platform.WEB,\n                description = \"Chromium Web Browser\",\n                instanceId = \"chromium\",\n                deviceType = Device.DeviceType.BROWSER,\n                deviceSpec = DeviceSpec.fromRequest(DeviceSpecRequest.Web())\n            ),\n            Device.AvailableForLaunch(\n                modelId = \"chromium\",\n                description = \"Chromium Web Browser\",\n                platform = Platform.WEB,\n                deviceType = Device.DeviceType.BROWSER,\n                deviceSpec = DeviceSpec.fromRequest(DeviceSpecRequest.Web())\n            )\n        )\n    }\n\n    fun listAndroidDevices(host: String? = null, port: Int? = null): List<Device> {\n        val host = host ?: \"localhost\"\n        if (port != null) {\n            val dadb = Dadb.create(host, port)\n            return listOf(\n                Device.Connected(\n                    instanceId = dadb.toString(),\n                    description = dadb.toString(),\n                    platform = Platform.ANDROID,\n                    deviceType = Device.DeviceType.EMULATOR,\n                    deviceSpec = DeviceSpec.fromRequest(\n                      DeviceSpecRequest.Android()\n                    )\n                )\n            )\n        }\n\n        // Fetch AVD info once (model + os) to avoid repeated avdmanager calls\n        val avdInfoList = fetchAndroidAvdInfo()\n\n        val connected = runCatching {\n            Dadb.list(host = host).map { dadb ->\n                val avdName = runCatching {\n                    dadb.shell(\"getprop ro.kernel.qemu\").output.trim().let { qemuProp ->\n                        if (qemuProp == \"1\") {\n                            val avdNameResult = ProcessBuilder(\"adb\", \"-s\", dadb.toString(), \"emu\", \"avd\", \"name\")\n                                .redirectErrorStream(true)\n                                .start()\n                                .apply { waitFor(5, TimeUnit.SECONDS) }\n                                .inputStream.bufferedReader().readLine()?.trim() ?: \"\"\n\n                            if (avdNameResult.isNotBlank() && !avdNameResult.contains(\"unknown AVD\")) {\n                                avdNameResult\n                            } else null\n                        } else null\n                    }\n                }.getOrNull()\n\n                val instanceId = dadb.toString()\n                val deviceType = when {\n                    instanceId.startsWith(\"emulator\") -> Device.DeviceType.EMULATOR\n                    else -> Device.DeviceType.REAL\n                }\n                val avdInfo = avdInfoList.find { it.name == avdName } ?: AvdInfo(name = avdName ?: \"\", model = \"\", os = \"\")\n                Device.Connected(\n                    instanceId = instanceId,\n                    description = avdName ?: dadb.toString(),\n                    platform = Platform.ANDROID,\n                    deviceType = deviceType,\n                    deviceSpec = DeviceSpec.fromRequest(\n                        DeviceSpecRequest.Android()\n                    ),\n                )\n            }\n        }.getOrNull() ?: emptyList()\n\n        // Note that there is a possibility that AVD is actually already connected and is present in\n        // connectedDevices.\n        val avds = try {\n            val emulatorBinary = requireEmulatorBinary()\n            ProcessBuilder(emulatorBinary.absolutePath, \"-list-avds\")\n                .start()\n                .inputStream\n                .bufferedReader()\n                .useLines { lines ->\n                    lines\n                        .map { avdName ->\n                            val avdInfo = avdInfoList.find { it.name == avdName } ?: AvdInfo(name = avdName, model = \"\", os = \"\")\n                            Device.AvailableForLaunch(\n                                modelId = avdName,\n                                description = avdName,\n                                platform = Platform.ANDROID,\n                                deviceType = Device.DeviceType.EMULATOR,\n                                deviceSpec = DeviceSpec.fromRequest(\n                                    DeviceSpecRequest.Android(avdInfo.model, avdInfo.os)\n                                )\n                            )\n                        }\n                        .toList()\n                }\n        } catch (ignored: Exception) {\n            emptyList()\n        }\n\n        return connected + avds\n    }\n\n    /**\n     * Runs `avdmanager list avd` and returns a list of AvdInfo\n     * - name\n     * - model: the canonical device ID from avdmanager, e.g. \"pixel_6\"\n     * - os: \"android-XX\" derived from the API level, e.g. \"android-34\"\n     *\n     * Falls back to config.ini for the OS if avdmanager output lacks it.\n     * Returns empty string on any failure\n     */\n    private fun fetchAndroidAvdInfo(): List<AvdInfo> {\n        return try {\n            val avd = requireAvdManagerBinary()\n            val output = ProcessBuilder(avd.absolutePath, \"list\", \"avd\")\n                .redirectErrorStream(true)\n                .start()\n                .apply { waitFor(30, TimeUnit.SECONDS) }\n                .inputStream.bufferedReader().readText()\n            parseAvdInfo(output, AndroidEnvUtils.androidAvdHome)\n        } catch (e: Exception) {\n            emptyList()\n        }\n    }\n\n    internal fun parseAvdInfo(output: String, avdHome: File): List<AvdInfo> {\n        val result = mutableListOf<AvdInfo>()\n        var currentName: String? = null\n        var currentModel: String? = null\n        var currentOs: String? = null\n\n        for (line in output.lines()) {\n            val trimmed = line.trim()\n            when {\n                trimmed.startsWith(\"Name:\") -> {\n                    // Save previous block\n                    if (currentName != null) {\n                        result += AvdInfo(name = currentName, model = currentModel ?: \"\", os = currentOs ?: \"\")\n                    }\n                    currentName = trimmed.removePrefix(\"Name:\").trim()\n                    currentModel = null\n                    currentOs = null\n                }\n                trimmed.startsWith(\"Device:\") -> {\n                    // \"pixel_6 (Google Pixel 6)\" → \"pixel_6\"\n                    currentModel = trimmed.removePrefix(\"Device:\").trim().substringBefore(\" \")\n                }\n                currentOs == null && currentName != null -> {\n                    // Read OS from config.ini once we have the AVD name\n                    val configFile = File(avdHome, \"$currentName.avd/config.ini\")\n                    if (configFile.exists()) {\n                        val sysdir = configFile.readLines()\n                            .firstOrNull { it.startsWith(\"image.sysdir.1=\") }\n                            ?.substringAfter(\"=\")\n                        currentOs = sysdir?.split(\"/\")?.firstOrNull { it.startsWith(\"android-\") }\n                    }\n                }\n            }\n        }\n        // Save last block\n        if (currentName != null) {\n            result += AvdInfo(name = currentName, model = currentModel ?: \"\", os = currentOs ?: \"\")\n        }\n        return result\n    }\n\n    fun listIOSDevices(): List<Device> {\n        val simctlList = try {\n            localSimulatorUtils.list()\n        } catch (ignored: Exception) {\n            return emptyList()\n        }\n\n        val runtimeNameByIdentifier = simctlList\n            .runtimes\n            .associate { it.identifier to it.name }\n\n        return simctlList\n            .devices\n            .flatMap { runtime ->\n                runtime.value\n                    .filter { it.isAvailable }\n                    .map { device(runtimeNameByIdentifier, runtime, it) }\n            } + listIOSConnectedDevices()\n    }\n\n    fun listIOSConnectedDevices(): List<Device.Connected> {\n        val connectedIphoneList = LocalIOSDevice().listDeviceViaDeviceCtl()\n\n        return connectedIphoneList.mapNotNull { device ->\n            val udid = device.hardwareProperties?.udid\n            if (device.connectionProperties.tunnelState != DeviceCtlResponse.ConnectionProperties.CONNECTED || udid == null) {\n                return@mapNotNull null\n            }\n\n            val description = listOfNotNull(\n                device.deviceProperties?.name,\n                device.deviceProperties?.osVersionNumber,\n                device.identifier\n            ).joinToString(\" - \")\n\n            Device.Connected(\n                instanceId = udid,\n                description = description,\n                platform = Platform.IOS,\n                deviceType = Device.DeviceType.REAL,\n                deviceSpec = DeviceSpec.fromRequest(\n                    DeviceSpecRequest.Ios()\n                )\n            )\n        }\n    }\n\n    private fun device(\n      runtimeNameByIdentifier: Map<String, String>,\n      runtime: Map.Entry<String, List<SimctlList.Device>>,\n      device: SimctlList.Device,\n    ): Device {\n        val runtimeName = runtimeNameByIdentifier[runtime.key] ?: \"Unknown runtime\"\n        val description = \"${device.name} - $runtimeName - ${device.udid}\"\n\n        // \"com.apple.CoreSimulator.SimDeviceType.iPhone-XS\" → \"iPhone-XS\"\n        val model = device.deviceTypeIdentifier?.substringAfterLast(\".\") ?: \"\"\n        // \"com.apple.CoreSimulator.SimRuntime.iOS-17-5\" → \"iOS-17-5\"\n        val os = runtime.key.substringAfterLast(\".\")\n\n        return if (device.state == \"Booted\") {\n            Device.Connected(\n                instanceId = device.udid,\n                description = description,\n                platform = Platform.IOS,\n                deviceType = Device.DeviceType.SIMULATOR,\n                deviceSpec = DeviceSpec.fromRequest(\n                    DeviceSpecRequest.Ios(model, os)\n                )\n            )\n        } else {\n            Device.AvailableForLaunch(\n                modelId = device.udid,\n                description = description,\n                platform = Platform.IOS,\n                deviceType =  Device.DeviceType.SIMULATOR,\n                deviceSpec = DeviceSpec.fromRequest(\n                    DeviceSpecRequest.Ios(model, os)\n                )\n            )\n        }\n    }\n\n    /**\n     * @return true if ios simulator or android emulator is currently connected\n     */\n    fun isDeviceConnected(deviceName: String, platform: Platform): Device.Connected? {\n        return when (platform) {\n            Platform.IOS -> listIOSDevices()\n                .filterIsInstance<Device.Connected>()\n                .find { it.description.contains(deviceName, ignoreCase = true) }\n\n            else -> runCatching {\n                (Dadb.list() + AdbServer.listDadbs(adbServerPort = 5038))\n                    .mapNotNull { dadb -> runCatching { dadb.shell(\"getprop ro.kernel.qemu.avd_name\").output }.getOrNull() }\n                    .map { output ->\n                        Device.Connected(\n                            instanceId = output,\n                            description = output,\n                            platform = Platform.ANDROID,\n                            deviceType = Device.DeviceType.EMULATOR,\n                            deviceSpec = DeviceSpec.fromRequest(\n                                DeviceSpecRequest.Android()\n                            )\n                        )\n                    }\n                    .find { connectedDevice -> connectedDevice.description.contains(deviceName, ignoreCase = true) }\n            }.getOrNull()\n        }\n    }\n\n    /**\n     * @return true if ios simulator or android emulator is available to launch\n     */\n    fun isDeviceAvailableToLaunch(deviceName: String, platform: Platform): Device.AvailableForLaunch? {\n        return if (platform == Platform.IOS) {\n            listIOSDevices()\n                .filterIsInstance<Device.AvailableForLaunch>()\n                .find { it.description.contains(deviceName, ignoreCase = true) }\n        } else {\n            listAndroidDevices()\n                .filterIsInstance<Device.AvailableForLaunch>()\n                .find { it.description.contains(deviceName, ignoreCase = true) }\n        }\n    }\n\n    /**\n     * Creates an iOS simulator\n     *\n     * @param deviceName Any name\n     * @param device Simulator type as specified by Apple i.e. iPhone-11\n     * @param os OS runtime name as specified by Apple i.e. iOS-16-2\n     */\n    fun createIosDevice(deviceName: String, device: String, os: String): UUID {\n        val command = listOf(\n            \"xcrun\",\n            \"simctl\",\n            \"create\",\n            deviceName,\n            \"com.apple.CoreSimulator.SimDeviceType.$device\",\n            \"com.apple.CoreSimulator.SimRuntime.$os\"\n        )\n\n        val process = ProcessBuilder(*command.toTypedArray()).start()\n        if (!process.waitFor(5, TimeUnit.MINUTES)) {\n            throw TimeoutException()\n        }\n\n        if (process.exitValue() != 0) {\n            val processOutput = process.errorStream\n                .source()\n                .buffer()\n                .readUtf8()\n\n            throw IllegalStateException(processOutput)\n        } else {\n            val output = String(process.inputStream.readBytes()).trim()\n            return try {\n                UUID.fromString(output)\n            } catch (ignore: IllegalArgumentException) {\n                throw IllegalStateException(\"Unable to create device. No UUID was generated\")\n            }\n        }\n    }\n\n    /**\n     * Creates an Android emulator\n     *\n     * @param deviceName Any device name\n     * @param device Device type as specified by the Android SDK i.e. \"pixel_6\"\n     * @param systemImage Full system package i.e \"system-images;android-28;google_apis;x86_64\"\n     * @param tag google apis or playstore tag i.e. google_apis or google_apis_playstore\n     * @param abi x86_64, x86, arm64 etc..\n     */\n    fun createAndroidDevice(\n        deviceName: String,\n        device: String,\n        systemImage: String,\n        tag: String,\n        abi: String,\n        force: Boolean = false,\n    ): String {\n        val avd = requireAvdManagerBinary()\n        val name = deviceName\n        val command = mutableListOf(\n            avd.absolutePath,\n            \"create\", \"avd\",\n            \"--name\", name,\n            \"--package\", systemImage,\n            \"--tag\", tag,\n            \"--abi\", abi,\n            \"--device\", device,\n        )\n\n        if (force) command.add(\"--force\")\n\n        val process = ProcessBuilder(*command.toTypedArray()).start()\n\n        if (!process.waitFor(5, TimeUnit.MINUTES)) {\n            throw TimeoutException()\n        }\n\n        if (process.exitValue() != 0) {\n            val processOutput = process.errorStream\n                .source()\n                .buffer()\n                .readUtf8()\n\n            throw IllegalStateException(\"Failed to start android emulator: $processOutput\")\n        }\n\n        return name\n    }\n\n    fun getAvailablePixelDevices(): List<AvdDevice> {\n        val avd = requireAvdManagerBinary()\n        val command = mutableListOf(\n            avd.absolutePath,\n            \"list\", \"device\"\n        )\n\n        val process = ProcessBuilder(*command.toTypedArray()).start()\n\n        if (!process.waitFor(1, TimeUnit.MINUTES)) {\n            throw TimeoutException()\n        }\n\n        if (process.exitValue() != 0) {\n            val processOutput = process.inputStream.source().buffer().readUtf8() + \"\\n\" + process.errorStream.source().buffer().readUtf8()\n\n            throw IllegalStateException(\"Failed to list avd devices emulator: $processOutput\")\n        }\n\n        return runCatching {\n            AndroidEnvUtils.parsePixelDevices(String(process.inputStream.readBytes()).trim())\n        }.getOrNull() ?: emptyList()\n    }\n\n    /**\n     * @return true is Android system image is already installed\n     */\n    fun isAndroidSystemImageInstalled(image: String): Boolean {\n        val command = listOf(\n            requireSdkManagerBinary().absolutePath,\n            \"--list_installed\"\n        )\n        try {\n            val process = ProcessBuilder(*command.toTypedArray()).start()\n            if (!process.waitFor(1, TimeUnit.MINUTES)) {\n                throw TimeoutException()\n            }\n\n            if (process.exitValue() == 0) {\n                val output = String(process.inputStream.readBytes()).trim()\n\n                return output.contains(image)\n            }\n        } catch (e: Exception) {\n            logger.error(\"Unable to detect if SDK package is installed\", e)\n        }\n\n        return false\n    }\n\n    /**\n     * Uses the Android SDK manager to install android image\n     */\n    fun installAndroidSystemImage(image: String): Boolean {\n        val command = listOf(\n            requireSdkManagerBinary().absolutePath,\n            image\n        )\n        try {\n            val process = ProcessBuilder(*command.toTypedArray())\n                .inheritIO()\n                .start()\n            if (!process.waitFor(120, TimeUnit.MINUTES)) {\n                throw TimeoutException()\n            }\n\n            if (process.exitValue() == 0) {\n                val output = String(process.inputStream.readBytes()).trim()\n\n                return output.contains(image)\n            }\n        } catch (e: Exception) {\n            logger.error(\"Unable to install if SDK package is installed\", e)\n        }\n\n        return false\n    }\n\n    fun getAndroidSystemImageInstallCommand(pkg: String): String {\n        return listOf(\n            requireSdkManagerBinary().absolutePath,\n            \"\\\"$pkg\\\"\"\n        ).joinToString(separator = \" \")\n    }\n\n    fun deleteIosDevice(uuid: String): Boolean {\n        val command = listOf(\n            \"xcrun\",\n            \"simctl\",\n            \"delete\",\n            uuid\n        )\n\n        val process = ProcessBuilder(*command.toTypedArray()).start()\n\n        if (!process.waitFor(1, TimeUnit.MINUTES)) {\n            throw TimeoutException()\n        }\n\n        return process.exitValue() == 0\n    }\n\n    fun killAndroidDevice(deviceId: String): Boolean {\n        val command = listOf(\"adb\", \"-s\", deviceId, \"emu\", \"kill\")\n\n        try {\n            val process = ProcessBuilder(*command.toTypedArray()).start()\n\n            if (!process.waitFor(1, TimeUnit.MINUTES)) {\n                throw TimeoutException(\"Android kill command timed out\")\n            }\n\n            val success = process.exitValue() == 0\n            if (success) {\n                logger.info(\"Killed Android device: $deviceId\")\n            } else {\n                logger.error(\"Failed to kill Android device: $deviceId\")\n            }\n\n            return success\n        } catch (e: Exception) {\n            logger.error(\"Error killing Android device: $deviceId\", e)\n            return false\n        }\n    }\n\n    fun killIOSDevice(deviceId: String): Boolean {\n        val command = listOf(\"xcrun\", \"simctl\", \"shutdown\", deviceId)\n\n        try {\n            val process = ProcessBuilder(*command.toTypedArray()).start()\n\n            if (!process.waitFor(1, TimeUnit.MINUTES)) {\n                throw TimeoutException(\"iOS kill command timed out\")\n            }\n\n            val success = process.exitValue() == 0\n            if (success) {\n                logger.info(\"Killed iOS device: $deviceId\")\n            } else {\n                logger.error(\"Failed to kill iOS device: $deviceId\")\n            }\n\n            return success\n        } catch (e: Exception) {\n            logger.error(\"Error killing iOS device: $deviceId\", e)\n            return false\n        }\n    }\n\n    private fun bootComplete(dadb: Dadb): Boolean {\n        return try {\n            val booted = dadb.shell(\"getprop sys.boot_completed\").output.trim() == \"1\"\n            val settingsAvailable = dadb.shell(\"settings list global\").exitCode == 0\n            val packageManagerAvailable = dadb.shell(\"pm get-max-users\").exitCode == 0\n            return settingsAvailable && packageManagerAvailable && booted\n        } catch (e: IllegalStateException) {\n            false\n        }\n    }\n\n    private fun requireEmulatorBinary(): File = AndroidEnvUtils.requireEmulatorBinary()\n\n    private fun requireAvdManagerBinary(): File = AndroidEnvUtils.requireCommandLineTools(\"avdmanager\")\n\n    private fun requireSdkManagerBinary(): File = AndroidEnvUtils.requireCommandLineTools(\"sdkmanager\")\n\n    private const val SET_LOCALE_RESULT_SUCCESS = 0\n    private const val SET_LOCALE_RESULT_LOCALE_NOT_VALID = 1\n    private const val SET_LOCALE_RESULT_UPDATE_CONFIGURATION_FAILED = 2\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/device/DeviceSpec.kt",
    "content": "package maestro.device\n\nimport com.fasterxml.jackson.annotation.JsonSubTypes\nimport com.fasterxml.jackson.annotation.JsonTypeInfo\nimport maestro.device.locale.DeviceLocale\n\nenum class CPU_ARCHITECTURE(val value: String) {\n  X86_64(\"x86_64\"),\n  ARM64(\"arm64-v8a\"),\n  UNKNOWN(\"unknown\");\n\n  companion object {\n    fun fromString(p: String?): Platform? {\n      return Platform.entries.firstOrNull { it.description.equals(p, ignoreCase = true) }\n    }\n  }\n}\n\n/**\n * Returned Sealed class that has all non-nullable values\n */\n@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = \"platform\")\n@JsonSubTypes(\n  JsonSubTypes.Type(DeviceSpec.Android::class, name = \"ANDROID\"),\n  JsonSubTypes.Type(DeviceSpec.Ios::class, name = \"IOS\"),\n  JsonSubTypes.Type(DeviceSpec.Web::class, name = \"WEB\"),\n)\nsealed class DeviceSpec {\n    abstract val platform: Platform\n    abstract val model: String\n    abstract val os: String\n    abstract val osVersion: Int\n    abstract val deviceName: String\n    abstract val locale: DeviceLocale\n\n    data class Android(\n        override val model: String,\n        override val os: String,\n        override val locale: DeviceLocale,\n        val orientation: DeviceOrientation,\n        val disableAnimations: Boolean,\n        val cpuArchitecture: CPU_ARCHITECTURE,\n    ) : DeviceSpec() {\n        override val platform = Platform.ANDROID\n        override val osVersion: Int = os.removePrefix(\"android-\").toIntOrNull() ?: 0\n        override val deviceName = \"Maestro_ANDROID_${model}_${os}\"\n        val tag = \"google_apis\"\n        val emulatorImage = \"system-images;$os;$tag;${cpuArchitecture.value}\"\n    }\n\n    data class Ios(\n        override val model: String,\n        override val os: String,\n        override val locale: DeviceLocale,\n        val orientation: DeviceOrientation,\n        val disableAnimations: Boolean,\n        val snapshotKeyHonorModalViews: Boolean,\n    ) : DeviceSpec() {\n        override val platform = Platform.IOS\n        override val osVersion: Int = os.removePrefix(\"iOS-\").substringBefore(\"-\").toIntOrNull() ?: 0\n        override val deviceName = \"Maestro_IOS_${model}_${osVersion}\"\n    }\n\n    data class Web(\n      override val model: String,\n      override val os: String,\n      override val locale: DeviceLocale\n    ) : DeviceSpec() {\n        override val platform = Platform.WEB\n        override val osVersion: Int = 0\n        override val deviceName = \"Maestro_WEB_${model}_${osVersion}\"\n    }\n\n    companion object {\n        /**\n         * Creates a fully resolved DeviceSpec from a DeviceRequest, filling in platform-aware defaults.\n         * The returned spec is not environment-validated.\n         * Environment-specific validation for model & os can happen via SupportedDevices.validate().\n         */\n        fun fromRequest(request: DeviceSpecRequest): DeviceSpec {\n            return when (request) {\n                is DeviceSpecRequest.Android -> Android(\n                    model = request.model ?: \"pixel_6\",\n                    os = request.os ?: \"android-33\",\n                    locale = DeviceLocale.fromString(request.locale ?: \"en_US\", Platform.ANDROID),\n                    orientation = request.orientation ?: DeviceOrientation.PORTRAIT,\n                    disableAnimations = request.disableAnimations ?: true,\n                    cpuArchitecture = request.cpuArchitecture ?: CPU_ARCHITECTURE.ARM64,\n                )\n                is DeviceSpecRequest.Ios -> Ios(\n                    model = request.model ?: \"iPhone-11\",\n                    os = request.os ?: \"iOS-17-5\",\n                    locale = DeviceLocale.fromString(request.locale ?: \"en_US\", Platform.IOS),\n                    orientation = request.orientation ?: DeviceOrientation.PORTRAIT,\n                    disableAnimations = request.disableAnimations ?: true,\n                    snapshotKeyHonorModalViews = request.snapshotKeyHonorModalViews ?: true,\n                )\n                is DeviceSpecRequest.Web -> Web(\n                    model = request.model ?: \"chromium\",\n                    os = request.os ?: \"default\",\n                    locale = DeviceLocale.fromString(request.locale ?: \"en_US\", Platform.WEB),\n                )\n            }\n        }\n    }\n}\n\n/**\n * Request for setting up device config\n */\nsealed class DeviceSpecRequest {\n    abstract val platform: Platform\n\n    data class Android(\n        val model: String? = null,\n        val os: String? = null,\n        val locale: String? = null,\n        val orientation: DeviceOrientation? = null,\n        val disableAnimations: Boolean? = null,\n        val cpuArchitecture: CPU_ARCHITECTURE? = null,\n    ) : DeviceSpecRequest() {\n        override val platform = Platform.ANDROID\n    }\n\n    data class Ios(\n        val model: String? = null,\n        val os: String? = null,\n        val locale: String? = null,\n        val orientation: DeviceOrientation? = null,\n        val disableAnimations: Boolean? = null,\n        val snapshotKeyHonorModalViews: Boolean? = null,\n    ) : DeviceSpecRequest() {\n        override val platform = Platform.IOS\n    }\n\n    data class Web(\n        val model: String? = null,\n        val os: String? = null,\n        val locale: String? = null,\n    ) : DeviceSpecRequest() {\n        override val platform = Platform.WEB\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/device/Platform.kt",
    "content": "package maestro.device\n\nenum class Platform(val description: String) {\n    ANDROID(\"Android\"),\n    IOS(\"iOS\"),\n    WEB(\"Web\");\n\n    companion object {\n        fun fromString(p: String): Platform {\n            return entries.firstOrNull { it.description.equals(p, ignoreCase = true) }\n                ?: throw IllegalArgumentException(\n                    \"Unknown platform: '$p'. Must be one of: ${entries.joinToString { it.description }}\"\n                )\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/device/locale/AndroidLocale.kt",
    "content": "package maestro.device.locale\n\nimport maestro.device.Platform\nimport java.util.Locale\n\n/**\n * Type-safe enum representing Android-supported languages.\n * These are a subset of languages available in Java's Locale API.\n */\nenum class AndroidLanguage(val code: String) {\n  ARABIC(\"ar\"),\n  BULGARIAN(\"bg\"),\n  CATALAN(\"ca\"),\n  CHINESE(\"zh\"),\n  CROATIAN(\"hr\"),\n  CZECH(\"cs\"),\n  DANISH(\"da\"),\n  DUTCH(\"nl\"),\n  ENGLISH(\"en\"),\n  FINNISH(\"fi\"),\n  FRENCH(\"fr\"),\n  GERMAN(\"de\"),\n  GREEK(\"el\"),\n  HEBREW(\"he\"),\n  HINDI(\"hi\"),\n  HUNGARIAN(\"hu\"),\n  INDONESIAN(\"id\"),\n  ITALIAN(\"it\"),\n  JAPANESE(\"ja\"),\n  KOREAN(\"ko\"),\n  LATVIAN(\"lv\"),\n  LITHUANIAN(\"lt\"),\n  NORWEGIAN_BOKMOL(\"nb\"),\n  POLISH(\"pl\"),\n  PORTUGUESE(\"pt\"),\n  ROMANIAN(\"ro\"),\n  RUSSIAN(\"ru\"),\n  SERBIAN(\"sr\"),\n  SLOVAK(\"sk\"),\n  SLOVENIAN(\"sl\"),\n  SPANISH(\"es\"),\n  SWEDISH(\"sv\"),\n  TAGALOG(\"tl\"),\n  THAI(\"th\"),\n  TURKISH(\"tr\"),\n  UKRAINIAN(\"uk\"),\n  VIETNAMESE(\"vi\");\n\n  // Gets the display name for this language using Java's Locale API.\n  // Falls back to the enum name if Locale doesn't recognize the code.\n  val displayName: String\n    get() = try {\n      Locale(code).getDisplayLanguage(Locale.US).takeIf { it.isNotBlank() }\n        ?: code.uppercase()\n    } catch (e: Exception) {\n      code.uppercase()\n    }\n\n  companion object {\n    init {\n      // Validate that all enum codes are valid ISO-639-1 language codes\n      val validISOCodes = Locale.getISOLanguages().toSet()\n      entries.forEach { language ->\n        require(language.code in validISOCodes) {\n          \"Language code '${language.code}' in AndroidLanguage enum is not a valid ISO-639-1 code\"\n        }\n      }\n    }\n\n    // Gets all language codes as a set.\n    val allCodes: Set<String>\n      get() = entries.map { it.code }.toSet()\n\n    // Finds a language by its code.\n    fun fromCode(code: String): AndroidLanguage? {\n      return entries.find { it.code == code }\n    }\n  }\n}\n\n/**\n * Type-safe enum representing Android-supported countries.\n * These are a subset of countries available in Java's Locale API.\n */\nenum class AndroidCountry(val code: String) {\n  AUSTRALIA(\"AU\"),\n  AUSTRIA(\"AT\"),\n  BELGIUM(\"BE\"),\n  BRAZIL(\"BR\"),\n  BRITAIN(\"GB\"),\n  BULGARIA(\"BG\"),\n  CANADA(\"CA\"),\n  CROATIA(\"HR\"),\n  CZECH_REPUBLIC(\"CZ\"),\n  DENMARK(\"DK\"),\n  EGYPT(\"EG\"),\n  FINLAND(\"FI\"),\n  FRANCE(\"FR\"),\n  GERMANY(\"DE\"),\n  GREECE(\"GR\"),\n  HONG_KONG(\"HK\"),\n  HUNGARY(\"HU\"),\n  INDIA(\"IN\"),\n  INDONESIA(\"ID\"),\n  IRELAND(\"IE\"),\n  ISRAEL(\"IL\"),\n  ITALY(\"IT\"),\n  JAPAN(\"JP\"),\n  KOREA(\"KR\"),\n  LATVIA(\"LV\"),\n  LIECHTENSTEIN(\"LI\"),\n  LITHUANIA(\"LT\"),\n  MEXICO(\"MX\"),\n  NETHERLANDS(\"NL\"),\n  NEW_ZEALAND(\"NZ\"),\n  NORWAY(\"NO\"),\n  PHILIPPINES(\"PH\"),\n  POLAND(\"PL\"),\n  PORTUGAL(\"PT\"),\n  PRC(\"CN\"),\n  ROMANIA(\"RO\"),\n  RUSSIA(\"RU\"),\n  SERBIA(\"RS\"),\n  SINGAPORE(\"SG\"),\n  SLOVAKIA(\"SK\"),\n  SLOVENIA(\"SI\"),\n  SPAIN(\"ES\"),\n  SWEDEN(\"SE\"),\n  SWITZERLAND(\"CH\"),\n  TAIWAN(\"TW\"),\n  THAILAND(\"TH\"),\n  TURKEY(\"TR\"),\n  UKRAINE(\"UA\"),\n  USA(\"US\"),\n  VIETNAM(\"VN\"),\n  ZIMBABWE(\"ZA\");\n\n  // Gets the display name for this country using Java's Locale API.\n  // Falls back to the enum name if Locale doesn't recognize the code.\n  val displayName: String\n    get() = try {\n      Locale(\"\", code).getDisplayCountry(Locale.US).takeIf { it.isNotBlank() }\n        ?: code\n    } catch (e: Exception) {\n      code\n    }\n\n  companion object {\n    init {\n      // Validate that all enum codes are valid ISO-3166-1 country codes\n      val validISOCodes = Locale.getISOCountries().toSet()\n      entries.forEach { country ->\n        require(country.code in validISOCodes) {\n          \"Country code '${country.code}' in AndroidCountry enum is not a valid ISO-3166-1 code\"\n        }\n      }\n    }\n\n    // Gets all country codes as a set.\n    val allCodes: Set<String>\n      get() = entries.map { it.code }.toSet()\n\n    // Finds a country by its code.\n    fun fromCode(code: String): AndroidCountry? {\n      return entries.find { it.code == code }\n    }\n  }\n}\n\n/**\n * Android device locale - a dynamic combination of language and country.\n * Android supports all combinations of supported languages and countries.\n */\ndata class AndroidLocale(\n  val language: AndroidLanguage,\n  val country: AndroidCountry\n) : DeviceLocale {\n\n  override val code: String\n    get() = \"${language.code}_${country.code}\"\n\n  override val displayName: String\n    get() = try {\n      Locale(language.code, country.code).getDisplayName(Locale.US)\n    } catch (e: Exception) {\n      \"${language.displayName} (${country.displayName})\"\n    }\n\n  override val languageCode: String\n    get() = language.code\n\n  override val countryCode: String\n    get() = country.code\n\n  override val platform: Platform = Platform.ANDROID\n\n  companion object {\n    /**\n     * Cached set of available Java Locale combinations (language_country format).\n     * Computed once and reused for efficient O(1) lookups.\n     */\n    private val availableJavaLocaleCombinations: Set<String> = Locale.getAvailableLocales()\n      .map { \"${it.language}_${it.country}\" }\n      .toSet()\n\n    /**\n     * Creates an AndroidLocale from a locale string (e.g., \"en_US\").\n     * Validates that both language and country codes are supported.\n     * @throws LocaleValidationException if the locale string is invalid or unsupported\n     */\n    fun fromString(localeString: String): AndroidLocale {\n      val parts = localeString.split(\"_\", \"-\")\n      if (parts.size != 2) {\n        throw LocaleValidationException(\n          \"Failed to validate device locale, $localeString is not a valid format. Expected format: language_country, e.g., en_US.\"\n        )\n      }\n\n      val languageCode = parts[0]\n      val countryCode = parts[1]\n\n      val language = AndroidLanguage.fromCode(languageCode)\n        ?: throw LocaleValidationException(\n          \"Failed to validate Android device language, $languageCode is not a supported Android language. Here is a full list of supported languageCode: \\n\\n ${AndroidLanguage.allCodes.joinToString(\", \")}\"\n        )\n\n      val country = AndroidCountry.fromCode(countryCode)\n        ?: throw LocaleValidationException(\n          \"Failed to validate Android device country, $countryCode is not a supported Android country. Here is a full list of supported countryCode: \\n\\n ${AndroidCountry.allCodes.joinToString(\", \")}\"\n        )\n\n      // Validate that the language-country combination exists in Java Locale\n      if (\"${languageCode}_${countryCode}\" !in availableJavaLocaleCombinations) {\n        throw LocaleValidationException(\n          \"Failed to validate Android device locale combination, $localeString is not a valid locale combination. Here is a full list of supported locales: \\n\\n ${allCodes.joinToString(\", \")}\"\n        )\n      }\n\n      return AndroidLocale(language, country)\n    }\n\n    /**\n     * Validates if a locale string represents a valid Android locale combination.\n     */\n    fun isValid(localeString: String): Boolean {\n      return try {\n        fromString(localeString)\n        true\n      } catch (e: LocaleValidationException) {\n        false\n      }\n    }\n\n    /**\n     * Generates all valid Android locale combinations dynamically.\n     * This creates all combinations of supported languages and countries\n     * that are valid Java locale combinations.\n     */\n    val all: List<AndroidLocale>\n      get() = AndroidLanguage.entries.flatMap { language ->\n        AndroidCountry.entries.mapNotNull { country ->\n          val localeKey = \"${language.code}_${country.code}\"\n          // Only include combinations that are valid Java locales\n          if (localeKey in availableJavaLocaleCombinations) {\n            AndroidLocale(language, country)\n          } else {\n            null\n          }\n        }\n      }\n\n    /**\n     * Gets all locale codes as a set.\n     */\n    val allCodes: Set<String>\n      get() = all.map { it.code }.toSet()\n\n    /**\n     * Finds a locale code given language and country codes for Android.\n     * @return Locale code if found (e.g., \"en_US\"), null otherwise\n     */\n    fun find(languageCode: String, countryCode: String): String? {\n      return if (isValid(\"${languageCode}_${countryCode}\")) {\n        \"${languageCode}_${countryCode}\"\n      } else {\n        null\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/device/locale/DeviceLocale.kt",
    "content": "package maestro.device.locale\n\nimport maestro.device.Platform\nimport java.util.Locale\n\n/**\n * Sealed interface for device locales to provide common behavior when needed.\n * This allows platform-agnostic code to work with locales while keeping\n * Android, iOS, and Web types separate for platform-specific code.\n */\nsealed interface DeviceLocale {\n  // Gets the locale code representation (e.g., \"en_US\", \"zh-Hans\").\n  val code: String\n\n  // Gets the display name for this locale.\n  val displayName: String\n\n  // Gets the language code for this locale (e.g., \"en\", \"fr\", \"zh\").\n  val languageCode: String\n\n  // Gets the country code for this locale (e.g., \"US\", \"FR\", \"CN\").\n  val countryCode: String\n\n  // Gets the platform this locale is for.\n  val platform: Platform\n\n  companion object {\n    /**\n     * Creates a DeviceLocale from a locale string and platform.\n     * This is useful for platform-agnostic code that needs to work with locales.\n     *\n     * @throws LocaleValidationException if the locale string is invalid or unsupported\n     */\n    fun fromString(localeString: String, platform: Platform): DeviceLocale {\n      return when (platform) {\n        Platform.ANDROID -> AndroidLocale.fromString(localeString)\n        Platform.IOS -> IosLocale.fromString(localeString)\n        Platform.WEB -> WebLocale.fromString(localeString)\n      }\n    }\n\n    /**\n     * Validates if a locale string is valid for the given platform.\n     */\n    fun isValid(localeString: String, platform: Platform): Boolean {\n      return try {\n        fromString(localeString, platform)\n        true\n      } catch (e: LocaleValidationException) {\n        false\n      }\n    }\n\n    /**\n     * Gets all supported locales for a platform.\n     */\n    fun all(platform: Platform): List<DeviceLocale> {\n      return when (platform) {\n        Platform.ANDROID -> AndroidLocale.all\n        Platform.IOS -> IosLocale.entries\n        Platform.WEB -> WebLocale.entries\n      }\n    }\n\n    /**\n     * Gets all supported locale codes for a platform.\n     */\n    fun allCodes(platform: Platform): Set<String> {\n      return when (platform) {\n        Platform.ANDROID -> AndroidLocale.allCodes\n        Platform.IOS -> IosLocale.allCodes\n        Platform.WEB -> WebLocale.allCodes\n      }\n    }\n\n    /**\n     * Finds a locale code given language and country codes for the specified platform.\n     * @return Locale code if found (e.g., \"en_US\" or \"en-US\"), null otherwise\n     */\n    fun find(languageCode: String, countryCode: String, platform: Platform): String? {\n      return when (platform) {\n        Platform.ANDROID -> AndroidLocale.find(languageCode, countryCode)\n        Platform.IOS -> IosLocale.find(languageCode, countryCode)\n        Platform.WEB -> WebLocale.find(languageCode, countryCode)\n      }\n    }\n\n    // Gets the default locale value\n    fun getDefault(platform: Platform): DeviceLocale {\n      return when(platform) {\n        Platform.ANDROID -> AndroidLocale.fromString(\"en_US\")\n        Platform.IOS -> IosLocale.fromString(\"en_US\")\n        Platform.WEB -> WebLocale.fromString(\"en_US\")\n      }\n    }\n\n    /**\n     * Generates a display name for a locale code using Java's Locale API.\n     * Parses locale codes in both underscore (en_US) and hyphen (en-US) formats.\n     * Falls back to returning the original code if parsing fails.\n     *\n     * @param localeCode The locale code to generate a display name for\n     * @return A human-readable display name (e.g., \"en_US\" -> \"English (United States)\"), or the original code if parsing fails\n     */\n    internal fun getDisplayNameFromCode(localeCode: String): String {\n      try {\n        val parts = localeCode.split(\"_\", \"-\")\n        if (parts.size == 2) {\n          val javaLocale = Locale(parts[0], parts[1])\n          val displayName = javaLocale.getDisplayName(Locale.US)\n          if (displayName.isNotBlank()) {\n            return displayName\n          }\n        }\n      } catch (e: Exception) {\n        // Fall through to return locale string\n      }\n      return localeCode\n    }\n  }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/device/locale/IosLocale.kt",
    "content": "package maestro.device.locale\n\nimport maestro.device.Platform\n\n/**\n * iOS device locale - fixed enum of supported locale combinations.\n */\nenum class IosLocale(override val code: String) : DeviceLocale {\n  EN_AU(\"en_AU\"),\n  NL_BE(\"nl_BE\"),\n  FR_BE(\"fr_BE\"),\n  MS_BN(\"ms_BN\"),\n  EN_CA(\"en_CA\"),\n  FR_CA(\"fr_CA\"),\n  CS_CZ(\"cs_CZ\"),\n  FI_FI(\"fi_FI\"),\n  DE_DE(\"de_DE\"),\n  EL_GR(\"el_GR\"),\n  HU_HU(\"hu_HU\"),\n  HI_IN(\"hi_IN\"),\n  ID_ID(\"id_ID\"),\n  HE_IL(\"he_IL\"),\n  IT_IT(\"it_IT\"),\n  JA_JP(\"ja_JP\"),\n  MS_MY(\"ms_MY\"),\n  NL_NL(\"nl_NL\"),\n  EN_NZ(\"en_NZ\"),\n  NB_NO(\"nb_NO\"),\n  TL_PH(\"tl_PH\"),\n  PL_PL(\"pl_PL\"),\n  ZH_CN(\"zh_CN\"),\n  RO_RO(\"ro_RO\"),\n  RU_RU(\"ru_RU\"),\n  EN_SG(\"en_SG\"),\n  SK_SK(\"sk_SK\"),\n  KO_KR(\"ko_KR\"),\n  SV_SE(\"sv_SE\"),\n  ZH_TW(\"zh_TW\"),\n  TH_TH(\"th_TH\"),\n  TR_TR(\"tr_TR\"),\n  EN_GB(\"en_GB\"),\n  UK_UA(\"uk_UA\"),\n  ES_US(\"es_US\"),\n  EN_US(\"en_US\"),\n  VI_VN(\"vi_VN\"),\n  ES_ES(\"es_ES\"),\n  FR_FR(\"fr_FR\"),\n\n  // Hyphenated locales (language-country format)\n  PT_BR(\"pt-BR\"),\n  ZH_HK(\"zh-HK\"),\n  EN_IN(\"en-IN\"),\n  EN_IE(\"en-IE\"),\n  ES_MX(\"es-MX\"),\n  EN_ZA(\"en-ZA\"),\n\n  // iOS-specific locale formats (not standard ISO locale combinations)\n  ZH_HANS(\"zh-Hans\"),\n  ZH_HANT(\"zh-Hant\"),\n  ES_419(\"es-419\");\n\n  override val displayName: String\n    get() {\n      return when (this) {\n        ZH_HANS -> \"Chinese (Simplified)\"\n        ZH_HANT -> \"Chinese (Traditional)\"\n        ES_419 -> \"Spanish (Latin America)\"\n        else -> DeviceLocale.getDisplayNameFromCode(code)\n      }\n    }\n\n  override val languageCode: String\n    get() {\n      val parts = code.split(\"_\", \"-\")\n      return parts[0]\n    }\n\n  override val countryCode: String\n    get() = code.split(\"_\", \"-\")[1]\n\n  override val platform: Platform = Platform.IOS\n\n  companion object {\n    /**\n     * Gets all locale codes as a set.\n     */\n    val allCodes: Set<String>\n      get() = entries.map { it.code }.toSet()\n\n    /**\n     * Finds a locale by its string representation.\n     * Accepts both underscore and hyphen formats.\n     *\n     * @throws LocaleValidationException if not found\n     */\n    fun fromString(localeString: String): IosLocale {\n      return entries.find {\n        it.code == localeString ||\n                it.code.replace(\"_\", \"-\") == localeString ||\n                it.code.replace(\"-\", \"_\") == localeString\n      } ?: throw LocaleValidationException(\"Failed to validate iOS device locale. Here is a full list of supported locales: \\n\\n ${allCodes.joinToString(\", \")}\")\n    }\n\n    /**\n     * Validates if a locale string is valid for iOS.\n     */\n    fun isValid(localeString: String): Boolean {\n      return entries.any {\n        it.code == localeString ||\n                it.code.replace(\"_\", \"-\") == localeString ||\n                it.code.replace(\"-\", \"_\") == localeString\n      }\n    }\n\n    /**\n     * Finds a locale code given language and country codes.\n     * Tries both underscore and hyphen formats.\n     * @return Locale code if found (e.g., \"en_US\" or \"en-US\"), null otherwise\n     */\n    fun find(languageCode: String, countryCode: String): String? {\n      // Try underscore format first\n      val underscoreFormat = \"${languageCode}_$countryCode\"\n      if (isValid(underscoreFormat)) {\n        return underscoreFormat\n      }\n\n      // Try hyphen format\n      val hyphenFormat = \"$languageCode-$countryCode\"\n      if (isValid(hyphenFormat)) {\n        return hyphenFormat\n      }\n\n      return null\n    }\n  }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/device/locale/LocaleValidationException.kt",
    "content": "package maestro.device.locale\n\nclass LocaleValidationException(message: String): Exception(message)\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/device/locale/WebLocale.kt",
    "content": "package maestro.device.locale\nimport maestro.device.Platform\n\n/**\n * Web device locale - fixed enum of supported locale combinations.\n */\nenum class WebLocale(override val code: String) : DeviceLocale {\n  EN_US(\"en_US\");\n\n  override val displayName: String\n    get() = DeviceLocale.getDisplayNameFromCode(code)\n\n  override val languageCode: String\n    get() {\n      val parts = code.split(\"_\", \"-\")\n      return parts[0]\n    }\n\n  override val countryCode: String\n    get() = code.split(\"_\", \"-\")[1]\n\n  override val platform: Platform = Platform.WEB\n\n  companion object {\n    /**\n     * Gets all locale codes as a set.\n     */\n    val allCodes: Set<String>\n      get() = entries.map { it.code }.toSet()\n\n    /**\n     * Finds a locale by its string representation.\n     * @throws LocaleValidationException if not found\n     */\n    fun fromString(localeString: String): WebLocale {\n      return entries.find { it.code == localeString }\n        ?: throw LocaleValidationException(\"Failed to validate web browser locale: $localeString. Here is a full list of supported locales: \\n\\n ${allCodes.joinToString(\", \")}\")\n    }\n\n    /**\n     * Validates if a locale string is valid for Web.\n     */\n    fun isValid(localeString: String): Boolean {\n      return entries.any { it.code == localeString }\n    }\n\n    /**\n     * Finds a locale code given language and country codes.\n     */\n    fun find(languageCode: String, countryCode: String): String? {\n      val underscoreFormat = \"${languageCode}_$countryCode\"\n      if (isValid(underscoreFormat)) {\n        return underscoreFormat\n      }\n      return null\n    }\n  }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/device/serialization/DeviceLocaleSerializer.kt",
    "content": "package maestro.device.serialization\n\nimport com.fasterxml.jackson.core.JsonGenerator\nimport com.fasterxml.jackson.core.JsonParser\nimport com.fasterxml.jackson.databind.DeserializationContext\nimport com.fasterxml.jackson.databind.JsonNode\nimport com.fasterxml.jackson.databind.SerializerProvider\nimport com.fasterxml.jackson.databind.deser.std.StdDeserializer\nimport com.fasterxml.jackson.databind.ser.std.StdSerializer\nimport maestro.device.Platform\nimport maestro.device.locale.DeviceLocale\n\nclass DeviceLocaleSerializer : StdSerializer<DeviceLocale>(DeviceLocale::class.java) {\n  override fun serialize(value: DeviceLocale, gen: JsonGenerator, provider: SerializerProvider) {\n    gen.writeStartObject()\n    gen.writeStringField(\"code\", value.code)\n    gen.writeStringField(\"platform\", value.platform.name)\n    gen.writeEndObject()\n  }\n}\n\nclass DeviceLocaleDeserializer : StdDeserializer<DeviceLocale>(DeviceLocale::class.java) {\n  override fun deserialize(p: JsonParser, ctxt: DeserializationContext): DeviceLocale {\n    val node = p.codec.readTree<JsonNode>(p)\n    val code = node.get(\"code\").asText()\n    val platform = Platform.valueOf(node.get(\"platform\").asText())\n    return DeviceLocale.fromString(code, platform)\n  }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/device/serialization/DeviceSpecModule.kt",
    "content": "package maestro.device.serialization\n\nimport com.fasterxml.jackson.databind.module.SimpleModule\nimport maestro.device.locale.DeviceLocale\n\nclass DeviceSpecModule : SimpleModule(\"DeviceSpecModule\") {\n    init {\n        addSerializer(DeviceLocale::class.java, DeviceLocaleSerializer())\n        addDeserializer(DeviceLocale::class.java, DeviceLocaleDeserializer())\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/device/util/AndroidEnvUtils.kt",
    "content": "package maestro.device.util\n\nimport maestro.device.DeviceError\nimport okio.buffer\nimport okio.source\nimport java.io.File\nimport java.nio.file.Path\nimport java.nio.file.Paths\nimport java.util.concurrent.TimeUnit\nimport java.util.concurrent.TimeoutException\n\nobject AndroidEnvUtils {\n\n    private val androidHome: String?\n        get() {\n            return System.getenv(\"ANDROID_HOME\")\n                ?: System.getenv(\"ANDROID_SDK_ROOT\")\n                ?: System.getenv(\"ANDROID_SDK_HOME\")\n                ?: System.getenv(\"ANDROID_SDK\")\n                ?: System.getenv(\"ANDROID\")\n        }\n\n    private val androidUserHome: Path\n        get() {\n            if (System.getenv(\"ANDROID_USER_HOME\") != null) {\n                return Paths.get(System.getenv(\"ANDROID_USER_HOME\"))\n            }\n\n            return Paths.get(System.getProperty(\"user.home\"), \".android\")\n        }\n\n    val androidAvdHome: File\n        get() {\n            System.getenv(\"ANDROID_AVD_HOME\")?.let { return File(it) }\n            return androidUserHome.resolve(\"avd\").toFile()\n        }\n\n    /**\n     * Returns SDK versions that are used by AVDs present in the system.\n     */\n    val androidEmulatorSdkVersions: List<String>\n        get() {\n            val iniFiles = androidUserHome.resolve(\"avd\").toFile()\n                .listFiles { file -> file.extension == \"ini\" }\n                ?.map { it } ?: emptyList()\n\n            val versions = iniFiles\n                .mapNotNull { iniFile -> iniFile.readLines().firstOrNull { it.startsWith(\"target=\") } }\n                .map { line -> line.split('=') }\n                .filter { lineParts -> lineParts.size == 2 }\n                .map { lineParts -> lineParts[1] }\n                .distinct()\n                .toList()\n\n            return versions\n        }\n\n    /**\n     * @return Path to java compatible android cmdline-tools\n     */\n    fun requireCommandLineTools(tool: String): File {\n        val androidHome = androidHome\n            ?: throw DeviceError(\"Could not detect Android home environment variable is not set. Ensure that either ANDROID_HOME or ANDROID_SDK_ROOT is set.\")\n\n        val javaVersion = SystemInfo.getJavaVersion()\n        val recommendedToolsVersion = getRecommendedToolsVersion()\n\n        val tools = File(androidHome, \"cmdline-tools\")\n        if (!tools.exists()) {\n            throw DeviceError(\n                \"Missing required component cmdline-tools. To install:\\n\" +\n                        \"1) Open Android Studio SDK manager \\n\" +\n                        \"2) Check \\\"Show package details\\\" to show all versions\\n\" +\n                        \"3) Install Android SDK Command-Line Tools. Recommended version: $recommendedToolsVersion\\n\" +\n                        \"* https://developer.android.com/studio/intro/update#sdk-manager\"\n            )\n        }\n\n        return findCompatibleCommandLineTool(tool)\n            ?: throw DeviceError(\n                \"Unable to find compatible cmdline-tools ($tools/<version>) for java version $javaVersion.\\n\\n\" +\n                        \"Try to install a different cmdline-tools version:\\n\" +\n                        \"1) Open Android Studio SDK manager \\n\" +\n                        \"2) Check \\\"Show package details\\\" to show all versions\\n\" +\n                        \"3) Install Android SDK Command-Line Tools. Recommended version: $recommendedToolsVersion\\n\" +\n                        \"* https://developer.android.com/studio/intro/update#sdk-manager\"\n            )\n    }\n\n    private fun getRecommendedToolsVersion(): String {\n        return when (SystemInfo.getJavaVersion()) {\n            8 -> \"8.0\"\n            11 -> \"10.0\"\n            17 -> \"11.0\"\n            else -> \"latest\"\n        }\n    }\n\n    private fun findCompatibleCommandLineTool(tool: String): File? {\n        val path = File(androidHome, \"cmdline-tools\")\n\n        var thisTool = tool\n        if (EnvUtils.isWindows()){\n            thisTool = \"$tool.bat\"\n        }\n\n        return path.listFiles()\n            ?.filter { it.isDirectory && File(it, \"/bin/$thisTool\").exists() }\n            ?.filter { isCommandLineToolCompatible(File(it, \"bin/$thisTool\")) }\n            ?.sortedWith(compareBy<File> { it.name != \"latest\" }\n                .thenByDescending { it.name.toDoubleOrNull() })\n            ?.map { File(it, \"bin/$thisTool\") }\n            ?.firstOrNull()\n    }\n\n    /**\n     * @return true if tool is compatible with running java version\n     */\n    private fun isCommandLineToolCompatible(toolPath: File): Boolean {\n        return runCatching {\n            val process = ProcessBuilder(listOf(toolPath.absolutePath, \"-h\")).start()\n            if (!process.waitFor(20, TimeUnit.SECONDS)) throw TimeoutException()\n            // don't rely on exit code, it's wrong\n            val output = process.errorStream\n                .source()\n                .buffer()\n                .readUtf8()\n            return !output.contains(\"UnsupportedClassVersionError\", ignoreCase = true)\n        }.getOrNull() ?: false\n    }\n\n    /**\n     * @return parses a string from 'avdmanager list device' and returns the pixel devices\n     */\n    fun parsePixelDevices(input: String): List<AvdDevice> {\n        val pattern = \"id: (\\\\d+) or \\\"(pixel.*?)\\\"\\\\n.*?Name: (.*?)\\\\n\".toRegex()\n        return pattern.findAll(input)\n            .map { matchResult ->\n                AvdDevice(\n                    matchResult.groupValues[1],\n                    matchResult.groupValues[2],\n                    matchResult.groupValues[3]\n                )\n            }\n            .toList()\n    }\n\n    fun requireEmulatorBinary(): File {\n        val androidHome = androidHome\n            ?: throw DeviceError(\"Could not detect Android home environment variable is not set. Ensure that either ANDROID_HOME or ANDROID_SDK_ROOT is set.\")\n        val firstChoice = File(androidHome, \"emulator/emulator\")\n        val secondChoice = File(androidHome, \"tools/emulator\")\n        return firstChoice.takeIf { it.exists() } ?: secondChoice.takeIf { it.exists() }\n        ?: throw DeviceError(\"Could not find emulator binary at either of the following paths:\\n$firstChoice\\n$secondChoice\")\n    }\n}\n\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/device/util/AvdDevice.kt",
    "content": "package maestro.device.util\n\ndata class AvdDevice(val numericId: String, val nameId: String, val name: String)\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/device/util/CommandLineUtils.kt",
    "content": "package maestro.device.util\n\nimport okio.buffer\nimport okio.source\nimport org.slf4j.LoggerFactory\nimport java.io.File\nimport java.util.concurrent.TimeUnit\nimport java.util.concurrent.TimeoutException\n\nobject CommandLineUtils {\n\n    private val isWindows = System.getProperty(\"os.name\").startsWith(\"Windows\")\n    private val nullFile = File(if (isWindows) \"NUL\" else \"/dev/null\")\n    private val logger = LoggerFactory.getLogger(CommandLineUtils::class.java)\n\n    @Suppress(\"SpreadOperator\")\n    fun runCommand(parts: List<String>, waitForCompletion: Boolean = true, outputFile: File? = null, params: Map<String, String> = emptyMap()): Process {\n        logger.info(\"Running command line operation: $parts\")\n\n        val processBuilder = if (outputFile != null) {\n            ProcessBuilder(*parts.toTypedArray())\n                .redirectOutput(outputFile)\n                .redirectError(outputFile)\n        } else {\n            ProcessBuilder(*parts.toTypedArray())\n                .redirectOutput(nullFile)\n                .redirectError(ProcessBuilder.Redirect.PIPE)\n        }\n\n        processBuilder.environment().putAll(params)\n        val process = processBuilder.start()\n\n        if (waitForCompletion) {\n            if (!process.waitFor(5, TimeUnit.MINUTES)) {\n                throw TimeoutException()\n            }\n\n            if (process.exitValue() != 0) {\n                val processOutput = process.errorStream\n                    .source()\n                    .buffer()\n                    .readUtf8()\n\n                logger.error(\"Process failed with exit code ${process.exitValue()}\")\n                logger.error(\"Error output $processOutput\")\n\n                throw IllegalStateException(processOutput)\n            }\n        }\n\n        return process\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/device/util/EnvUtils.kt",
    "content": "package maestro.device.util\n\nimport java.util.concurrent.TimeUnit\nimport java.util.concurrent.TimeoutException\n\nobject EnvUtils {\n  val OS_NAME: String = System.getProperty(\"os.name\")\n  val OS_ARCH: String = System.getProperty(\"os.arch\")\n  val OS_VERSION: String = System.getProperty(\"os.version\")\n\n  /**\n   * @return true, if we're executing from Windows Linux shell (WSL)\n   */\n  fun isWSL(): Boolean {\n    try {\n      val p1 = ProcessBuilder(\"printenv\", \"WSL_DISTRO_NAME\").start()\n      if (!p1.waitFor(20, TimeUnit.SECONDS)) throw TimeoutException()\n      if (p1.exitValue() == 0 && String(p1.inputStream.readBytes()).trim().isNotEmpty()) {\n        return true\n      }\n\n      val p2 = ProcessBuilder(\"printenv\", \"IS_WSL\").start()\n      if (!p2.waitFor(20, TimeUnit.SECONDS)) throw TimeoutException()\n      if (p2.exitValue() == 0 && String(p2.inputStream.readBytes()).trim().isNotEmpty()) {\n        return true\n      }\n    } catch (ignore: Exception) {\n      // ignore\n    }\n\n    return false\n  }\n\n  fun isWindows(): Boolean {\n    return OS_NAME.lowercase().startsWith(\"windows\")\n  }\n\n  /**\n   * Returns major version of Java, e.g. 8, 11, 17, 21.\n   */\n  fun getJavaVersion(): Int {\n    // Adapted from https://stackoverflow.com/a/2591122/7009800\n    val version = System.getProperty(\"java.version\")\n    return if (version.startsWith(\"1.\")) {\n      version.substring(2, 3).toInt()\n    } else {\n      val dot = version.indexOf(\".\")\n      if (dot != -1) version.substring(0, dot).toInt() else 0\n    }\n  }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/device/util/PrintUtils.kt",
    "content": "package maestro.device.util\n\nimport org.slf4j.LoggerFactory\n\n/**\n * Simplified version of PrintUtils for DeviceService\n */\nobject PrintUtils {\n    private val logger = LoggerFactory.getLogger(PrintUtils::class.java)\n\n    fun message(message: String) {\n        logger.info(message)\n        println(message)\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/device/util/SimctlList.kt",
    "content": "package maestro.device.util\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties\n\n@JsonIgnoreProperties(ignoreUnknown = true)\ndata class SimctlList(\n    val devicetypes: List<DeviceType>,\n    val runtimes: List<Runtime>,\n    val devices: Map<String, List<Device>>,\n    val pairs: Map<String, Pair>,\n) {\n    @JsonIgnoreProperties(ignoreUnknown = true)\n    data class DeviceType(\n        val identifier: String,\n        val name: String,\n        val bundlePath: String,\n        val productFamily: String,\n        val maxRuntimeVersion: Long?,\n        val maxRuntimeVersionString: String?,\n        val minRuntimeVersion: Long?,\n        val minRuntimeVersionString: String?,\n        val modelIdentifier: String?,\n    )\n\n    @JsonIgnoreProperties(ignoreUnknown = true)\n    data class Runtime(\n        val bundlePath: String,\n        val buildversion: String,\n        val platform: String?,\n        val runtimeRoot: String,\n        val identifier: String,\n        val version: String,\n        val isInternal: Boolean,\n        val isAvailable: Boolean,\n        val name: String,\n        val supportedDeviceTypes: List<DeviceType>,\n    )\n\n    @JsonIgnoreProperties(ignoreUnknown = true)\n    data class Device(\n        val name: String,\n        val dataPath: String?,\n        val logPath: String?,\n        val udid: String,\n        val isAvailable: Boolean,\n        val deviceTypeIdentifier: String?,\n        val state: String,\n        val availabilityError: String?,\n    )\n\n    @JsonIgnoreProperties(ignoreUnknown = true)\n    data class Pair(\n        val watch: Device,\n        val phone: Device,\n        val state: String,\n    )\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/device/util/SystemInfo.kt",
    "content": "package maestro.device.util\n\nobject SystemInfo {\n    fun getJavaVersion(): Int {\n        // Adapted from https://stackoverflow.com/a/2591122/7009800\n        val version = System.getProperty(\"java.version\")\n        return if (version.startsWith(\"1.\")) {\n            version.substring(2, 3).toInt()\n        } else {\n            val dot = version.indexOf(\".\")\n            if (dot != -1) version.substring(0, dot).toInt() else 0\n        }\n    }\n}\n\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/drivers/AndroidDriver.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.drivers\n\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport com.google.protobuf.ByteString\nimport dadb.AdbShellPacket\nimport dadb.AdbShellResponse\nimport dadb.AdbShellStream\nimport dadb.Dadb\nimport io.grpc.ManagedChannelBuilder\nimport io.grpc.Metadata\nimport io.grpc.Status\nimport io.grpc.StatusRuntimeException\nimport maestro.*\nimport maestro.MaestroDriverStartupException.AndroidDriverTimeoutException\nimport maestro.MaestroDriverStartupException.AndroidInstrumentationSetupFailure\nimport maestro.UiElement.Companion.toUiElementOrNull\nimport maestro.android.AndroidAppFiles\nimport maestro.android.AndroidLaunchArguments.toAndroidLaunchArguments\nimport maestro.android.chromedevtools.AndroidWebViewHierarchyClient\nimport maestro.device.DeviceOrientation\nimport maestro.device.Platform\nimport maestro.utils.BlockingStreamObserver\nimport maestro.utils.MaestroTimer\nimport maestro.utils.Metrics\nimport maestro.utils.MetricsProvider\nimport maestro.utils.ScreenshotUtils\nimport maestro.utils.StringUtils.toRegexSafe\nimport maestro_android.*\nimport net.dongliu.apk.parser.ApkFile\nimport okio.*\nimport org.slf4j.LoggerFactory\nimport org.w3c.dom.Element\nimport org.w3c.dom.Node\nimport java.io.File\nimport java.io.IOException\nimport java.util.concurrent.CompletableFuture\nimport java.util.concurrent.Executors\nimport java.util.concurrent.TimeUnit\nimport java.util.concurrent.TimeoutException\nimport javax.xml.parsers.DocumentBuilderFactory\nimport kotlin.io.use\n\nprivate val logger = LoggerFactory.getLogger(Maestro::class.java)\n\nprivate const val DefaultDriverHostPort = 7001\n\nclass AndroidDriver(\n    private val dadb: Dadb,\n    hostPort: Int? = null,\n    private var emulatorName: String = \"\",\n    private val reinstallDriver: Boolean = true,\n    private val metricsProvider: Metrics = MetricsProvider.getInstance(),\n    ) : Driver {\n    private var portForwarder: AutoCloseable? = null\n    private var open = false\n    private val hostPort: Int = hostPort ?: DefaultDriverHostPort\n\n    private val metrics = metricsProvider.withPrefix(\"maestro.driver\").withTags(mapOf(\"platform\" to \"android\", \"emulatorName\" to emulatorName))\n\n    private val channel = ManagedChannelBuilder.forAddress(\"localhost\", this.hostPort)\n        .usePlaintext()\n        .keepAliveTime(2, TimeUnit.MINUTES)\n        .keepAliveTimeout(20, TimeUnit.SECONDS)\n        .keepAliveWithoutCalls(true)\n        .build()\n    private val blockingStub = MaestroDriverGrpc.newBlockingStub(channel)\n    private val blockingStubWithTimeout get() = blockingStub.withDeadlineAfter(120, TimeUnit.SECONDS)\n    private val asyncStub = MaestroDriverGrpc.newStub(channel)\n    private val documentBuilderFactory = DocumentBuilderFactory.newInstance()\n\n    private val androidWebViewHierarchyClient = AndroidWebViewHierarchyClient(dadb)\n\n    private var instrumentationSession: AdbShellStream? = null\n    private var proxySet = false\n\n    private var isLocationMocked = false\n    private var chromeDevToolsEnabled = false\n\n    override fun name(): String {\n        return \"Android Device ($dadb)\"\n    }\n\n    override fun open() {\n        allocateForwarder()\n        installMaestroApks()\n        startInstrumentationSession(hostPort)\n\n        try {\n            awaitLaunch()\n        } catch (ignored: InterruptedException) {\n            instrumentationSession?.close()\n            return\n        }\n    }\n\n    private fun startInstrumentationSession(port: Int = 7001) {\n        val startTime = System.currentTimeMillis()\n        val apiLevel = getDeviceApiLevel()\n\n        val instrumentationCommand = buildString {\n            append(\"am instrument -w \")\n            if (apiLevel >= 26) append(\"-m \")\n            append(\"-e debug false \")\n            append(\"-e class 'dev.mobile.maestro.MaestroDriverService#grpcServer' \")\n            append(\"-e port $port \")\n            append(\"dev.mobile.maestro.test/androidx.test.runner.AndroidJUnitRunner &\\n\")\n        }\n\n        open = true\n        while (System.currentTimeMillis() - startTime < getStartupTimeout()) {\n            instrumentationSession = dadb.openShell(instrumentationCommand)\n\n            if (instrumentationSession.successfullyStarted()) {\n                return\n            }\n\n            instrumentationSession?.close()\n            Thread.sleep(100)\n        }\n        throw AndroidInstrumentationSetupFailure(\"Maestro instrumentation could not be initialized\")\n    }\n\n    private fun getDeviceApiLevel(): Int {\n        val response = dadb.openShell(\"getprop ro.build.version.sdk\").readAll()\n        if (response.exitCode != 0) {\n            throw IOException(\"Failed to get device API level: ${response.errorOutput}\")\n        }\n        return response.output.trim().toIntOrNull() ?: throw IOException(\"Invalid API level: ${response.output}\")\n    }\n\n\n    private fun allocateForwarder() {\n        portForwarder?.close()\n\n        portForwarder = dadb.tcpForward(\n            hostPort,\n            hostPort\n        )\n    }\n\n    private fun awaitLaunch() {\n        val startTime = System.currentTimeMillis()\n\n        while (System.currentTimeMillis() - startTime < getStartupTimeout()) {\n            runCatching {\n                dadb.open(\"tcp:$hostPort\").close()\n                return\n            }\n            Thread.sleep(100)\n        }\n\n        throw AndroidDriverTimeoutException(\"Maestro Android driver did not start up in time  ---  emulator [ ${emulatorName} ] & port  [ dadb.open( tcp:${hostPort} ) ]\")\n    }\n\n    override fun close() {\n        if (proxySet) {\n            resetProxy()\n        }\n        if (isLocationMocked) {\n            blockingStubWithTimeout.disableLocationUpdates(emptyRequest {  })\n            isLocationMocked = false\n        }\n\n        LOGGER.info(\"[Start] close port forwarder\")\n        portForwarder?.close()\n        LOGGER.info(\"[Done] close port forwarder\")\n\n        LOGGER.info(\"[Start] Uninstall driver from device\")\n        if (reinstallDriver) {\n            uninstallMaestroDriverApp()\n        }\n        if (reinstallDriver) {\n            uninstallMaestroServerApp()\n        }\n        LOGGER.info(\"[Done] Uninstall driver from device\")\n\n        LOGGER.info(\"[Start] Close instrumentation session\")\n        instrumentationSession?.close()\n        instrumentationSession = null\n        LOGGER.info(\"[Done] Close instrumentation session\")\n\n        LOGGER.info(\"[Start] Shutdown GRPC channel\")\n        channel.shutdown()\n        LOGGER.info(\"[Done] Shutdown GRPC channel\")\n\n        androidWebViewHierarchyClient.close()\n\n        if (!channel.awaitTermination(5, TimeUnit.SECONDS)) {\n            throw TimeoutException(\"Couldn't close Maestro Android driver due to gRPC timeout\")\n        }\n    }\n\n    override fun deviceInfo(): DeviceInfo {\n        return runDeviceCall(\"deviceInfo\") {\n            val response = blockingStubWithTimeout.deviceInfo(deviceInfoRequest {})\n\n            DeviceInfo(\n                platform = Platform.ANDROID,\n                widthPixels = response.widthPixels,\n                heightPixels = response.heightPixels,\n                widthGrid = response.widthPixels,\n                heightGrid = response.heightPixels,\n            )\n        }\n    }\n\n    override fun launchApp(\n        appId: String,\n        launchArguments: Map<String, Any>,\n    ) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"launchApp\", \"appId\" to appId)) {\n            if(!open) // pick device flow, no open() invocation\n                open()\n\n            if (!isPackageInstalled(appId)) {\n                throw IllegalArgumentException(\"Package $appId is not installed\")\n            }\n\n            val arguments = launchArguments.toAndroidLaunchArguments()\n            runDeviceCall(\"launchApp\") {\n                blockingStubWithTimeout.launchApp(\n                    launchAppRequest {\n                        this.packageName = appId\n                        this.arguments.addAll(arguments)\n                    }\n                ) ?: throw IllegalStateException(\"Maestro driver failed to launch app\")\n            }\n        }\n    }\n\n    override fun stopApp(appId: String) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"stopApp\", \"appId\" to appId)) {\n            // Note: If the package does not exist, this call does *not* throw an exception\n            shell(\"am force-stop $appId\")\n        }\n    }\n\n    override fun killApp(appId: String) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"killApp\", \"appId\" to appId)) {\n            // Kill is the adb command needed to trigger System-initiated Process Death\n            shell(\"am kill $appId\")\n        }\n    }\n\n    override fun clearAppState(appId: String) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"clearAppState\", \"appId\" to appId)) {\n            if (!isPackageInstalled(appId)) {\n                return@measured\n            }\n\n            shell(\"pm clear $appId\")\n        }\n    }\n\n    override fun clearKeychain() {\n        // No op\n    }\n\n    override fun tap(point: Point) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"tap\")) {\n            runDeviceCall(\"tap\") {\n                blockingStubWithTimeout.tap(\n                    tapRequest {\n                        x = point.x\n                        y = point.y\n                    }\n                ) ?: throw IllegalStateException(\"Response can't be null\")\n            }\n        }\n    }\n\n    override fun longPress(point: Point) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"longPress\")) {\n            dadb.shell(\"input swipe ${point.x} ${point.y} ${point.x} ${point.y} 3000\")\n        }\n    }\n\n    override fun pressKey(code: KeyCode) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"pressKey\")) {\n            val intCode: Int = when (code) {\n                KeyCode.ENTER -> 66\n                KeyCode.BACKSPACE -> 67\n                KeyCode.BACK -> 4\n                KeyCode.VOLUME_UP -> 24\n                KeyCode.VOLUME_DOWN -> 25\n                KeyCode.HOME -> 3\n                KeyCode.LOCK -> 276\n                KeyCode.REMOTE_UP -> 19\n                KeyCode.REMOTE_DOWN -> 20\n                KeyCode.REMOTE_LEFT -> 21\n                KeyCode.REMOTE_RIGHT -> 22\n                KeyCode.REMOTE_CENTER -> 23\n                KeyCode.REMOTE_PLAY_PAUSE -> 85\n                KeyCode.REMOTE_STOP -> 86\n                KeyCode.REMOTE_NEXT -> 87\n                KeyCode.REMOTE_PREVIOUS -> 88\n                KeyCode.REMOTE_REWIND -> 89\n                KeyCode.REMOTE_FAST_FORWARD -> 90\n                KeyCode.POWER -> 26\n                KeyCode.ESCAPE -> 111\n                KeyCode.TAB -> 62\n                KeyCode.REMOTE_SYSTEM_NAVIGATION_UP -> 280\n                KeyCode.REMOTE_SYSTEM_NAVIGATION_DOWN -> 281\n                KeyCode.REMOTE_BUTTON_A -> 96\n                KeyCode.REMOTE_BUTTON_B -> 97\n                KeyCode.REMOTE_MENU -> 82\n                KeyCode.TV_INPUT -> 178\n                KeyCode.TV_INPUT_HDMI_1 -> 243\n                KeyCode.TV_INPUT_HDMI_2 -> 244\n                KeyCode.TV_INPUT_HDMI_3 -> 245\n            }\n\n            dadb.shell(\"input keyevent $intCode\")\n            Thread.sleep(300)\n        }\n    }\n\n    override fun contentDescriptor(excludeKeyboardElements: Boolean): TreeNode {\n        return metrics.measured(\"operation\", mapOf(\"command\" to \"contentDescriptor\")) {\n            val response = callViewHierarchy()\n\n            val document = documentBuilderFactory\n                .newDocumentBuilder()\n                .parse(response.hierarchy.byteInputStream())\n\n            val baseTree = mapHierarchy(document)\n\n            val treeNode = androidWebViewHierarchyClient.augmentHierarchy(baseTree, chromeDevToolsEnabled)\n\n            if (excludeKeyboardElements) {\n                treeNode.excludeKeyboardElements() ?: treeNode\n            } else {\n                treeNode\n            }\n        }\n    }\n\n    private fun TreeNode.excludeKeyboardElements(): TreeNode? {\n        val filtered = children.mapNotNull {\n            it.excludeKeyboardElements()\n        }.toList()\n\n        val resourceId = attributes[\"resource-id\"]\n        if (resourceId != null && resourceId.startsWith(\"com.google.android.inputmethod.latin:id/\")) {\n            return null\n        }\n        return TreeNode(\n            attributes = attributes,\n            children = filtered,\n            clickable = clickable,\n            enabled = enabled,\n            focused = focused,\n            checked = checked,\n            selected = selected\n        )\n    }\n\n    private fun callViewHierarchy(attempt: Int = 1): MaestroAndroid.ViewHierarchyResponse {\n        return try {\n            blockingStubWithTimeout.viewHierarchy(viewHierarchyRequest {})\n        } catch (throwable: StatusRuntimeException) {\n            val status = Status.fromThrowable(throwable)\n            when (status.code) {\n                Status.Code.DEADLINE_EXCEEDED -> {\n                    LOGGER.error(\"Timeout while fetching view hierarchy\")\n                    throw throwable\n                }\n                Status.Code.UNAVAILABLE -> {\n                    if (throwable.cause is IOException || throwable.message?.contains(\"io exception\", ignoreCase = true) == true) {\n                        LOGGER.error(\"Not able to reach the gRPC server while fetching view hierarchy\")\n                    } else {\n                        LOGGER.error(\"Received UNAVAILABLE status with message: ${throwable.message}\")\n                    }\n                }\n                else -> {\n                    LOGGER.error(\"Unexpected error: ${status.code} - ${throwable.message}\")\n                }\n            }\n\n            // There is a bug in Android UiAutomator that rarely throws an NPE while dumping a view hierarchy.\n            // Trying to recover once by giving it a bit of time to settle.\n            LOGGER.error(\"Failed to get view hierarchy: ${status.description}\", throwable)\n\n            if (attempt > 0) {\n                MaestroTimer.sleep(MaestroTimer.Reason.BUFFER, 1000L)\n                return callViewHierarchy(attempt - 1)\n            }\n            throw throwable\n        }\n    }\n\n    override fun scrollVertical() {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"scrollVertical\")) {\n            swipe(SwipeDirection.UP, 400)\n        }\n    }\n\n    override fun isKeyboardVisible(): Boolean {\n        return metrics.measured(\"operation\", mapOf(\"command\" to \"isKeyboardVisible\")) {\n            val root = contentDescriptor().let {\n                val deviceInfo = deviceInfo()\n                val filtered = it.filterOutOfBounds(\n                    width = deviceInfo.widthGrid,\n                    height = deviceInfo.heightGrid\n                )\n                filtered ?: it\n            }\n            \"com.google.android.inputmethod.latin:id\" in jacksonObjectMapper().writeValueAsString(root)\n        }\n    }\n\n    override fun swipe(start: Point, end: Point, durationMs: Long) {\n        dadb.shell(\"input swipe ${start.x} ${start.y} ${end.x} ${end.y} $durationMs\")\n    }\n\n    override fun swipe(swipeDirection: SwipeDirection, durationMs: Long) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"swipeWithDirection\", \"direction\" to swipeDirection.name, \"durationMs\" to durationMs.toString())) {\n            val deviceInfo = deviceInfo()\n            when (swipeDirection) {\n                SwipeDirection.UP -> {\n                    val startX = (deviceInfo.widthGrid * 0.5f).toInt()\n                    val startY = (deviceInfo.heightGrid * 0.5f).toInt()\n                    val endX = (deviceInfo.widthGrid * 0.5f).toInt()\n                    val endY = (deviceInfo.heightGrid * 0.1f).toInt()\n                    directionalSwipe(\n                        durationMs,\n                        Point(startX, startY),\n                        Point(endX, endY)\n                    )\n                }\n\n                SwipeDirection.DOWN -> {\n                    val startX = (deviceInfo.widthGrid * 0.5f).toInt()\n                    val startY = (deviceInfo.heightGrid * 0.2f).toInt()\n                    val endX = (deviceInfo.widthGrid * 0.5f).toInt()\n                    val endY = (deviceInfo.heightGrid * 0.9f).toInt()\n                    directionalSwipe(\n                        durationMs,\n                        Point(startX, startY),\n                        Point(endX, endY)\n                    )\n                }\n\n                SwipeDirection.RIGHT -> {\n                    val startX = (deviceInfo.widthGrid * 0.1f).toInt()\n                    val startY = (deviceInfo.heightGrid * 0.5f).toInt()\n                    val endX = (deviceInfo.widthGrid * 0.9f).toInt()\n                    val endY = (deviceInfo.heightGrid * 0.5f).toInt()\n                    directionalSwipe(\n                        durationMs,\n                        Point(startX, startY),\n                        Point(endX, endY)\n                    )\n                }\n\n                SwipeDirection.LEFT -> {\n                    val startX = (deviceInfo.widthGrid * 0.9f).toInt()\n                    val startY = (deviceInfo.heightGrid * 0.5f).toInt()\n                    val endX = (deviceInfo.widthGrid * 0.1f).toInt()\n                    val endY = (deviceInfo.heightGrid * 0.5f).toInt()\n                    directionalSwipe(\n                        durationMs,\n                        Point(startX, startY),\n                        Point(endX, endY)\n                    )\n                }\n            }\n        }\n    }\n\n    override fun swipe(elementPoint: Point, direction: SwipeDirection, durationMs: Long) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"swipeWithElementPoint\", \"direction\" to direction.name, \"durationMs\" to durationMs.toString())) {\n            val deviceInfo = deviceInfo()\n            when (direction) {\n                SwipeDirection.UP -> {\n                    val endY = (deviceInfo.heightGrid * 0.1f).toInt()\n                    directionalSwipe(durationMs, elementPoint, Point(elementPoint.x, endY))\n                }\n\n                SwipeDirection.DOWN -> {\n                    val endY = (deviceInfo.heightGrid * 0.9f).toInt()\n                    directionalSwipe(durationMs, elementPoint, Point(elementPoint.x, endY))\n                }\n\n                SwipeDirection.RIGHT -> {\n                    val endX = (deviceInfo.widthGrid * 0.9f).toInt()\n                    directionalSwipe(durationMs, elementPoint, Point(endX, elementPoint.y))\n                }\n\n                SwipeDirection.LEFT -> {\n                    val endX = (deviceInfo.widthGrid * 0.1f).toInt()\n                    directionalSwipe(durationMs, elementPoint, Point(endX, elementPoint.y))\n                }\n            }\n        }\n    }\n\n    private fun directionalSwipe(durationMs: Long, start: Point, end: Point) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"directionalSwipe\", \"durationMs\" to durationMs.toString())) {\n            dadb.shell(\"input swipe ${start.x} ${start.y} ${end.x} ${end.y} $durationMs\")\n        }\n    }\n\n    override fun backPress() {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"backPress\")) {\n            dadb.shell(\"input keyevent 4\")\n            Thread.sleep(300)\n        }\n    }\n\n    override fun hideKeyboard() {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"hideKeyboard\")) {\n            dadb.shell(\"input keyevent 4\") // 'Back', which dismisses the keyboard before handing over to navigation\n            Thread.sleep(300)\n            waitForAppToSettle(null, null)\n        }\n    }\n\n    override fun takeScreenshot(out: Sink, compressed: Boolean) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"takeScreenshot\", \"compressed\" to compressed.toString())) {\n            runDeviceCall(\"takeScreenshot\") {\n                val response = blockingStubWithTimeout.screenshot(screenshotRequest {})\n                out.buffer().use {\n                    it.write(response.bytes.toByteArray())\n                }\n            }\n        }\n    }\n\n    override fun startScreenRecording(out: Sink): ScreenRecording {\n        return metrics.measured(\"operation\", mapOf(\"command\" to \"startScreenRecording\")) {\n\n            val deviceScreenRecordingPath = \"/sdcard/maestro-screenrecording.mp4\"\n\n            val future = CompletableFuture.runAsync({\n                val timeLimit = if (getDeviceApiLevel() >= 34) \"--time-limit 0\" else \"\"\n                try {\n                    shell(\"screenrecord $timeLimit --bit-rate '100000' $deviceScreenRecordingPath\")\n                } catch (e: IOException) {\n                    throw IOException(\n                        \"Failed to capture screen recording on the device. Note that some Android emulators do not support screen recording. \" +\n                            \"Try using a different Android emulator (eg. Pixel 5 / API 30)\",\n                        e,\n                    )\n                }\n            }, Executors.newSingleThreadExecutor())\n\n            object : ScreenRecording {\n                override fun close() {\n                    dadb.shell(\"killall -INT screenrecord\") // Ignore exit code\n                    future.get()\n                    Thread.sleep(3000)\n                    dadb.pull(out, deviceScreenRecordingPath)\n                }\n            }\n        }\n    }\n\n    override fun inputText(text: String) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"inputText\")) {\n            runDeviceCall(\"inputText\") {\n                blockingStubWithTimeout.inputText(inputTextRequest {\n                    this.text = text\n                }) ?: throw IllegalStateException(\"Input Response can't be null\")\n            }\n        }\n    }\n\n    override fun openLink(link: String, appId: String?, autoVerify: Boolean, browser: Boolean) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"openLink\", \"appId\" to appId, \"autoVerify\" to autoVerify.toString(), \"browser\" to browser.toString())) {\n            if (browser) {\n                openBrowser(link)\n            } else {\n                dadb.shell(\"am start -a android.intent.action.VIEW -d \\\"$link\\\"\")\n            }\n\n            if (autoVerify) {\n                autoVerifyApp(appId)\n            }\n        }\n    }\n\n    private fun autoVerifyApp(appId: String?) {\n        if (appId != null) {\n            autoVerifyWithAppName(appId)\n        }\n        autoVerifyChromeAgreement()\n    }\n\n    private fun autoVerifyWithAppName(appId: String) {\n        val appNameResult = runCatching {\n            val apkFile = AndroidAppFiles.getApkFile(dadb, appId)\n            val appName = ApkFile(apkFile).apkMeta.name\n            apkFile.delete()\n            appName\n        }\n        if (appNameResult.isSuccess) {\n            val appName = appNameResult.getOrThrow()\n            waitUntilScreenIsStatic(3000)\n            val appNameElement = filterByText(appName)\n            if (appNameElement != null) {\n                tap(appNameElement.bounds.center())\n                filterById(\"android:id/button_once\")?.let {\n                    tap(it.bounds.center())\n                }\n            } else {\n                val openWithAppElement = filterByText(\".*$appName.*\")\n                if (openWithAppElement != null) {\n                    filterById(\"android:id/button_once\")?.let {\n                        tap(it.bounds.center())\n                    }\n                }\n            }\n        }\n    }\n\n    private fun autoVerifyChromeAgreement() {\n        filterById(\"com.android.chrome:id/terms_accept\")?.let { tap(it.bounds.center()) }\n        waitForAppToSettle(null, null)\n        filterById(\"com.android.chrome:id/negative_button\")?.let { tap(it.bounds.center()) }\n    }\n\n    private fun filterByText(textRegex: String): UiElement? {\n        val textMatcher = Filters.textMatches(textRegex.toRegexSafe(REGEX_OPTIONS))\n        val filterFunc = Filters.deepestMatchingElement(textMatcher)\n        return filterFunc(contentDescriptor().aggregate()).firstOrNull()?.toUiElementOrNull()\n    }\n\n    private fun filterById(idRegex: String): UiElement? {\n        val idMatcher = Filters.idMatches(idRegex.toRegexSafe(REGEX_OPTIONS))\n        val filterFunc = Filters.deepestMatchingElement(idMatcher)\n        return filterFunc(contentDescriptor().aggregate()).firstOrNull()?.toUiElementOrNull()\n    }\n\n    private fun openBrowser(link: String) {\n        val installedPackages = installedPackages()\n        when {\n            installedPackages.contains(\"com.android.chrome\") -> {\n                dadb.shell(\"am start -a android.intent.action.VIEW -d \\\"$link\\\" com.android.chrome\")\n            }\n\n            installedPackages.contains(\"org.mozilla.firefox\") -> {\n                dadb.shell(\"am start -a android.intent.action.VIEW -d \\\"$link\\\" org.mozilla.firefox\")\n            }\n\n            else -> {\n                dadb.shell(\"am start -a android.intent.action.VIEW -d \\\"$link\\\"\")\n            }\n        }\n    }\n\n    private fun installedPackages() = shell(\"pm list packages\").split(\"\\n\")\n        .map { line: String -> line.split(\":\".toRegex()).toTypedArray() }\n        .filter { parts: Array<String> -> parts.size == 2 }\n        .map { parts: Array<String> -> parts[1] }\n\n    override fun setLocation(latitude: Double, longitude: Double) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"setLocation\")) {\n            if (!isLocationMocked) {\n                LOGGER.info(\"[Start] Setting up for mocking location $latitude, $longitude\")\n                shell(\"pm grant dev.mobile.maestro android.permission.ACCESS_FINE_LOCATION\")\n                shell(\"pm grant dev.mobile.maestro android.permission.ACCESS_COARSE_LOCATION\")\n                shell(\"appops set dev.mobile.maestro android:mock_location allow\")\n                runDeviceCall(\"enableMockLocationProviders\") {\n                    blockingStubWithTimeout.enableMockLocationProviders(emptyRequest {  })\n                }\n                LOGGER.info(\"[Done] Setting up for mocking location $latitude, $longitude\")\n\n                isLocationMocked = true\n            }\n\n            runDeviceCall(\"setLocation\") {\n                blockingStubWithTimeout.setLocation(\n                    setLocationRequest {\n                        this.latitude = latitude\n                        this.longitude = longitude\n                    }\n                ) ?: error(\"Set Location Response can't be null\")\n            }\n        }\n    }\n\n    override fun setOrientation(orientation: DeviceOrientation) {\n        // Disable accelerometer based rotation before overriding orientation\n        dadb.shell(\"settings put system accelerometer_rotation 0\")\n\n        when(orientation) {\n            DeviceOrientation.PORTRAIT -> dadb.shell(\"settings put system user_rotation 0\")\n            DeviceOrientation.LANDSCAPE_LEFT -> dadb.shell(\"settings put system user_rotation 1\")\n            DeviceOrientation.UPSIDE_DOWN -> dadb.shell(\"settings put system user_rotation 2\")\n            DeviceOrientation.LANDSCAPE_RIGHT -> dadb.shell(\"settings put system user_rotation 3\")\n        }\n    }\n\n    override fun eraseText(charactersToErase: Int) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"eraseText\", \"charactersToErase\" to charactersToErase.toString())) {\n            runDeviceCall(\"eraseText\") {\n                blockingStubWithTimeout.eraseAllText(\n                    eraseAllTextRequest {\n                        this.charactersToErase = charactersToErase\n                    }\n                ) ?: throw IllegalStateException(\"Erase Response can't be null\")\n            }\n        }\n    }\n\n    override fun setProxy(host: String, port: Int) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"setProxy\")) {\n            shell(\"\"\"settings put global http_proxy \"${host}:${port}\"\"\"\")\n            proxySet = true\n        }\n    }\n\n    override fun resetProxy() {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"resetProxy\")) {\n            shell(\"settings put global http_proxy :0\")\n        }\n    }\n\n    override fun isShutdown(): Boolean {\n        return metrics.measured(\"operation\", mapOf(\"command\" to \"isShutdown\")) {\n            channel.isShutdown\n        }\n    }\n\n    override fun isUnicodeInputSupported(): Boolean {\n        return false\n    }\n\n    override fun waitForAppToSettle(initialHierarchy: ViewHierarchy?, appId: String?, timeoutMs: Int?): ViewHierarchy? {\n        return metrics.measured(\"operation\", mapOf(\"command\" to \"waitForAppToSettle\", \"appId\" to appId, \"timeoutMs\" to timeoutMs.toString())) {\n            if (appId != null) {\n                waitForWindowToSettle(appId, initialHierarchy, timeoutMs)\n            } else {\n                ScreenshotUtils.waitForAppToSettle(initialHierarchy, this, timeoutMs)\n            }\n        }\n    }\n\n    private fun waitForWindowToSettle(\n        appId: String,\n        initialHierarchy: ViewHierarchy?,\n        timeoutMs: Int? = null\n    ): ViewHierarchy {\n        val endTime = System.currentTimeMillis() + WINDOW_UPDATE_TIMEOUT_MS\n        var hierarchy: ViewHierarchy? = null\n        do {\n            runDeviceCall(\"isWindowUpdating\") {\n                val windowUpdating = blockingStubWithTimeout.isWindowUpdating(checkWindowUpdatingRequest {\n                    this.appId = appId\n                }).isWindowUpdating\n\n                if (windowUpdating) {\n                    hierarchy = ScreenshotUtils.waitForAppToSettle(initialHierarchy, this, timeoutMs)\n                }\n            }\n        } while (System.currentTimeMillis() < endTime)\n\n        return hierarchy ?: ScreenshotUtils.waitForAppToSettle(initialHierarchy, this)\n    }\n\n    override fun waitUntilScreenIsStatic(timeoutMs: Long): Boolean {\n        return metrics.measured(\"operation\", mapOf(\"command\" to \"waitUntilScreenIsStatic\", \"timeoutMs\" to timeoutMs.toString())) {\n            ScreenshotUtils.waitUntilScreenIsStatic(timeoutMs, SCREENSHOT_DIFF_THRESHOLD, this)\n        }\n    }\n\n    override fun capabilities(): List<Capability> {\n        return metrics.measured(\"operation\", mapOf(\"command\" to \"capabilities\")) {\n            listOf(\n                Capability.FAST_HIERARCHY\n            )\n        }\n    }\n\n    override fun setPermissions(appId: String, permissions: Map<String, String>) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"setPermissions\", \"appId\" to appId)) {\n            val mutable = permissions.toMutableMap()\n            mutable.remove(\"all\")?.let { value ->\n                setAllPermissions(appId, value)\n            }\n\n            mutable.forEach { permission ->\n                val permissionValue = translatePermissionValue(permission.value)\n                translatePermissionName(permission.key).forEach { permissionName ->\n                    setPermissionInternal(appId, permissionName, permissionValue)\n                }\n            }\n        }\n    }\n\n    override fun addMedia(mediaFiles: List<File>) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"addMedia\", \"mediaFilesCount\" to mediaFiles.size.toString())) {\n            LOGGER.info(\"[Start] Adding media files\")\n            mediaFiles.forEach { addMediaToDevice(it) }\n            LOGGER.info(\"[Done] Adding media files\")\n        }\n    }\n\n    override fun isAirplaneModeEnabled(): Boolean {\n        return metrics.measured(\"operation\", mapOf(\"command\" to \"isAirplaneModeEnabled\")) {\n            when (val result = shell(\"cmd connectivity airplane-mode\").trim()) {\n                \"No shell command implementation.\", \"\" -> {\n                    LOGGER.debug(\"Falling back to old airplane mode read method\")\n                    when (val fallbackResult = shell(\"settings get global airplane_mode_on\").trim()) {\n                        \"0\" -> false\n                        \"1\" -> true\n                        else -> throw IllegalStateException(\"Received invalid response from while trying to read airplane mode state: $fallbackResult\")\n                    }\n                }\n\n                \"disabled\" -> false\n                \"enabled\" -> true\n                else -> throw IllegalStateException(\"Received invalid response while trying to read airplane mode state: $result\")\n            }\n        }\n    }\n\n    override fun setAirplaneMode(enabled: Boolean) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"setAirplaneMode\", \"enabled\" to enabled.toString())) {\n            // fallback to old way on API < 28\n            if (getDeviceApiLevel() < 28) {\n                val num = if (enabled) 1 else 0\n                shell(\"settings put global airplane_mode_on $num\")\n                // We need to broadcast the change to really apply it\n                broadcastAirplaneMode(enabled)\n                return@measured\n            }\n            val value = if (enabled) \"enable\" else \"disable\"\n            shell(\"cmd connectivity airplane-mode $value\")\n        }\n    }\n\n    private fun broadcastAirplaneMode(enabled: Boolean) {\n        val command = \"am broadcast -a android.intent.action.AIRPLANE_MODE --ez state $enabled\"\n        try {\n            shell(command)\n        } catch (e: IOException) {\n            if (e.message?.contains(\"Security exception: Permission Denial:\") == true) {\n                try {\n                    shell(\"su root $command\")\n                } catch (e: IOException) {\n                    throw MaestroException.NoRootAccess(\"Failed to broadcast airplane mode change. Make sure to run an emulator with root access for API < 28\")\n                }\n            }\n        }\n    }\n\n    override fun setAndroidChromeDevToolsEnabled(enabled: Boolean) {\n        this.chromeDevToolsEnabled = enabled\n    }\n\n    fun setDeviceLocale(country: String, language: String): Int {\n        return metrics.measured(\"operation\", mapOf(\"command\" to \"setDeviceLocale\", \"country\" to country, \"language\" to language)) {\n            dadb.shell(\"pm grant dev.mobile.maestro android.permission.CHANGE_CONFIGURATION\")\n            val response =\n                dadb.shell(\"am broadcast -a dev.mobile.maestro.locale -n dev.mobile.maestro/.receivers.LocaleSettingReceiver --es lang $language --es country $country\")\n            extractSetLocaleResult(response.output)\n        }\n    }\n\n    private fun extractSetLocaleResult(result: String): Int {\n        val regex = Regex(\"result=(-?\\\\d+)\")\n        val match = regex.find(result)\n        return match?.groups?.get(1)?.value?.toIntOrNull() ?: -1\n    }\n\n    private fun addMediaToDevice(mediaFile: File) {\n        val namedSource = NamedSource(\n            mediaFile.name,\n            mediaFile.source(),\n            mediaFile.extension,\n            mediaFile.path\n        )\n        val responseObserver = BlockingStreamObserver<MaestroAndroid.AddMediaResponse>()\n        val requestStream = asyncStub.addMedia(responseObserver)\n        val ext =\n            MediaExt.values().firstOrNull { it.extName == namedSource.extension } ?: throw IllegalArgumentException(\n                \"Extension .${namedSource.extension} is not yet supported for add media\"\n            )\n\n        val buffer = Buffer()\n        val source = namedSource.source\n        while (source.read(buffer, CHUNK_SIZE) != -1L) {\n            requestStream.onNext(\n                addMediaRequest {\n                    this.payload = payload {\n                        data = ByteString.copyFrom(buffer.readByteArray())\n                    }\n                    this.mediaName = namedSource.name\n                    this.mediaExt = ext.extName\n                }\n            )\n            buffer.clear()\n        }\n        source.close()\n        requestStream.onCompleted()\n        responseObserver.awaitResult()\n    }\n\n    private fun setAllPermissions(appId: String, permissionValue: String) {\n        val permissionsResult = runCatching {\n            val apkFile = AndroidAppFiles.getApkFile(dadb, appId)\n            val permissions = ApkFile(apkFile).apkMeta.usesPermissions\n            apkFile.delete()\n            permissions\n        }\n        if (permissionsResult.isSuccess) {\n            permissionsResult.getOrNull()?.let {\n                it.forEach { permission ->\n                    setPermissionInternal(appId, permission, translatePermissionValue(permissionValue))\n                }\n            }\n        }\n    }\n\n    private fun setPermissionInternal(appId: String, permission: String, permissionValue: String) {\n        try {\n            shell(\"pm $permissionValue $appId $permission\")\n        } catch (exception: Exception) {\n            // Ignore if it's something that the user doesn't have control over (e.g. you can't grant / deny INTERNET)\n            if (exception.message?.contains(\"is not a changeable permission type\") == false) {\n                // Debug level is fine.\n                // We don't need to be loud about this. IOExceptions were already caught in shell(..)\n                // Remaining issues are likely due to \"all\" containing permissions that the app doesn't support.\n                logger.debug(\"Failed to set permission $permission for app $appId: ${exception.message}\")\n            }\n        }\n    }\n\n    private fun translatePermissionName(name: String): List<String> {\n        return when (name) {\n            \"location\" -> listOf(\n                \"android.permission.ACCESS_FINE_LOCATION\",\n                \"android.permission.ACCESS_COARSE_LOCATION\",\n            )\n\n            \"camera\" -> listOf(\"android.permission.CAMERA\")\n            \"contacts\" -> listOf(\n                \"android.permission.READ_CONTACTS\",\n                \"android.permission.WRITE_CONTACTS\"\n            )\n\n            \"phone\" -> listOf(\n                \"android.permission.CALL_PHONE\",\n                \"android.permission.ANSWER_PHONE_CALLS\",\n            )\n\n            \"microphone\" -> listOf(\n                \"android.permission.RECORD_AUDIO\"\n            )\n\n            \"bluetooth\" -> listOf(\n                \"android.permission.BLUETOOTH_CONNECT\",\n                \"android.permission.BLUETOOTH_SCAN\",\n            )\n\n            \"storage\" -> listOf(\n                \"android.permission.WRITE_EXTERNAL_STORAGE\",\n                \"android.permission.READ_EXTERNAL_STORAGE\"\n            )\n\n            \"notifications\" -> listOf(\n                \"android.permission.POST_NOTIFICATIONS\"\n            )\n\n            \"medialibrary\" -> listOf(\n                \"android.permission.WRITE_EXTERNAL_STORAGE\",\n                \"android.permission.READ_EXTERNAL_STORAGE\",\n                \"android.permission.READ_MEDIA_AUDIO\",\n                \"android.permission.READ_MEDIA_IMAGES\",\n                \"android.permission.READ_MEDIA_VIDEO\"\n            )\n\n            \"calendar\" -> listOf(\n                \"android.permission.WRITE_CALENDAR\",\n                \"android.permission.READ_CALENDAR\"\n            )\n\n            \"sms\" -> listOf(\n                \"android.permission.READ_SMS\",\n                \"android.permission.RECEIVE_SMS\",\n                \"android.permission.SEND_SMS\"\n            )\n\n            else -> listOf(name.replace(\"[^A-Za-z0-9._]+\".toRegex(), \"\"))\n        }\n    }\n\n    private fun translatePermissionValue(value: String): String {\n        return when (value) {\n            \"allow\" -> \"grant\"\n            \"deny\" -> \"revoke\"\n            \"unset\" -> \"revoke\"\n            else -> \"revoke\"\n        }\n    }\n\n    private fun mapHierarchy(node: Node): TreeNode {\n        val attributes = if (node is Element) {\n            val attributesBuilder = mutableMapOf<String, String>()\n\n            if (node.hasAttribute(\"text\")) {\n                val text = node.getAttribute(\"text\")\n                attributesBuilder[\"text\"] = text\n            }\n\n            if (node.hasAttribute(\"content-desc\")) {\n                attributesBuilder[\"accessibilityText\"] = node.getAttribute(\"content-desc\")\n            }\n\n            if (node.hasAttribute(\"hintText\")) {\n                attributesBuilder[\"hintText\"] = node.getAttribute(\"hintText\")\n            }\n\n            if (node.hasAttribute(\"class\") && node.getAttribute(\"class\") == TOAST_CLASS_NAME) {\n                attributesBuilder[\"ignoreBoundsFiltering\"] = true.toString()\n            } else {\n                attributesBuilder[\"ignoreBoundsFiltering\"] = false.toString()\n            }\n\n            if (node.hasAttribute(\"resource-id\")) {\n                attributesBuilder[\"resource-id\"] = node.getAttribute(\"resource-id\")\n            }\n\n            if (node.hasAttribute(\"clickable\")) {\n                attributesBuilder[\"clickable\"] = node.getAttribute(\"clickable\")\n            }\n\n            if (node.hasAttribute(\"bounds\")) {\n                attributesBuilder[\"bounds\"] = node.getAttribute(\"bounds\")\n            }\n\n            if (node.hasAttribute(\"enabled\")) {\n                attributesBuilder[\"enabled\"] = node.getAttribute(\"enabled\")\n            }\n\n            if (node.hasAttribute(\"focused\")) {\n                attributesBuilder[\"focused\"] = node.getAttribute(\"focused\")\n            }\n\n            if (node.hasAttribute(\"checked\")) {\n                attributesBuilder[\"checked\"] = node.getAttribute(\"checked\")\n            }\n\n            if (node.hasAttribute(\"scrollable\")) {\n                attributesBuilder[\"scrollable\"] = node.getAttribute(\"scrollable\")\n            }\n\n            if (node.hasAttribute(\"selected\")) {\n                attributesBuilder[\"selected\"] = node.getAttribute(\"selected\")\n            }\n\n            if (node.hasAttribute(\"class\")) {\n                attributesBuilder[\"class\"] = node.getAttribute(\"class\")\n            }\n\n            if (node.hasAttribute(\"important-for-accessibility\")) {\n                attributesBuilder[\"important-for-accessibility\"] = node.getAttribute(\"important-for-accessibility\")\n            }\n\n            if (node.hasAttribute(\"error\")) {\n                attributesBuilder[\"error\"] = node.getAttribute(\"error\")\n            }\n\n            attributesBuilder\n        } else {\n            emptyMap()\n        }\n\n        val children = mutableListOf<TreeNode>()\n        val childNodes = node.childNodes\n        (0 until childNodes.length).forEach { i ->\n            children += mapHierarchy(childNodes.item(i))\n        }\n\n        return TreeNode(\n            attributes = attributes.toMutableMap(),\n            children = children,\n            clickable = node.getBoolean(\"clickable\"),\n            enabled = node.getBoolean(\"enabled\"),\n            focused = node.getBoolean(\"focused\"),\n            checked = node.getBoolean(\"checked\"),\n            selected = node.getBoolean(\"selected\"),\n        )\n    }\n\n    private fun Node.getBoolean(name: String): Boolean? {\n        return (this as? Element)\n            ?.getAttribute(name)\n            ?.let { it == \"true\" }\n    }\n\n    fun installMaestroDriverApp() {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"installMaestroDriverApp\")) {\n            if (reinstallDriver) {\n                uninstallMaestroDriverApp()\n            } else if (isPackageInstalled(\"dev.mobile.maestro\")) {\n                return@measured\n            }\n\n            val maestroAppApk = File.createTempFile(\"maestro-app\", \".apk\")\n\n            Maestro::class.java.getResourceAsStream(\"/maestro-app.apk\")?.let {\n                val bufferedSink = maestroAppApk.sink().buffer()\n                bufferedSink.writeAll(it.source())\n                bufferedSink.flush()\n            }\n\n            install(maestroAppApk)\n            if (!isPackageInstalled(\"dev.mobile.maestro\")) {\n                throw IllegalStateException(\"dev.mobile.maestro was not installed\")\n            }\n            maestroAppApk.delete()\n        }\n    }\n\n    private fun installMaestroServerApp() {\n        if (reinstallDriver) {\n            uninstallMaestroServerApp()\n        } else if (isPackageInstalled(\"dev.mobile.maestro.test\")) {\n            return\n        }\n\n        val maestroServerApk = File.createTempFile(\"maestro-server\", \".apk\")\n\n        Maestro::class.java.getResourceAsStream(\"/maestro-server.apk\")?.let {\n            val bufferedSink = maestroServerApk.sink().buffer()\n            bufferedSink.writeAll(it.source())\n            bufferedSink.flush()\n        }\n\n        install(maestroServerApk)\n        if (!isPackageInstalled(\"dev.mobile.maestro.test\")) {\n            throw IllegalStateException(\"dev.mobile.maestro.test was not installed\")\n        }\n        maestroServerApk.delete()\n    }\n\n    private fun installMaestroApks() {\n        installMaestroDriverApp()\n        installMaestroServerApp()\n    }\n\n    fun uninstallMaestroDriverApp() {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"uninstallMaestroDriverApp\")) {\n            try {\n                if (isPackageInstalled(\"dev.mobile.maestro\")) {\n                    uninstall(\"dev.mobile.maestro\")\n                }\n            } catch (e: IOException) {\n                logger.warn(\"Failed to check or uninstall maestro driver app: ${e.message}\")\n                // Continue with cleanup even if we can't check package status\n                try {\n                    uninstall(\"dev.mobile.maestro\")\n                } catch (e2: IOException) {\n                    logger.warn(\"Failed to uninstall maestro driver app: ${e2.message}\")\n                    // Just log and continue, don't throw\n                }\n            }\n        }\n    }\n\n    private fun uninstallMaestroServerApp() {\n        try {\n            if (isPackageInstalled(\"dev.mobile.maestro.test\")) {\n                uninstall(\"dev.mobile.maestro.test\")\n            }\n        } catch (e: IOException) {\n            logger.warn(\"Failed to check or uninstall maestro server app: ${e.message}\")\n            // Continue with cleanup even if we can't check package status\n            try {\n                uninstall(\"dev.mobile.maestro.test\")\n            } catch (e2: IOException) {\n                logger.warn(\"Failed to uninstall maestro server app: ${e2.message}\")\n                // Just log and continue, don't throw\n            }\n        }\n    }\n\n    private fun uninstallMaestroApks() {\n        uninstallMaestroDriverApp()\n        uninstallMaestroServerApp()\n    }\n\n    private fun install(apkFile: File) {\n        try {\n            dadb.install(apkFile)\n        } catch (installError: IOException) {\n            throw IOException(\"Failed to install apk \" + apkFile + \": \" + installError.message, installError)\n        }\n    }\n\n    private fun uninstall(packageName: String) {\n        try {\n            dadb.uninstall(packageName)\n        } catch (error: IOException) {\n            throw IOException(\"Failed to uninstall package \" + packageName + \": \" + error.message, error)\n        }\n    }\n\n    private fun isPackageInstalled(packageName: String): Boolean {\n        try {\n            val output: String = shell(\"pm list packages --user 0 $packageName\")\n            return output.split(\"\\n\".toRegex())\n                .map { line -> line.split(\":\".toRegex()) }\n                .filter { parts -> parts.size == 2 }\n                .map { parts -> parts[1] }\n                .any { linePackageName -> linePackageName == packageName }\n        } catch (e: IOException) {\n            logger.warn(\"Failed to check if package $packageName is installed: ${e.message}\")\n            // If we can't check, we'll assume it's not installed\n            throw e\n        }\n    }\n\n    private fun shell(command: String): String {\n        val response: AdbShellResponse = try {\n            dadb.shell(command)\n        } catch (e: IOException) {\n            throw IOException(command, e)\n        }\n\n        if (response.exitCode != 0) {\n            throw IOException(\"$command: ${response.allOutput}\")\n        }\n        return response.output\n    }\n\n    private fun getStartupTimeout(): Long = runCatching {\n        System.getenv(MAESTRO_DRIVER_STARTUP_TIMEOUT).toLong()\n    }.getOrDefault(SERVER_LAUNCH_TIMEOUT_MS)\n\n    private fun AdbShellStream?.successfullyStarted(): Boolean {\n        val output = this?.read()\n        return when {\n            output is AdbShellPacket.StdError -> false\n            output.toString().contains(\"FAILED\", true) -> false\n            output.toString().contains(\"UNABLE\", true) -> false\n            else -> true\n        }\n    }\n\n    private fun <T> runDeviceCall(callName: String, call: () -> T): T {\n        return try {\n            call()\n        } catch (throwable: StatusRuntimeException) {\n            val status = Status.fromThrowable(throwable)\n            when (status.code) {\n                Status.Code.DEADLINE_EXCEEDED -> {\n                    LOGGER.error(\"$callName call failed on android with $status\", throwable)\n                    throw throwable\n                }\n                Status.Code.UNAVAILABLE -> {\n                    if (throwable.cause is IOException || throwable.message?.contains(\"io exception\", ignoreCase = true) == true) {\n                        LOGGER.error(\"Not able to reach the gRPC server while processing $callName command\")\n                        throw throwable\n                    } else {\n                        LOGGER.error(\"Received UNAVAILABLE status with message: ${throwable.message} while processing $callName command\", throwable)\n                        throw throwable\n                    }\n                }\n                Status.Code.INTERNAL -> {\n                    val trailers = Status.trailersFromThrowable(throwable)\n                    val errorType = trailers?.get(ERROR_TYPE_KEY)\n                    val errorMsg = trailers?.get(ERROR_MSG_KEY)\n                    val errorCause = trailers?.get(ERROR_CAUSE_KEY)\n                    LOGGER.error(\"$callName call failed: type=$errorType, message=$errorMsg, cause=$errorCause\", throwable)\n                    throw throwable.cause ?: throwable\n                }\n                else -> {\n                    LOGGER.error(\"Unexpected error during $callName: ${status.code} - ${throwable.message} and cause ${throwable.cause}\", throwable)\n                    throw throwable\n                }\n            }\n        }\n    }\n\n\n    companion object {\n\n        private const val SERVER_LAUNCH_TIMEOUT_MS = 15000L\n        private const val MAESTRO_DRIVER_STARTUP_TIMEOUT = \"MAESTRO_DRIVER_STARTUP_TIMEOUT\"\n        private const val WINDOW_UPDATE_TIMEOUT_MS = 750\n\n        private val REGEX_OPTIONS = setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL, RegexOption.MULTILINE)\n\n        private val ERROR_TYPE_KEY: Metadata.Key<String> =\n            Metadata.Key.of(\"error-type\", Metadata.ASCII_STRING_MARSHALLER)\n        private val ERROR_MSG_KEY: Metadata.Key<String> =\n            Metadata.Key.of(\"error-message\", Metadata.ASCII_STRING_MARSHALLER)\n        private val ERROR_CAUSE_KEY: Metadata.Key<String> =\n            Metadata.Key.of(\"error-cause\", Metadata.ASCII_STRING_MARSHALLER)\n\n        private val LOGGER = LoggerFactory.getLogger(AndroidDriver::class.java)\n\n        private const val TOAST_CLASS_NAME = \"android.widget.Toast\"\n        private const val SCREENSHOT_DIFF_THRESHOLD = 0.005\n        private const val CHUNK_SIZE = 1024L * 1024L * 3L\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/drivers/CdpWebDriver.kt",
    "content": "package maestro.drivers\n\nimport CdpClient\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport kotlinx.coroutines.runBlocking\nimport maestro.Capability\nimport maestro.DeviceInfo\nimport maestro.device.DeviceOrientation\nimport maestro.Driver\nimport maestro.KeyCode\nimport maestro.Maestro\nimport maestro.OnDeviceElementQuery\nimport maestro.Point\nimport maestro.ScreenRecording\nimport maestro.SwipeDirection\nimport maestro.TreeNode\nimport maestro.ViewHierarchy\nimport maestro.device.Platform\nimport maestro.utils.ScreenshotUtils\nimport maestro.web.record.JcodecVideoEncoder\nimport maestro.web.record.WebScreenRecorder\nimport okio.Sink\nimport okio.buffer\nimport org.openqa.selenium.By\nimport org.openqa.selenium.JavascriptExecutor\nimport org.openqa.selenium.Keys\nimport org.openqa.selenium.WebDriver\nimport org.openqa.selenium.WebElement\nimport org.openqa.selenium.chrome.ChromeDriver\nimport org.openqa.selenium.chrome.ChromeDriverService\nimport org.openqa.selenium.chrome.ChromeOptions\nimport org.openqa.selenium.chromium.ChromiumDriverLogLevel\nimport org.openqa.selenium.devtools.HasDevTools\nimport org.openqa.selenium.devtools.v144.emulation.Emulation\nimport org.openqa.selenium.interactions.Actions\nimport org.openqa.selenium.interactions.PointerInput\nimport org.openqa.selenium.interactions.Sequence\nimport org.openqa.selenium.remote.RemoteWebDriver\nimport org.slf4j.LoggerFactory\nimport java.io.File\nimport java.net.URI\nimport java.time.Duration\nimport java.util.*\nimport java.util.logging.Level\nimport java.util.logging.Logger\n\n\nprivate const val SYNTHETIC_COORDINATE_SPACE_OFFSET = 100000\n\nclass CdpWebDriver(\n    val isStudio: Boolean,\n    private val isHeadless: Boolean = false,\n    private val screenSize: String?\n) : Driver {\n\n    private lateinit var cdpClient: CdpClient\n\n    private var seleniumDriver: org.openqa.selenium.WebDriver? = null\n    private var maestroWebScript: String? = null\n    private var lastSeenWindowHandles = setOf<String>()\n    private var injectedArguments: Map<String, Any> = emptyMap()\n\n    private var webScreenRecorder: WebScreenRecorder? = null\n\n    init {\n        Maestro::class.java.getResourceAsStream(\"/maestro-web.js\")?.let {\n            it.bufferedReader().use { br ->\n                maestroWebScript = br.readText()\n            }\n        } ?: error(\"Could not read maestro web script\")\n    }\n\n    override fun name(): String {\n        return \"Chromium Desktop Browser (Experimental)\"\n    }\n\n    override fun open() {\n        seleniumDriver = createSeleniumDriver()\n\n        try {\n            seleniumDriver\n                ?.let { it as? HasDevTools }\n                ?.devTools\n                ?.createSessionIfThereIsNotOne()\n        } catch (e: Exception) {\n            // Swallow the exception to avoid crashing the whole process.\n            // Some implementations of Selenium do not support DevTools\n            // and do not fail gracefully.\n        }\n\n        if (isStudio) {\n            seleniumDriver?.get(\"https://maestro.mobile.dev\")\n        }\n    }\n\n    private fun createSeleniumDriver(): WebDriver {\n        System.setProperty(\"webdriver.chrome.silentOutput\", \"true\")\n        System.setProperty(ChromeDriverService.CHROME_DRIVER_SILENT_OUTPUT_PROPERTY, \"true\")\n        Logger.getLogger(\"org.openqa.selenium\").level = Level.OFF\n        Logger.getLogger(\"org.openqa.selenium.devtools.CdpVersionFinder\").level = Level.OFF\n\n        val driverService = ChromeDriverService.Builder()\n            .withLogLevel(ChromiumDriverLogLevel.OFF)\n            .build()\n\n        val driver = ChromeDriver(\n            driverService,\n            ChromeOptions().apply {\n                addArguments(\"--remote-allow-origins=*\")\n                addArguments(\"--disable-search-engine-choice-screen\")\n                addArguments(\"--lang=en\")\n\n                // Disable password management\n                addArguments(\"--password-store=basic\")\n                val chromePrefs = hashMapOf<String, Any>(\n                    \"credentials_enable_service\" to false,\n                    \"profile.password_manager_enabled\" to false,\n                    \"profile.password_manager_leak_detection\" to false   // important one\n                )\n                setExperimentalOption(\"prefs\", chromePrefs)\n\n                setExperimentalOption(\"detach\", true)\n\n                if (isHeadless) {\n                    addArguments(\"--headless=new\")\n                    if(screenSize != null){\n                        addArguments(\"--window-size=\" + screenSize.replace('x',','))\n                    }\n                    else{\n                        addArguments(\"--window-size=1024,768\")\n                    }\n                }\n            }\n        )\n\n        val options = driver.capabilities.getCapability(\"goog:chromeOptions\") as Map<String, Any>\n        val debuggerAddress = options[\"debuggerAddress\"] as String\n        val parts = debuggerAddress.split(\":\")\n\n        cdpClient = CdpClient(\n            host = parts[0],\n            port = parts[1].toInt()\n        )\n\n        return driver\n    }\n\n    private fun ensureOpen(): org.openqa.selenium.WebDriver {\n        return seleniumDriver ?: error(\"Driver is not open\")\n    }\n\n    private fun executeJS(js: String): Any? {\n        return runBlocking {\n            try {\n                val target = cdpClient.listTargets().first()\n\n                cdpClient.evaluate(\"$maestroWebScript\", target)\n\n                injectedArguments.forEach { (key, value) ->\n                    cdpClient.evaluate(\"$key = '$value'\", target)\n                }\n\n                Thread.sleep(100)\n\n                var resultStr = cdpClient.evaluate(js, target)\n\n                // Convert from string to Map<String, Any> if needed\n                return@runBlocking jacksonObjectMapper().readValue(resultStr, Any::class.java)\n            } catch (e: Exception) {\n                if (e.message?.contains(\"getContentDescription\") == true) {\n                    return@runBlocking executeJS(js)\n                } else {\n                    LOGGER.error(\"Failed to execute JS\", e)\n                }\n                return@runBlocking null\n            }\n        }\n    }\n\n    private fun scrollToPoint(point: Point): Long {\n        ensureOpen()\n        val windowHeight = executeJS(\"window.innerHeight\") as Int\n\n        if (point.y >= 0 && point.y.toLong() <= windowHeight) return 0L\n\n        val scrolledPixels =\n            executeJS(\"() => {const delta = ${point.y} - Math.floor(window.innerHeight / 2); window.scrollBy({ top: delta, left: 0, behavior: 'smooth' }); return delta}()\") as Int\n        sleep(3000L)\n        return scrolledPixels.toLong()\n    }\n\n    private fun sleep(ms: Long) {\n        Thread.sleep(ms)\n    }\n\n    private fun scroll(top: String, left: String) {\n        executeJS(\"window.scroll({ top: $top, left: $left, behavior: 'smooth' })\")\n    }\n\n    private fun random(start: Int, end: Int): Int {\n        return Random().nextInt((end + 1) - start) + start\n    }\n\n    override fun close() {\n        injectedArguments = emptyMap()\n\n        try {\n            seleniumDriver?.quit()\n            webScreenRecorder?.close()\n        } catch (e: Exception) {\n            // Swallow the exception to avoid crashing the whole process\n        }\n\n        seleniumDriver = null\n        lastSeenWindowHandles = setOf()\n        webScreenRecorder = null\n    }\n\n    override fun deviceInfo(): DeviceInfo {\n        val width = executeJS(\"window.innerWidth\") as Int\n        val height = executeJS(\"window.innerHeight\") as Int\n\n        return DeviceInfo(\n            platform = Platform.WEB,\n            widthPixels = width,\n            heightPixels = height,\n            widthGrid = width,\n            heightGrid = height,\n        )\n    }\n\n    override fun launchApp(\n        appId: String,\n        launchArguments: Map<String, Any>,\n    ) {\n        injectedArguments = injectedArguments + launchArguments\n\n        runBlocking {\n            val target = cdpClient.listTargets().first()\n            cdpClient.openUrl(appId, target)\n        }\n    }\n\n    override fun stopApp(appId: String) {\n        // Not supported at the moment.\n        // Simply calling driver.close() can kill the Selenium session, rendering\n        // the driver inoperable.\n    }\n\n    override fun killApp(appId: String) {\n        // On Web there is no Process Death like on Android so this command will be a synonym to the stop command\n        stopApp(appId)\n    }\n\n    override fun contentDescriptor(excludeKeyboardElements: Boolean): TreeNode {\n        ensureOpen()\n\n        detectWindowChange()\n\n        // retrieve view hierarchy from DOM\n        // There are edge cases where executeJS returns null, and we cannot get the hierarchy. In this situation\n        // we retry multiple times until throwing an error eventually. (See issue #1936)\n        var contentDesc: Any? = null\n        var retry = 0\n        while (contentDesc == null) {\n            contentDesc = executeJS(\"window.maestro.getContentDescription()\")\n            if (contentDesc == null) {\n                retry++\n            }\n            if (retry == RETRY_FETCHING_CONTENT_DESCRIPTION) {\n                throw IllegalStateException(\"Could not retrieve hierarchy through maestro.getContentDescription() (tried $retry times\")\n            }\n        }\n\n        val rawMap = contentDesc as Map<String, Any>\n        val enrichedMap = injectCrossOriginIframes(rawMap)\n        val root = parseDomAsTreeNodes(enrichedMap)\n        seleniumDriver?.currentUrl?.let { url ->\n            root.attributes[\"url\"] = url\n        }\n        return root\n    }\n\n    fun parseDomAsTreeNodes(domRepresentation: Map<String, Any>): TreeNode {\n        val attrs = domRepresentation[\"attributes\"] as Map<String, Any>\n\n        val attributes = mutableMapOf(\n            \"text\" to attrs[\"text\"] as String,\n            \"bounds\" to attrs[\"bounds\"] as String,\n        )\n        if (attrs.containsKey(\"resource-id\") && attrs[\"resource-id\"] != null) {\n            attributes[\"resource-id\"] = attrs[\"resource-id\"] as String\n        }\n        if (attrs.containsKey(\"selected\") && attrs[\"selected\"] != null) {\n            attributes[\"selected\"] = (attrs[\"selected\"] as Boolean).toString()\n        }\n        if (attrs.containsKey(\"synthetic\") && attrs[\"synthetic\"] != null) {\n            attributes[\"synthetic\"] = (attrs[\"synthetic\"] as Boolean).toString()\n        }\n        if (attrs.containsKey(\"ignoreBoundsFiltering\") && attrs[\"ignoreBoundsFiltering\"] != null) {\n            attributes[\"ignoreBoundsFiltering\"] = (attrs[\"ignoreBoundsFiltering\"] as Boolean).toString()\n        }\n\n        val children = domRepresentation[\"children\"] as List<Map<String, Any>>\n\n        return TreeNode(attributes = attributes, children = children.map { parseDomAsTreeNodes(it) })\n    }\n\n    private fun detectWindowChange() {\n        // Checks whether there are any new window handles available and, if so, switches Selenium driver focus to it\n        val driver = ensureOpen()\n\n        if (lastSeenWindowHandles != driver.windowHandles) {\n            val newHandles = driver.windowHandles - lastSeenWindowHandles\n            lastSeenWindowHandles = driver.windowHandles\n\n            if (newHandles.isNotEmpty()) {\n                val newHandle = newHandles.first()\n                LOGGER.info(\"Detected a window change, switching to new window handle $newHandle\")\n\n                driver.switchTo().window(newHandle)\n\n                webScreenRecorder?.onWindowChange()\n            }\n        }\n    }\n\n    override fun clearAppState(appId: String) {\n        ensureOpen()\n\n        val origin = try {\n            val uri = URI(appId)\n            if (uri.scheme.isNullOrBlank() || uri.host.isNullOrBlank()) {\n                null\n            } else if (uri.port == -1) {\n                \"${uri.scheme}://${uri.host}\"\n            } else {\n                \"${uri.scheme}://${uri.host}:${uri.port}\"\n            }\n        } catch (e: Exception) {\n            LOGGER.warn(\"Failed to parse origin from $appId\", e)\n            null\n        }\n\n        if (origin == null) {\n            return\n        }\n\n        try {\n            runBlocking {\n                val target = cdpClient.listTargets().first()\n                cdpClient.clearDataForOrigin(origin, \"all\", target)\n            }\n        } catch (e: Exception) {\n            LOGGER.warn(\"Failed to clear browser data for $origin\", e)\n        }\n    }\n\n    override fun clearKeychain() {\n        // Do nothing\n    }\n\n    override fun tap(point: Point) {\n        val driver = ensureOpen()\n\n        if (point.x >= SYNTHETIC_COORDINATE_SPACE_OFFSET && point.y >= SYNTHETIC_COORDINATE_SPACE_OFFSET) {\n            tapOnSyntheticCoordinateSpace(point)\n            return\n        }\n\n        val pixelsScrolled = scrollToPoint(point)\n\n        val mouse = PointerInput(PointerInput.Kind.MOUSE, \"default mouse\")\n        val actions = Sequence(mouse, 1)\n            .addAction(\n                mouse.createPointerMove(\n                    Duration.ofMillis(400),\n                    PointerInput.Origin.viewport(),\n                    point.x,\n                    point.y - pixelsScrolled.toInt()\n                )\n            )\n\n        (driver as RemoteWebDriver).perform(listOf(actions))\n\n        Actions(driver).click().build().perform()\n    }\n\n    private fun tapOnSyntheticCoordinateSpace(point: Point) {\n        val elements = contentDescriptor()\n\n        val hit = ViewHierarchy.from(this, true)\n            .getElementAt(elements, point.x, point.y)\n\n        if (hit == null) {\n            return\n        }\n\n        if (hit.attributes[\"synthetic\"] != \"true\") {\n            return\n        }\n\n        executeJS(\"window.maestro.tapOnSyntheticElement(${point.x}, ${point.y})\")\n    }\n\n    override fun longPress(point: Point) {\n        val driver = ensureOpen()\n\n        val mouse = PointerInput(PointerInput.Kind.MOUSE, \"default mouse\")\n        val actions = Sequence(mouse, 0)\n            .addAction(mouse.createPointerMove(Duration.ZERO, PointerInput.Origin.viewport(), point.x, point.y))\n        (driver as RemoteWebDriver).perform(listOf(actions))\n\n        Actions(driver).clickAndHold().pause(3000L).release().build().perform()\n    }\n\n    override fun pressKey(code: KeyCode) {\n        val key = mapToSeleniumKey(code)\n        withActiveElement { it.sendKeys(key) }\n    }\n\n    private fun mapToSeleniumKey(code: KeyCode): Keys {\n        return when (code) {\n            KeyCode.ENTER -> Keys.ENTER\n            KeyCode.BACKSPACE -> Keys.BACK_SPACE\n            else -> error(\"Keycode $code is not supported on web\")\n        }\n    }\n\n    override fun scrollVertical() {\n        // Check if this is a Flutter web app\n        val isFlutter = executeJS(\"window.maestro.isFlutterApp()\") as? Boolean ?: false\n        \n        if (isFlutter) {\n            // Use Flutter-specific smooth animated scrolling\n            executeJS(\"window.maestro.smoothScrollFlutter('UP', 500)\")\n        } else {\n            // Use standard scroll for regular web pages\n            scroll(\"window.scrollY + Math.round(window.innerHeight / 2)\", \"window.scrollX\")\n        }\n    }\n\n    override fun isKeyboardVisible(): Boolean {\n        return false\n    }\n\n    override fun swipe(start: Point, end: Point, durationMs: Long) {\n        val driver = ensureOpen()\n\n        val finger = PointerInput(PointerInput.Kind.TOUCH, \"finger\")\n        val swipe = Sequence(finger, 1)\n        swipe.addAction(\n            finger.createPointerMove(\n                Duration.ofMillis(0),\n                PointerInput.Origin.viewport(),\n                start.x,\n                start.y\n            )\n        )\n        swipe.addAction(finger.createPointerDown(PointerInput.MouseButton.LEFT.asArg()))\n        swipe.addAction(\n            finger.createPointerMove(\n                Duration.ofMillis(durationMs),\n                PointerInput.Origin.viewport(),\n                end.x,\n                end.y\n            )\n        )\n        swipe.addAction(finger.createPointerUp(PointerInput.MouseButton.LEFT.asArg()))\n        (driver as RemoteWebDriver).perform(listOf(swipe))\n    }\n\n    override fun swipe(swipeDirection: SwipeDirection, durationMs: Long) {\n        val isFlutter = executeJS(\"window.maestro.isFlutterApp()\") as? Boolean ?: false\n        \n        if (isFlutter) {\n            // Flutter web: Use smooth animated scrolling with easing\n            executeJS(\"window.maestro.smoothScrollFlutter('${swipeDirection.name}', $durationMs)\")\n        } else {\n            // HTML web: Use standard window scrolling\n            when (swipeDirection) {\n                SwipeDirection.UP -> scroll(\"window.scrollY + Math.round(window.innerHeight / 2)\", \"window.scrollX\")\n                SwipeDirection.DOWN -> scroll(\"window.scrollY - Math.round(window.innerHeight / 2)\", \"window.scrollX\")\n                SwipeDirection.LEFT -> scroll(\"window.scrollY\", \"window.scrollX + Math.round(window.innerWidth / 2)\")\n                SwipeDirection.RIGHT -> scroll(\"window.scrollY\", \"window.scrollX - Math.round(window.innerWidth / 2)\")\n            }\n        }\n    }\n\n    override fun swipe(elementPoint: Point, direction: SwipeDirection, durationMs: Long) {\n        // Ignoring elementPoint to enable a rudimentary implementation of scrollUntilVisible for web\n        swipe(direction, durationMs)\n    }\n\n    override fun backPress() {\n        val driver = ensureOpen()\n        driver.navigate().back()\n    }\n\n    override fun inputText(text: String) {\n        withActiveElement { element ->\n            for (c in text.toCharArray()) {\n                element.sendKeys(\"$c\")\n                sleep(random(20, 100).toLong())\n            }\n        }\n    }\n\n    override fun openLink(link: String, appId: String?, autoVerify: Boolean, browser: Boolean) {\n        val driver = ensureOpen()\n\n        driver.get(if (link.startsWith(\"http\")) link else \"https://$link\")\n    }\n\n    override fun hideKeyboard() {\n        // no-op on web\n        return\n    }\n\n    override fun takeScreenshot(out: Sink, compressed: Boolean) {\n        runBlocking {\n            val target = cdpClient.listTargets().first()\n            val bytes = cdpClient.captureScreenshot(target)\n\n            out.buffer().use { it.write(bytes) }\n        }\n    }\n\n    override fun startScreenRecording(out: Sink): ScreenRecording {\n        val driver = ensureOpen()\n        webScreenRecorder = WebScreenRecorder(\n            JcodecVideoEncoder(),\n            driver\n        )\n        webScreenRecorder?.startScreenRecording(out)\n\n        return object : ScreenRecording {\n            override fun close() {\n                webScreenRecorder?.close()\n            }\n        }\n    }\n\n    override fun setLocation(latitude: Double, longitude: Double) {\n        val driver = ensureOpen() as HasDevTools\n\n        driver.devTools.createSessionIfThereIsNotOne()\n\n        driver.devTools.send(\n            Emulation.setGeolocationOverride(\n                Optional.of(latitude),\n                Optional.of(longitude),\n                Optional.of(0.0),\n                Optional.empty(),\n                Optional.empty(),\n                Optional.empty(),\n                Optional.empty()\n            )\n        )\n    }\n\n    override fun setOrientation(orientation: DeviceOrientation) {\n        // No op\n    }\n\n    override fun eraseText(charactersToErase: Int) {\n        withActiveElement { element ->\n            for (i in 0 until charactersToErase) {\n                element.sendKeys(Keys.BACK_SPACE)\n                sleep(random(20, 50).toLong())\n            }\n        }\n        sleep(1000)\n    }\n\n    override fun setProxy(host: String, port: Int) {\n        // Do nothing\n    }\n\n    override fun resetProxy() {\n        // Do nothing\n    }\n\n    override fun isShutdown(): Boolean {\n        close()\n        return true\n    }\n\n    override fun isUnicodeInputSupported(): Boolean {\n        return true\n    }\n\n    override fun waitForAppToSettle(initialHierarchy: ViewHierarchy?, appId: String?, timeoutMs: Int?): ViewHierarchy {\n        return ScreenshotUtils.waitForAppToSettle(initialHierarchy, this)\n    }\n\n    override fun waitUntilScreenIsStatic(timeoutMs: Long): Boolean {\n        return ScreenshotUtils.waitUntilScreenIsStatic(timeoutMs, SCREENSHOT_DIFF_THRESHOLD, this)\n    }\n\n    override fun capabilities(): List<Capability> {\n        return listOf(\n            Capability.FAST_HIERARCHY\n        )\n    }\n\n    override fun setPermissions(appId: String, permissions: Map<String, String>) {\n        // no-op for web\n    }\n\n    override fun addMedia(mediaFiles: List<File>) {\n        // noop for web\n    }\n\n    override fun isAirplaneModeEnabled(): Boolean {\n        return false\n    }\n\n    override fun setAirplaneMode(enabled: Boolean) {\n        // Do nothing\n    }\n\n    override fun queryOnDeviceElements(query: OnDeviceElementQuery): List<TreeNode> {\n        return when (query) {\n            is OnDeviceElementQuery.Css -> queryCss(query)\n            else -> super.queryOnDeviceElements(query)\n        }\n    }\n\n    private fun queryCss(query: OnDeviceElementQuery.Css): List<TreeNode> {\n        ensureOpen()\n\n        val jsResult: Any? = executeJS(\"window.maestro.queryCss('${query.css}')\")\n\n        if (jsResult == null) {\n            return emptyList()\n        }\n\n        if (jsResult is List<*>) {\n            return jsResult\n                .mapNotNull { it as? Map<*, *> }\n                .map { parseDomAsTreeNodes(it as Map<String, Any>) }\n        } else {\n            LOGGER.error(\"Unexpected result type from queryCss: ${jsResult.javaClass.name}\")\n            return emptyList()\n        }\n    }\n\n    @Suppress(\"UNCHECKED_CAST\")\n    private fun injectCrossOriginIframes(node: Map<String, Any>): Map<String, Any> {\n        val attrs = node[\"attributes\"] as Map<String, Any>\n        val iframeSrc = attrs[\"__crossOriginIframe\"] as? String\n\n        if (iframeSrc != null) {\n            val iframeContent = fetchCrossOriginIframeContent(iframeSrc)\n            if (iframeContent != null) return iframeContent\n            val cleanAttrs = attrs - \"__crossOriginIframe\"\n            return mapOf(\"attributes\" to cleanAttrs, \"children\" to emptyList<Any>())\n        }\n\n        val children = (node[\"children\"] as List<Map<String, Any>>)\n            .map { injectCrossOriginIframes(it) }\n        return mapOf(\"attributes\" to attrs, \"children\" to children)\n    }\n\n    @Suppress(\"UNCHECKED_CAST\")\n    private fun fetchCrossOriginIframeContent(iframeSrc: String): Map<String, Any>? {\n        val driver = seleniumDriver ?: return null\n        val jsExecutor = driver as? JavascriptExecutor ?: return null\n\n        // Find the iframe element by its resolved src property (absolute URL)\n        val iframeElement = try {\n            jsExecutor.executeScript(\n                \"return [...document.querySelectorAll('iframe')].find(f => f.src === arguments[0]);\",\n                iframeSrc\n            ) as? WebElement\n        } catch (e: Exception) {\n            LOGGER.warn(\"Could not find iframe element with src $iframeSrc\", e)\n            return null\n        } ?: run {\n            LOGGER.warn(\"No iframe element found with src $iframeSrc\")\n            return null\n        }\n\n        // Get the iframe's scaled viewport params (accounts for parent viewportWidth/Height scaling)\n        val paramsJson = try {\n            jsExecutor.executeScript(\n                \"return JSON.stringify(window.maestro.getIframeViewportParams(arguments[0]));\",\n                iframeSrc\n            ) as? String\n        } catch (e: Exception) {\n            LOGGER.warn(\"Could not get viewport params for iframe $iframeSrc\", e)\n            return null\n        } ?: return null\n\n        val params = jacksonObjectMapper().readValue(paramsJson, Map::class.java) as Map<String, Any>\n        val iframeX = (params[\"viewportX\"]      as? Number)?.toDouble() ?: 0.0\n        val iframeY = (params[\"viewportY\"]      as? Number)?.toDouble() ?: 0.0\n        val iframeW = (params[\"viewportWidth\"]  as? Number)?.toDouble() ?: 0.0\n        val iframeH = (params[\"viewportHeight\"] as? Number)?.toDouble() ?: 0.0\n\n        // ChromeDriver can execute scripts inside cross-origin iframes via switchTo().frame()\n        driver.switchTo().frame(iframeElement)\n        return try {\n            val resultJson = jsExecutor.executeScript(\"\"\"\n                $maestroWebScript\n                window.maestro.viewportX = $iframeX;\n                window.maestro.viewportY = $iframeY;\n                window.maestro.viewportWidth = $iframeW;\n                window.maestro.viewportHeight = $iframeH;\n                return JSON.stringify(window.maestro.getContentDescription());\n            \"\"\".trimIndent()) as? String ?: return null\n            jacksonObjectMapper().readValue(resultJson, Map::class.java) as? Map<String, Any>\n        } catch (e: Exception) {\n            LOGGER.warn(\"Failed to get content description from cross-origin iframe $iframeSrc\", e)\n            null\n        } finally {\n            try { driver.switchTo().defaultContent() }\n            catch (e: Exception) { LOGGER.warn(\"Failed to switch back to default content\", e) }\n        }\n    }\n\n    /**\n     * Locates the truly focused element, even when it lives inside a cross-origin iframe.\n     *\n     * When the user taps inside a cross-origin iframe the main frame's\n     * `document.activeElement` is the `<iframe>` element itself.  This helper\n     * detects that case, switches Selenium into the iframe, resolves the real\n     * active element there, runs [action], and switches back to the default\n     * content so subsequent commands target the main frame again.\n     */\n    private fun withActiveElement(action: (WebElement) -> Unit) {\n        val driver = ensureOpen()\n        val jsExecutor = driver as JavascriptExecutor\n\n        val isIframeFocused = jsExecutor.executeScript(\n            \"return document.activeElement && document.activeElement.tagName.toLowerCase() === 'iframe'\"\n        ) as? Boolean ?: false\n\n        if (isIframeFocused) {\n            val iframe = jsExecutor.executeScript(\"return document.activeElement\") as WebElement\n            driver.switchTo().frame(iframe)\n            try {\n                jsExecutor.executeScript(\"$maestroWebScript\")\n                val xPath = jsExecutor.executeScript(\n                    \"return window.maestro.createXPathFromElement(document.activeElement)\"\n                ) as String\n                val element = driver.findElement(By.ByXPath(xPath))\n                action(element)\n            } finally {\n                try { driver.switchTo().defaultContent() }\n                catch (e: Exception) { LOGGER.warn(\"Failed to switch back to default content\", e) }\n            }\n        } else {\n            val xPath = executeJS(\"window.maestro.createXPathFromElement(document.activeElement)\") as String\n            val element = driver.findElement(By.ByXPath(xPath))\n            action(element)\n        }\n    }\n\n    companion object {\n        private const val SCREENSHOT_DIFF_THRESHOLD = 0.005\n        private const val RETRY_FETCHING_CONTENT_DESCRIPTION = 10\n\n        private val LOGGER = LoggerFactory.getLogger(CdpWebDriver::class.java)\n    }\n}\n\nfun main() {\n    val driver = CdpWebDriver(isStudio = false, isHeadless = false, screenSize = null)\n    driver.open()\n\n    try {\n        driver.launchApp(\"https://example.com\", emptyMap())\n        println(driver.contentDescriptor())\n\n        println(driver.deviceInfo())\n    } finally {\n        driver.close()\n    }\n}"
  },
  {
    "path": "maestro-client/src/main/java/maestro/drivers/IOSDriver.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.drivers\n\nimport com.github.michaelbull.result.expect\nimport device.IOSDevice\nimport hierarchy.AXElement\nimport ios.IOSDeviceErrors\nimport maestro.Capability\nimport maestro.DeviceInfo\nimport maestro.device.DeviceOrientation\nimport maestro.Driver\nimport maestro.Filters\nimport maestro.KeyCode\nimport maestro.MaestroException\nimport maestro.MediaExt\nimport maestro.NamedSource\nimport maestro.Point\nimport maestro.ScreenRecording\nimport maestro.SwipeDirection\nimport maestro.TreeNode\nimport maestro.UiElement.Companion.toUiElement\nimport maestro.UiElement.Companion.toUiElementOrNull\nimport maestro.ViewHierarchy\nimport maestro.toCommonDeviceInfo\nimport maestro.utils.Insight\nimport maestro.utils.Insights\nimport maestro.utils.MaestroTimer\nimport maestro.utils.Metrics\nimport maestro.utils.MetricsProvider\nimport maestro.utils.NoopInsights\nimport maestro.utils.ScreenshotUtils\nimport maestro.utils.TempFileHandler\nimport okio.Sink\nimport okio.source\nimport org.slf4j.LoggerFactory\nimport util.XCRunnerCLIUtils\nimport java.io.File\nimport java.net.SocketTimeoutException\nimport kotlin.collections.set\n\nclass IOSDriver(\n    private val iosDevice: IOSDevice,\n    private val insights: Insights = NoopInsights,\n    private val metricsProvider: Metrics = MetricsProvider.getInstance(),\n) : Driver {\n\n    private val metrics = metricsProvider.withPrefix(\"maestro.driver\").withTags(mapOf(\"platform\" to \"ios\", \"deviceId\" to iosDevice.deviceId).filterValues { it != null }.mapValues { it.value!! })\n\n    private var appId: String? = null\n    private var proxySet = false\n    private val xcRunnerCLIUtils = XCRunnerCLIUtils(tempFileHandler = TempFileHandler())\n\n    override fun name(): String {\n        return metrics.measured(\"name\") {\n            NAME\n        }\n    }\n\n    override fun open() {\n        metrics.measured(\"open\") {\n            iosDevice.open()\n        }\n    }\n\n    override fun close() {\n        metrics.measured(\"close\") {\n            if (proxySet) {\n                resetProxy()\n            }\n            iosDevice.close()\n            appId = null\n        }\n    }\n\n    override fun deviceInfo(): DeviceInfo {\n        return metrics.measured(\"operation\", mapOf(\"command\" to \"deviceInfo\")) {\n            runDeviceCall(\"deviceInfo\") { iosDevice.deviceInfo().toCommonDeviceInfo() }\n        }\n    }\n\n    override fun launchApp(\n        appId: String,\n        launchArguments: Map<String, Any>,\n    ) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"launchApp\", \"appId\" to appId)) {\n            iosDevice.launch(appId, launchArguments)\n            this.appId = appId\n        }\n    }\n\n    override fun stopApp(appId: String) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"stopApp\", \"appId\" to appId)) {\n            iosDevice.stop(appId)\n        }\n    }\n\n    override fun killApp(appId: String) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"killApp\", \"appId\" to appId)) {\n            // On iOS there is no Process Death like on Android so this command will be a synonym to the stop command\n            stopApp(appId)\n        }\n    }\n\n    override fun clearAppState(appId: String) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"clearAppState\", \"appId\" to appId)) {\n            iosDevice.clearAppState(appId)\n        }\n    }\n\n    override fun clearKeychain() {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"clearKeychain\")) {\n            iosDevice.clearKeychain().expect {}\n        }\n    }\n\n    override fun tap(point: Point) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"tap\")) {\n            runDeviceCall(\"tap\") { iosDevice.tap(point.x, point.y) }\n        }\n    }\n\n    override fun longPress(point: Point) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"longPress\")) {\n            runDeviceCall(\"longPress\") { iosDevice.longPress(point.x, point.y, 3000) }\n        }\n    }\n\n    override fun pressKey(code: KeyCode) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"pressKey\")) {\n            val keyCodeNameMap = mapOf(\n                KeyCode.BACKSPACE to \"delete\",\n                KeyCode.ENTER to \"return\",\n            )\n\n            val buttonNameMap = mapOf(\n                KeyCode.HOME to \"home\",\n                KeyCode.LOCK to \"lock\",\n            )\n\n            runDeviceCall(\"pressKey\") {\n                keyCodeNameMap[code]?.let { name ->\n                    iosDevice.pressKey(name)\n                }\n\n                buttonNameMap[code]?.let { name ->\n                    iosDevice.pressButton(name)\n                }\n            }\n        }\n    }\n\n    override fun contentDescriptor(excludeKeyboardElements: Boolean): TreeNode {\n        return metrics.measured(\"operation\", mapOf(\"command\" to \"contentDescriptor\")) {\n            runDeviceCall(\"snapshot\") { viewHierarchy(excludeKeyboardElements) }\n        }\n    }\n\n    private fun viewHierarchy(excludeKeyboardElements: Boolean): TreeNode {\n        val hierarchyResult = iosDevice.viewHierarchy(excludeKeyboardElements)\n        if (hierarchyResult.depth > WARNING_MAX_DEPTH) {\n            val message = \"The view hierarchy has been calculated. The current depth of the hierarchy \" +\n                    \"is ${hierarchyResult.depth}. This might affect the execution time of your test. \" +\n                    \"If you are using React native, consider migrating to the new \" +\n                    \"architecture where view flattening is available. For more information on the \" +\n                    \"migration process, please visit: https://reactnative.dev/docs/new-architecture-intro\"\n            insights.report(Insight(message, Insight.Level.INFO))\n        } else {\n            insights.report(Insight(\"\", Insight.Level.NONE))\n        }\n        val hierarchy = hierarchyResult.axElement\n        return mapViewHierarchy(hierarchy)\n    }\n\n    private fun mapViewHierarchy(element: AXElement): TreeNode {\n        val attributes = mutableMapOf<String, String>()\n        attributes[\"accessibilityText\"] = element.label\n        attributes[\"title\"] = element.title ?: \"\"\n        attributes[\"value\"] = element.value ?: \"\"\n        attributes[\"text\"] = element.title?.ifEmpty { element.value } ?: \"\"\n        attributes[\"hintText\"] = element.placeholderValue ?: \"\"\n        attributes[\"resource-id\"] = element.identifier\n        attributes[\"bounds\"] = element.frame.boundsString\n        attributes[\"enabled\"] = element.enabled.toString()\n        attributes[\"focused\"] = element.hasFocus.toString()\n        attributes[\"selected\"] = element.selected.toString()\n\n        val checked = element.elementType in CHECKABLE_ELEMENTS && element.value == \"1\"\n        attributes[\"checked\"] = checked.toString()\n\n        val children = element.children.map {\n            mapViewHierarchy(it)\n        }\n\n        return TreeNode(\n            attributes = attributes,\n            children = children,\n            enabled = element.enabled,\n            focused = element.hasFocus,\n            selected = element.selected,\n            checked = checked,\n        )\n    }\n\n    override fun isUnicodeInputSupported(): Boolean {\n        return true\n    }\n\n    override fun scrollVertical() {\n        val deviceInfo = deviceInfo()\n        val width = deviceInfo.widthGrid\n        val height = deviceInfo.heightGrid\n\n        swipe(\n            start = Point(0.5.asPercentOf(width), 0.5.asPercentOf(height)),\n            end = Point(0.5.asPercentOf(width), 0.1.asPercentOf(height)),\n            durationMs = 333,\n        )\n    }\n\n    override fun isKeyboardVisible(): Boolean {\n        return metrics.measured(\"operation\", mapOf(\"command\" to \"isKeyboardVisible\")) {\n            runDeviceCall(\"isKeyboardVisible\") { iosDevice.isKeyboardVisible() }\n        }\n    }\n\n    override fun swipe(\n        start: Point,\n        end: Point,\n        durationMs: Long\n    ) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"swipe\", \"durationMs\" to durationMs.toString())) {\n            val deviceInfo = deviceInfo()\n            val startPoint = start.coerceIn(maxWidth = deviceInfo.widthGrid, maxHeight = deviceInfo.heightGrid)\n            val endPoint = end.coerceIn(maxWidth = deviceInfo.widthGrid, maxHeight = deviceInfo.heightGrid)\n\n            runDeviceCall(\"swipe\") {\n                waitForAppToSettle(null, null)\n                iosDevice.scroll(\n                    xStart = startPoint.x.toDouble(),\n                    yStart = startPoint.y.toDouble(),\n                    xEnd = endPoint.x.toDouble(),\n                    yEnd = endPoint.y.toDouble(),\n                    duration = durationMs.toDouble() / 1000\n                )\n            }\n        }\n    }\n\n    override fun swipe(swipeDirection: SwipeDirection, durationMs: Long) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"swipeWithDirection\", \"direction\" to swipeDirection.name, \"durationMs\" to durationMs.toString())) {\n            val deviceInfo = deviceInfo()\n            val width = deviceInfo.widthGrid\n            val height = deviceInfo.heightGrid\n\n            val startPoint: Point\n            val endPoint: Point\n\n            when (swipeDirection) {\n                SwipeDirection.UP -> {\n                    startPoint = Point(\n                        x = 0.5.asPercentOf(width),\n                        y = 0.9.asPercentOf(height),\n                    )\n                    endPoint = Point(\n                        x = 0.5.asPercentOf(width),\n                        y = 0.1.asPercentOf(height),\n                    )\n                }\n\n                SwipeDirection.DOWN -> {\n                    startPoint = Point(\n                        x = 0.5.asPercentOf(width),\n                        y = 0.2.asPercentOf(height),\n                    )\n                    endPoint = Point(\n                        x = 0.5.asPercentOf(width),\n                        y = 0.9.asPercentOf(height),\n                    )\n                }\n\n                SwipeDirection.RIGHT -> {\n                    startPoint = Point(\n                        x = 0.1.asPercentOf(width),\n                        y = 0.5.asPercentOf(height),\n                    )\n                    endPoint = Point(\n                        x = 0.9.asPercentOf(width),\n                        y = 0.5.asPercentOf(height),\n                    )\n                }\n\n                SwipeDirection.LEFT -> {\n                    startPoint = Point(\n                        x = 0.9.asPercentOf(width),\n                        y = 0.5.asPercentOf(height),\n                    )\n                    endPoint = Point(\n                        x = 0.1.asPercentOf(width),\n                        y = 0.5.asPercentOf(height),\n                    )\n                }\n            }\n            swipe(startPoint, endPoint, durationMs)\n        }\n    }\n\n    override fun swipe(elementPoint: Point, direction: SwipeDirection, durationMs: Long) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"swipeWithElementPoint\", \"direction\" to direction.name, \"durationMs\" to durationMs.toString())) {\n            val deviceInfo = deviceInfo()\n            val width = deviceInfo.widthGrid\n            val height = deviceInfo.heightGrid\n\n            when (direction) {\n                SwipeDirection.UP -> {\n                    val end = Point(x = elementPoint.x, y = 0.1.asPercentOf(height))\n                    swipe(elementPoint, end, durationMs)\n                }\n\n                SwipeDirection.DOWN -> {\n                    val end = Point(x = elementPoint.x, y = 0.9.asPercentOf(height))\n                    swipe(elementPoint, end, durationMs)\n                }\n\n                SwipeDirection.RIGHT -> {\n                    val end = Point(x = (0.9).asPercentOf(width), y = elementPoint.y)\n                    swipe(elementPoint, end, durationMs)\n                }\n\n                SwipeDirection.LEFT -> {\n                    val end = Point(x = (0.1).asPercentOf(width), y = elementPoint.y)\n                    swipe(elementPoint, end, durationMs)\n                }\n            }\n        }\n    }\n\n    override fun backPress() {}\n\n    override fun hideKeyboard() {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"hideKeyboard\")) {\n            val deviceInfo = deviceInfo()\n            val width = deviceInfo.widthGrid\n            val height = deviceInfo.heightGrid\n\n            dismissKeyboardIntroduction(heightPoints = deviceInfo.heightGrid)\n\n            if (isKeyboardHidden()) return@measured\n\n            swipe(\n                start = Point(0.5.asPercentOf(width), 0.5.asPercentOf(height)),\n                end = Point(0.5.asPercentOf(width), 0.47.asPercentOf(height)),\n                durationMs = 50,\n            )\n\n            if (isKeyboardHidden()) return@measured\n\n            swipe(\n                start = Point(0.5.asPercentOf(width), 0.5.asPercentOf(height)),\n                end = Point(0.47.asPercentOf(width), 0.5.asPercentOf(height)),\n                durationMs = 50,\n            )\n\n            waitForAppToSettle(null, null)\n        }\n    }\n\n    private fun isKeyboardHidden(): Boolean {\n        val filter = Filters.idMatches(\"delete\".toRegex())\n        val element = MaestroTimer.withTimeout(2000) {\n            filter(contentDescriptor().aggregate()).firstOrNull()\n        }?.toUiElementOrNull()\n\n        return element == null\n    }\n\n    private fun dismissKeyboardIntroduction(heightPoints: Int) {\n        val fastTypingInstruction =\n            \"Speed up your typing by sliding your finger across the letters to compose a word.*\".toRegex()\n        val instructionTextFilter = Filters.textMatches(fastTypingInstruction)\n        val instructionText = MaestroTimer.withTimeout(2000) {\n            instructionTextFilter(contentDescriptor().aggregate()).firstOrNull()\n        }?.toUiElementOrNull()\n        if (instructionText != null && instructionText.bounds.center().y in heightPoints / 2..heightPoints) {\n            val continueElementFilter = Filters.textMatches(\"Continue\".toRegex())\n            val continueElement = MaestroTimer.withTimeout(2000) {\n                continueElementFilter(contentDescriptor().aggregate()).find {\n                    it.toUiElement().bounds.center().y > instructionText.bounds.center().y\n                }\n            }?.toUiElementOrNull()\n            if (continueElement != null && continueElement.bounds.center().y > instructionText.bounds.center().y) {\n                tap(continueElement.bounds.center())\n            }\n        }\n    }\n\n    override fun takeScreenshot(out: Sink, compressed: Boolean) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"takeScreenshot\")) {\n            runDeviceCall(\"takeScreenshot\") { iosDevice.takeScreenshot(out, compressed) }\n        }\n    }\n\n    override fun startScreenRecording(out: Sink): ScreenRecording {\n        return metrics.measured(\"operation\", mapOf(\"command\" to \"startScreenRecording\")) {\n            val iosScreenRecording = iosDevice.startScreenRecording(out)\n            object : ScreenRecording {\n                override fun close() = iosScreenRecording.close()\n            }\n        }\n    }\n\n    override fun inputText(text: String) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"inputText\")) {\n            runDeviceCall(\"inputText\") { iosDevice.input(text = text) }\n        }\n    }\n\n    override fun openLink(link: String, appId: String?, autoVerify: Boolean, browser: Boolean) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"openLink\", \"appId\" to appId.toString(), \"autoVerify\" to autoVerify.toString(), \"browser\" to browser.toString())) {\n            iosDevice.openLink(link).expect {}\n        }\n    }\n\n    override fun setLocation(latitude: Double, longitude: Double) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"setLocation\")) {\n            runDeviceCall(\"setLocation\") { iosDevice.setLocation(latitude, longitude).expect {} }\n        }\n    }\n\n    override fun setOrientation(orientation: DeviceOrientation) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"setOrientation\")) {\n            runDeviceCall(\"setOrientation\") { iosDevice.setOrientation(orientation.camelCaseName) }\n        }\n    }\n\n    override fun eraseText(charactersToErase: Int) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"eraseText\")) {\n            runDeviceCall(\"eraseText\") { iosDevice.eraseText(charactersToErase) }\n        }\n    }\n\n    override fun setProxy(host: String, port: Int) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"setProxy\")) {\n            xcRunnerCLIUtils.setProxy(host, port)\n            proxySet = true\n        }\n    }\n\n    override fun resetProxy() {\n        xcRunnerCLIUtils.resetProxy()\n    }\n\n    override fun isShutdown(): Boolean {\n        return metrics.measured(\"operation\", mapOf(\"command\" to \"isShutdown\")) {\n            iosDevice.isShutdown()\n        }\n    }\n\n    override fun waitUntilScreenIsStatic(timeoutMs: Long): Boolean {\n        return metrics.measured(\"operation\", mapOf(\"command\" to \"waitUntilScreenIsStatic\", \"timeoutMs\" to timeoutMs.toString())) {\n             MaestroTimer.retryUntilTrue(timeoutMs) {\n                val isScreenStatic = isScreenStatic()\n\n                return@retryUntilTrue isScreenStatic\n            }\n        }\n    }\n\n    override fun waitForAppToSettle(initialHierarchy: ViewHierarchy?, appId: String?, timeoutMs: Int?): ViewHierarchy? {\n        return metrics.measured(\"operation\", mapOf(\"command\" to \"waitForAppToSettle\", \"appId\" to appId.toString(), \"timeoutMs\" to timeoutMs.toString())) {\n            LOGGER.info(\"Waiting for animation to end with timeout $SCREEN_SETTLE_TIMEOUT_MS\")\n            val didFinishOnTime = waitUntilScreenIsStatic(SCREEN_SETTLE_TIMEOUT_MS)\n\n            if (didFinishOnTime) null else ScreenshotUtils.waitForAppToSettle(initialHierarchy, this, timeoutMs)\n        }\n    }\n\n    override fun capabilities(): List<Capability> {\n        return emptyList()\n    }\n\n    override fun setPermissions(appId: String, permissions: Map<String, String>) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"setPermissions\", \"appId\" to appId)) {\n            runDeviceCall(\"setPermissions\") {\n                iosDevice.setPermissions(appId, permissions)\n            }\n        }\n    }\n\n    override fun addMedia(mediaFiles: List<File>) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"addMedia\", \"mediaFilesCount\" to mediaFiles.size.toString())) {\n            LOGGER.info(\"[Start] Adding media files\")\n            mediaFiles.forEach { addMediaToDevice(it) }\n            LOGGER.info(\"[Done] Adding media files\")\n        }\n    }\n\n    override fun isAirplaneModeEnabled(): Boolean {\n        LOGGER.warn(\"Airplane mode is not available on iOS simulators\")\n        return false\n    }\n\n    override fun setAirplaneMode(enabled: Boolean) {\n        LOGGER.warn(\"Airplane mode is not available on iOS simulators\")\n    }\n\n    private fun addMediaToDevice(mediaFile: File) {\n        metrics.measured(\"operation\", mapOf(\"command\" to \"addMediaToDevice\")) {\n            val namedSource = NamedSource(\n                mediaFile.name,\n                mediaFile.source(),\n                mediaFile.extension,\n                mediaFile.path\n            )\n            MediaExt.values().firstOrNull { mediaExt -> mediaExt.extName == namedSource.extension }\n                ?: throw IllegalArgumentException(\n                    \"Extension .${namedSource.extension} is not yet supported for add media\"\n                )\n            iosDevice.addMedia(namedSource.path)\n        }\n    }\n\n    private fun isScreenStatic(): Boolean {\n        return runDeviceCall(\"isScreenStatic\") { iosDevice.isScreenStatic() }\n    }\n\n    private fun <T> runDeviceCall(callName: String, call: () -> T): T {\n        return try {\n            call()\n        } catch (socketTimeoutException: SocketTimeoutException) {\n            LOGGER.error(\"Got socket timeout processing $callName command\", socketTimeoutException)\n            throw socketTimeoutException\n        } catch (appCrashException: IOSDeviceErrors.AppCrash) {\n            LOGGER.error(\"Detected app crash during $callName command\", appCrashException)\n            throw MaestroException.AppCrash(appCrashException.errorMessage)\n        } catch (timeoutException: IOSDeviceErrors.OperationTimeout) {\n            val debugMessage = when {\n                timeoutException.errorMessage.contains(\"Timed out while evaluating UI query\") -> {\n                    \"\"\"\n                        Your app screen might be too complex.\n                                            \n                        * This usually happens when the screen has very large view hierarchies, such as table views loading with large amount of data.\n                        * Try loading fewer cells initially or implementing lazy loading to reduce the load during tests.\n                    \"\"\".trimIndent()\n                }\n                timeoutException.errorMessage.contains(\"Unable to perform work on main run loop, process main thread busy\") -> {\n                    \"\"\"\n                        Your app is doing heavy work on the main/UI thread.\n                        \n                        * Move any heavy computation or blocking work off the main thread.\n                        * This ensures the UI stays responsive and Maestro can take snapshot of the screen.\n                    \"\"\".trimIndent()\n                }\n                else -> null\n            }\n            throw MaestroException.DriverTimeout(\n                message = \"Maestro driver timed out during $callName call with: ${timeoutException.errorMessage}\",\n                debugMessage = debugMessage\n            )\n        }\n    }\n\n    companion object {\n        const val NAME = \"iOS Simulator\"\n\n        private val LOGGER = LoggerFactory.getLogger(IOSDevice::class.java)\n\n        private const val ELEMENT_TYPE_CHECKBOX = 12\n        private const val ELEMENT_TYPE_SWITCH = 40\n        private const val ELEMENT_TYPE_TOGGLE = 41\n\n        private const val WARNING_MAX_DEPTH = 61\n\n        private val CHECKABLE_ELEMENTS = setOf(\n            ELEMENT_TYPE_CHECKBOX,\n            ELEMENT_TYPE_SWITCH,\n            ELEMENT_TYPE_TOGGLE,\n        )\n\n        private const val SCREEN_SETTLE_TIMEOUT_MS: Long = 3000\n    }\n}\n\nprivate fun Double.asPercentOf(total: Int): Int {\n    return (this * total).toInt()\n}\n\nprivate fun Point.coerceIn(maxWidth: Int, maxHeight: Int): Point {\n    return Point(\n        x = x.coerceIn(0, maxWidth),\n        y = y.coerceIn(0, maxHeight),\n    )\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/drivers/WebDriver.kt",
    "content": "package maestro.drivers\n\nimport maestro.Capability\nimport maestro.DeviceInfo\nimport maestro.device.DeviceOrientation\nimport maestro.Driver\nimport maestro.KeyCode\nimport maestro.Maestro\nimport maestro.OnDeviceElementQuery\nimport maestro.Point\nimport maestro.ScreenRecording\nimport maestro.SwipeDirection\nimport maestro.TreeNode\nimport maestro.ViewHierarchy\nimport maestro.device.Platform\nimport maestro.utils.ScreenshotUtils\nimport maestro.web.record.JcodecVideoEncoder\nimport maestro.web.record.WebScreenRecorder\nimport maestro.web.selenium.ChromeSeleniumFactory\nimport maestro.web.selenium.SeleniumFactory\nimport okio.Sink\nimport okio.buffer\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport org.openqa.selenium.By\nimport org.openqa.selenium.JavascriptExecutor\nimport org.openqa.selenium.Keys\nimport org.openqa.selenium.OutputType\nimport org.openqa.selenium.TakesScreenshot\nimport org.openqa.selenium.WebElement\nimport org.openqa.selenium.devtools.HasDevTools\nimport org.openqa.selenium.devtools.v144.emulation.Emulation\nimport org.openqa.selenium.interactions.Actions\nimport org.openqa.selenium.interactions.PointerInput\nimport org.openqa.selenium.remote.RemoteWebDriver\nimport org.openqa.selenium.support.ui.WebDriverWait\nimport org.slf4j.LoggerFactory\nimport java.io.File\nimport java.time.Duration\nimport java.util.*\n\n\nprivate const val SYNTHETIC_COORDINATE_SPACE_OFFSET = 100000\n\nclass WebDriver(\n    val isStudio: Boolean,\n    isHeadless: Boolean = isStudio,\n    screenSize: String?,\n    private val seleniumFactory: SeleniumFactory = ChromeSeleniumFactory(isHeadless = isHeadless, screenSize)\n) : Driver {\n\n    private var seleniumDriver: org.openqa.selenium.WebDriver? = null\n    private var maestroWebScript: String? = null\n    private var lastSeenWindowHandles = setOf<String>()\n    private var injectedArguments: Map<String, Any> = emptyMap()\n\n    private var webScreenRecorder: WebScreenRecorder? = null\n\n    init {\n        Maestro::class.java.getResourceAsStream(\"/maestro-web.js\")?.let {\n            it.bufferedReader().use { br ->\n                maestroWebScript = br.readText()\n            }\n        } ?: error(\"Could not read maestro web script\")\n    }\n\n    override fun name(): String {\n        return \"Chromium Desktop Browser (Experimental)\"\n    }\n\n    override fun open() {\n        seleniumDriver = seleniumFactory.create()\n\n        try {\n            seleniumDriver\n                ?.let { it as? HasDevTools }\n                ?.devTools\n                ?.createSessionIfThereIsNotOne()\n        } catch (e: Exception) {\n            // Swallow the exception to avoid crashing the whole process.\n            // Some implementations of Selenium do not support DevTools\n            // and do not fail gracefully.\n        }\n\n        if (isStudio) {\n            seleniumDriver?.get(\"https://maestro.mobile.dev\")\n        }\n    }\n\n    private fun ensureOpen(): org.openqa.selenium.WebDriver {\n        return seleniumDriver ?: error(\"Driver is not open\")\n    }\n\n    private fun executeJS(js: String): Any? {\n        val executor = seleniumDriver as JavascriptExecutor\n\n        try {\n            executor.executeScript(\"$maestroWebScript\")\n\n            injectedArguments.forEach { (key, value) ->\n                executor.executeScript(\"$key = '$value'\")\n            }\n\n            Thread.sleep(100)\n            return executor.executeScript(js)\n        } catch (e: Exception) {\n            if (e.message?.contains(\"getContentDescription\") == true) {\n                return executeJS(js)\n            }\n            return null\n        }\n    }\n\n    private fun executeAsyncJS(js: String, timeoutMs: Long): Any? {\n        val executor = seleniumDriver as JavascriptExecutor\n\n        try {\n            executor.executeScript(\"$maestroWebScript\")\n\n            injectedArguments.forEach { (key, value) ->\n                executor.executeScript(\"$key = '$value'\")\n            }\n\n            Thread.sleep(100)\n            seleniumDriver?.manage()?.timeouts()?.scriptTimeout(Duration.ofMillis(timeoutMs))\n\n            val wrapped = \"\"\"\n                const callback = arguments[arguments.length - 1];\n                Promise.resolve((function() { return $js; })())\n                    .then((result) => callback(result))\n                    .catch(() => callback(null));\n            \"\"\".trimIndent()\n\n            return executor.executeAsyncScript(wrapped)\n        } catch (e: Exception) {\n            if (e.message?.contains(\"getContentDescription\") == true) {\n                return executeAsyncJS(js, timeoutMs)\n            }\n            return null\n        }\n    }\n\n    private fun scrollToPoint(point: Point): Long {\n        ensureOpen()\n        val windowHeight = executeJS(\"return window.innerHeight\") as Long\n\n        if (point.y >= 0 && point.y.toLong() <= windowHeight) return 0L\n\n        val scrolledPixels =\n            executeJS(\"const delta = ${point.y} - Math.floor(window.innerHeight / 2); window.scrollBy({ top: delta, left: 0, behavior: 'smooth' }); return delta\") as Long\n        sleep(3000L)\n        return scrolledPixels\n    }\n\n    private fun sleep(ms: Long) {\n        Thread.sleep(ms)\n    }\n\n    private fun scroll(top: String, left: String) {\n        executeJS(\"window.scroll({ top: $top, left: $left, behavior: 'smooth' });\")\n    }\n\n    private fun random(start: Int, end: Int): Int {\n        return Random().nextInt((end + 1) - start) + start\n    }\n\n    override fun close() {\n        injectedArguments = emptyMap()\n\n        try {\n            seleniumDriver?.quit()\n            webScreenRecorder?.close()\n        } catch (e: Exception) {\n            // Swallow the exception to avoid crashing the whole process\n        }\n\n        seleniumDriver = null\n        lastSeenWindowHandles = setOf()\n        webScreenRecorder = null\n    }\n\n    override fun deviceInfo(): DeviceInfo {\n        val driver = ensureOpen() as JavascriptExecutor\n\n        val width = driver.executeScript(\"return window.innerWidth;\") as Long\n        val height = driver.executeScript(\"return window.innerHeight;\") as Long\n\n        return DeviceInfo(\n            platform = Platform.WEB,\n            widthPixels = width.toInt(),\n            heightPixels = height.toInt(),\n            widthGrid = width.toInt(),\n            heightGrid = height.toInt(),\n        )\n    }\n\n    override fun launchApp(\n        appId: String,\n        launchArguments: Map<String, Any>,\n    ) {\n        injectedArguments = injectedArguments + launchArguments\n\n        open()\n        val driver = ensureOpen()\n\n        driver.manage().timeouts().implicitlyWait(Duration.ofMillis(5000))\n        val wait = WebDriverWait(driver, Duration.ofSeconds(30L))\n\n        driver.get(appId)\n        wait.until { (it as JavascriptExecutor).executeScript(\"return document.readyState\") == \"complete\" }\n    }\n\n    override fun stopApp(appId: String) {\n        // Not supported at the moment.\n        // Simply calling driver.close() can kill the Selenium session, rendering\n        // the driver inoperable.\n    }\n\n    override fun killApp(appId: String) {\n        // On Web there is no Process Death like on Android so this command will be a synonym to the stop command\n        stopApp(appId)\n    }\n\n    override fun contentDescriptor(excludeKeyboardElements: Boolean): TreeNode {\n        ensureOpen()\n\n        detectWindowChange()\n\n        // retrieve view hierarchy from DOM\n        // There are edge cases where executeJS returns null, and we cannot get the hierarchy. In this situation\n        // we retry multiple times until throwing an error eventually. (See issue #1936)\n        var contentDesc: Any? = null\n        var retry = 0\n        while (contentDesc == null) {\n            contentDesc = executeJS(\"return window.maestro.getContentDescription()\")\n            if (contentDesc == null) {\n                retry++\n            }\n            if (retry == RETRY_FETCHING_CONTENT_DESCRIPTION) {\n                throw IllegalStateException(\"Could not retrieve hierarchy through maestro.getContentDescription() (tried $retry times\")\n            }\n        }\n\n        val rawMap = contentDesc as Map<String, Any>\n        val enrichedMap = injectCrossOriginIframes(rawMap)\n        val root = parseDomAsTreeNodes(enrichedMap)\n        seleniumDriver?.currentUrl?.let { url ->\n            root.attributes[\"url\"] = url\n        }\n        return root\n    }\n\n    fun parseDomAsTreeNodes(domRepresentation: Map<String, Any>): TreeNode {\n        val attrs = domRepresentation[\"attributes\"] as Map<String, Any>\n\n        val attributes = mutableMapOf(\n            \"text\" to attrs[\"text\"] as String,\n            \"bounds\" to attrs[\"bounds\"] as String,\n        )\n        if (attrs.containsKey(\"resource-id\") && attrs[\"resource-id\"] != null) {\n            attributes[\"resource-id\"] = attrs[\"resource-id\"] as String\n        }\n        if (attrs.containsKey(\"selected\") && attrs[\"selected\"] != null) {\n            attributes[\"selected\"] = (attrs[\"selected\"] as Boolean).toString()\n        }\n        if (attrs.containsKey(\"synthetic\") && attrs[\"synthetic\"] != null) {\n            attributes[\"synthetic\"] = (attrs[\"synthetic\"] as Boolean).toString()\n        }\n        if (attrs.containsKey(\"ignoreBoundsFiltering\") && attrs[\"ignoreBoundsFiltering\"] != null) {\n            attributes[\"ignoreBoundsFiltering\"] = (attrs[\"ignoreBoundsFiltering\"] as Boolean).toString()\n        }\n\n        val children = domRepresentation[\"children\"] as List<Map<String, Any>>\n\n        return TreeNode(attributes = attributes, children = children.map { parseDomAsTreeNodes(it) })\n    }\n\n    private fun detectWindowChange() {\n        // Checks whether there are any new window handles available and, if so, switches Selenium driver focus to it\n        val driver = ensureOpen()\n\n        if (lastSeenWindowHandles != driver.windowHandles) {\n            val newHandles = driver.windowHandles - lastSeenWindowHandles\n            lastSeenWindowHandles = driver.windowHandles\n\n            if (newHandles.isNotEmpty()) {\n                val newHandle = newHandles.first();\n                LOGGER.info(\"Detected a window change, switching to new window handle $newHandle\")\n\n                driver.switchTo().window(newHandle)\n\n                webScreenRecorder?.onWindowChange()\n            }\n        }\n    }\n\n    override fun clearAppState(appId: String) {\n        val driver = ensureOpen()\n\n        try {\n            val jsExecutor = driver as JavascriptExecutor\n            jsExecutor.executeScript(\n                \"\"\"\n                try { window.localStorage.clear(); } catch(e) {}\n                try { window.sessionStorage.clear(); } catch(e) {}\n                try {\n                    document.cookie.split(';').forEach(function(c) {\n                        document.cookie = c.trim().split('=')[0] +\n                            '=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/';\n                    });\n                } catch(e) {}\n                try {\n                    if (window.indexedDB && window.indexedDB.databases) {\n                        window.indexedDB.databases().then(function(dbs) {\n                            dbs.forEach(function(db) { window.indexedDB.deleteDatabase(db.name); });\n                        });\n                    }\n                } catch(e) {}\n                \"\"\".trimIndent()\n            )\n        } catch (e: Exception) {\n            LOGGER.warn(\"Failed to clear browser state for $appId\", e)\n        }\n    }\n\n    override fun clearKeychain() {\n        // Do nothing\n    }\n\n    override fun tap(point: Point) {\n        val driver = ensureOpen()\n\n        if (point.x >= SYNTHETIC_COORDINATE_SPACE_OFFSET && point.y >= SYNTHETIC_COORDINATE_SPACE_OFFSET) {\n            tapOnSyntheticCoordinateSpace(point)\n            return\n        }\n\n        val pixelsScrolled = scrollToPoint(point)\n\n        val mouse = PointerInput(PointerInput.Kind.MOUSE, \"default mouse\")\n        val actions = org.openqa.selenium.interactions.Sequence(mouse, 1)\n            .addAction(\n                mouse.createPointerMove(\n                    Duration.ofMillis(400),\n                    PointerInput.Origin.viewport(),\n                    point.x,\n                    point.y - pixelsScrolled.toInt()\n                )\n            )\n\n        (driver as RemoteWebDriver).perform(listOf(actions))\n\n        Actions(driver).click().build().perform()\n    }\n\n    private fun tapOnSyntheticCoordinateSpace(point: Point) {\n        val elements = contentDescriptor()\n\n        val hit = ViewHierarchy.from(this, true)\n            .getElementAt(elements, point.x, point.y)\n\n        if (hit == null) {\n            return\n        }\n\n        if (hit.attributes[\"synthetic\"] != \"true\") {\n            return\n        }\n\n        executeJS(\"window.maestro.tapOnSyntheticElement(${point.x}, ${point.y})\")\n    }\n\n    override fun longPress(point: Point) {\n        val driver = ensureOpen()\n\n        val mouse = PointerInput(PointerInput.Kind.MOUSE, \"default mouse\")\n        val actions = org.openqa.selenium.interactions.Sequence(mouse, 0)\n            .addAction(mouse.createPointerMove(Duration.ZERO, PointerInput.Origin.viewport(), point.x, point.y))\n        (driver as RemoteWebDriver).perform(listOf(actions))\n\n        Actions(driver).clickAndHold().pause(3000L).release().build().perform()\n    }\n\n    override fun pressKey(code: KeyCode) {\n        val key = mapToSeleniumKey(code)\n        withActiveElement { element -> element.sendKeys(key) }\n    }\n\n    private fun mapToSeleniumKey(code: KeyCode): Keys {\n        return when (code) {\n            KeyCode.ENTER -> Keys.ENTER\n            KeyCode.BACKSPACE -> Keys.BACK_SPACE\n            else -> error(\"Keycode $code is not supported on web\")\n        }\n    }\n\n    override fun scrollVertical() {\n        // Check if this is a Flutter web app\n        val isFlutter = executeJS(\"return window.maestro.isFlutterApp()\") as? Boolean ?: false\n        \n        if (isFlutter) {\n            // Use Flutter-specific smooth animated scrolling\n            executeAsyncJS(\"window.maestro.smoothScrollFlutter('UP', 500)\", 1500L)\n        } else {\n            // Use standard scroll for regular web pages\n            scroll(\"window.scrollY + Math.round(window.innerHeight / 2)\", \"window.scrollX\")\n        }\n    }\n\n    override fun isKeyboardVisible(): Boolean {\n        return false\n    }\n\n    override fun swipe(start: Point, end: Point, durationMs: Long) {\n        val driver = ensureOpen()\n\n        val isFlutter = executeJS(\"return window.maestro.isFlutterApp()\") as? Boolean ?: false\n        \n        if (isFlutter) {\n            // Flutter web: Convert coordinate-based swipe to wheel events\n            // Calculate the scroll delta from start to end points\n            val deltaX = start.x - end.x  // Swipe left = scroll right (positive deltaX)\n            val deltaY = start.y - end.y  // Swipe up = scroll down (positive deltaY)\n            \n            // Dispatch wheel events at the center of the viewport for Flutter\n            val waitMs = (durationMs + 500).coerceAtLeast(1000L)\n            executeAsyncJS(\n                \"window.maestro.smoothScrollFlutterByDelta($deltaX, $deltaY, $durationMs)\",\n                waitMs\n            )\n        } else {\n            // Standard web: Use touch pointer drag\n            val finger = PointerInput(PointerInput.Kind.TOUCH, \"finger\")\n            val swipe = org.openqa.selenium.interactions.Sequence(finger, 1)\n            swipe.addAction(\n                finger.createPointerMove(\n                    Duration.ofMillis(0),\n                    PointerInput.Origin.viewport(),\n                    start.x,\n                    start.y\n                )\n            )\n            swipe.addAction(finger.createPointerDown(PointerInput.MouseButton.LEFT.asArg()))\n            swipe.addAction(\n                finger.createPointerMove(\n                    Duration.ofMillis(durationMs),\n                    PointerInput.Origin.viewport(),\n                    end.x,\n                    end.y\n                )\n            )\n            swipe.addAction(finger.createPointerUp(PointerInput.MouseButton.LEFT.asArg()))\n            (driver as RemoteWebDriver).perform(listOf(swipe))\n        }\n    }\n\n    override fun swipe(swipeDirection: SwipeDirection, durationMs: Long) {\n        val isFlutter = executeJS(\"return window.maestro.isFlutterApp()\") as? Boolean ?: false\n        \n        if (isFlutter) {\n            // Flutter web: Use smooth animated scrolling with easing\n            val waitMs = (durationMs + 1000).coerceAtLeast(1000L)\n            executeAsyncJS(\n                \"window.maestro.smoothScrollFlutter('${swipeDirection.name}', $durationMs)\",\n                waitMs\n            )\n        } else {\n            // HTML web: Use standard window scrolling\n            when (swipeDirection) {\n                SwipeDirection.UP -> scroll(\"window.scrollY + Math.round(window.innerHeight / 2)\", \"window.scrollX\")\n                SwipeDirection.DOWN -> scroll(\"window.scrollY - Math.round(window.innerHeight / 2)\", \"window.scrollX\")\n                SwipeDirection.LEFT -> scroll(\"window.scrollY\", \"window.scrollX + Math.round(window.innerWidth / 2)\")\n                SwipeDirection.RIGHT -> scroll(\"window.scrollY\", \"window.scrollX - Math.round(window.innerWidth / 2)\")\n            }\n        }\n    }\n\n    override fun swipe(elementPoint: Point, direction: SwipeDirection, durationMs: Long) {\n        // Ignoring elementPoint to enable a rudimentary implementation of scrollUntilVisible for web\n        swipe(direction, durationMs)\n    }\n\n    override fun backPress() {\n        val driver = ensureOpen()\n        driver.navigate().back()\n    }\n\n    override fun inputText(text: String) {\n        withActiveElement { element ->\n            for (c in text.toCharArray()) {\n                element.sendKeys(\"$c\")\n                sleep(random(20, 100).toLong())\n            }\n        }\n    }\n\n    override fun openLink(link: String, appId: String?, autoVerify: Boolean, browser: Boolean) {\n        val driver = ensureOpen()\n\n        driver.get(if (link.startsWith(\"http\")) link else \"https://$link\")\n    }\n\n    override fun hideKeyboard() {\n        // no-op on web\n        return\n    }\n\n    override fun takeScreenshot(out: Sink, compressed: Boolean) {\n        val driver = ensureOpen()\n\n        val src = (driver as TakesScreenshot).getScreenshotAs(OutputType.FILE)\n        out.buffer().use { it.write(src.readBytes()) }\n    }\n\n    override fun startScreenRecording(out: Sink): ScreenRecording {\n        val driver = ensureOpen()\n        webScreenRecorder = WebScreenRecorder(\n            JcodecVideoEncoder(),\n            driver\n        )\n        webScreenRecorder?.startScreenRecording(out)\n\n        return object : ScreenRecording {\n            override fun close() {\n                webScreenRecorder?.close()\n            }\n        }\n    }\n\n    override fun setLocation(latitude: Double, longitude: Double) {\n        val driver = ensureOpen() as HasDevTools\n\n        driver.devTools.createSessionIfThereIsNotOne()\n\n        driver.devTools.send(\n            Emulation.setGeolocationOverride(\n                Optional.of(latitude),\n                Optional.of(longitude),\n                Optional.of(0.0),\n                Optional.empty(),\n                Optional.empty(),\n                Optional.empty(),\n                Optional.empty()\n            )\n        )\n    }\n\n    override fun setOrientation(orientation: DeviceOrientation) {\n        // no-op for web\n    }\n\n    override fun eraseText(charactersToErase: Int) {\n        withActiveElement { element ->\n            for (i in 0 until charactersToErase) {\n                element.sendKeys(Keys.BACK_SPACE)\n                sleep(random(20, 50).toLong())\n            }\n        }\n        sleep(1000)\n    }\n\n    override fun setProxy(host: String, port: Int) {\n        // Do nothing\n    }\n\n    override fun resetProxy() {\n        // Do nothing\n    }\n\n    override fun isShutdown(): Boolean {\n        close()\n        return true\n    }\n\n    override fun isUnicodeInputSupported(): Boolean {\n        return true\n    }\n\n    override fun waitForAppToSettle(initialHierarchy: ViewHierarchy?, appId: String?, timeoutMs: Int?): ViewHierarchy {\n        return ScreenshotUtils.waitForAppToSettle(initialHierarchy, this)\n    }\n\n    override fun waitUntilScreenIsStatic(timeoutMs: Long): Boolean {\n        return ScreenshotUtils.waitUntilScreenIsStatic(timeoutMs, SCREENSHOT_DIFF_THRESHOLD, this)\n    }\n\n    override fun capabilities(): List<Capability> {\n        return listOf(\n            Capability.FAST_HIERARCHY\n        )\n    }\n\n    override fun setPermissions(appId: String, permissions: Map<String, String>) {\n        // no-op for web\n    }\n\n    override fun addMedia(mediaFiles: List<File>) {\n        // noop for web\n    }\n\n    override fun isAirplaneModeEnabled(): Boolean {\n        return false;\n    }\n\n    override fun setAirplaneMode(enabled: Boolean) {\n        // Do nothing\n    }\n\n    override fun queryOnDeviceElements(query: OnDeviceElementQuery): List<TreeNode> {\n        return when (query) {\n            is OnDeviceElementQuery.Css -> queryCss(query)\n            else -> super.queryOnDeviceElements(query)\n        }\n    }\n\n    private fun queryCss(query: OnDeviceElementQuery.Css): List<TreeNode> {\n        ensureOpen()\n\n        val jsResult: Any? = executeJS(\"return window.maestro.queryCss('${query.css}')\")\n\n        if (jsResult == null) {\n            return emptyList()\n        }\n\n        if (jsResult is List<*>) {\n            return jsResult\n                .mapNotNull { it as? Map<*, *> }\n                .map { parseDomAsTreeNodes(it as Map<String, Any>) }\n        } else {\n            LOGGER.error(\"Unexpected result type from queryCss: ${jsResult.javaClass.name}\")\n            return emptyList()\n        }\n    }\n\n    @Suppress(\"UNCHECKED_CAST\")\n    private fun injectCrossOriginIframes(node: Map<String, Any>): Map<String, Any> {\n        val attrs = node[\"attributes\"] as Map<String, Any>\n        val iframeSrc = attrs[\"__crossOriginIframe\"] as? String\n\n        if (iframeSrc != null) {\n            val iframeContent = fetchCrossOriginIframeContent(iframeSrc)\n            if (iframeContent != null) return iframeContent\n            val cleanAttrs = attrs - \"__crossOriginIframe\"\n            return mapOf(\"attributes\" to cleanAttrs, \"children\" to emptyList<Any>())\n        }\n\n        val children = (node[\"children\"] as List<Map<String, Any>>)\n            .map { injectCrossOriginIframes(it) }\n        return mapOf(\"attributes\" to attrs, \"children\" to children)\n    }\n\n    @Suppress(\"UNCHECKED_CAST\")\n    private fun fetchCrossOriginIframeContent(iframeSrc: String): Map<String, Any>? {\n        val driver = seleniumDriver ?: return null\n        val jsExecutor = driver as? JavascriptExecutor ?: return null\n\n        // Find the iframe element by its resolved src property (absolute URL)\n        val iframeElement = try {\n            jsExecutor.executeScript(\n                \"return [...document.querySelectorAll('iframe')].find(f => f.src === arguments[0]);\",\n                iframeSrc\n            ) as? WebElement\n        } catch (e: Exception) {\n            LOGGER.warn(\"Could not find iframe element with src $iframeSrc\", e)\n            return null\n        } ?: run {\n            LOGGER.warn(\"No iframe element found with src $iframeSrc\")\n            return null\n        }\n\n        // Get the iframe's scaled viewport params (accounts for parent viewportWidth/Height scaling)\n        val paramsJson = try {\n            jsExecutor.executeScript(\n                \"return JSON.stringify(window.maestro.getIframeViewportParams(arguments[0]));\",\n                iframeSrc\n            ) as? String\n        } catch (e: Exception) {\n            LOGGER.warn(\"Could not get viewport params for iframe $iframeSrc\", e)\n            return null\n        } ?: return null\n\n        val params = jacksonObjectMapper().readValue(paramsJson, Map::class.java) as Map<String, Any>\n        val iframeX = (params[\"viewportX\"]      as? Number)?.toDouble() ?: 0.0\n        val iframeY = (params[\"viewportY\"]      as? Number)?.toDouble() ?: 0.0\n        val iframeW = (params[\"viewportWidth\"]  as? Number)?.toDouble() ?: 0.0\n        val iframeH = (params[\"viewportHeight\"] as? Number)?.toDouble() ?: 0.0\n\n        // ChromeDriver can execute scripts inside cross-origin iframes via switchTo().frame()\n        driver.switchTo().frame(iframeElement)\n        return try {\n            val resultJson = jsExecutor.executeScript(\"\"\"\n                $maestroWebScript\n                window.maestro.viewportX = $iframeX;\n                window.maestro.viewportY = $iframeY;\n                window.maestro.viewportWidth = $iframeW;\n                window.maestro.viewportHeight = $iframeH;\n                return JSON.stringify(window.maestro.getContentDescription());\n            \"\"\".trimIndent()) as? String ?: return null\n            jacksonObjectMapper().readValue(resultJson, Map::class.java) as? Map<String, Any>\n        } catch (e: Exception) {\n            LOGGER.warn(\"Failed to get content description from cross-origin iframe $iframeSrc\", e)\n            null\n        } finally {\n            try { driver.switchTo().defaultContent() }\n            catch (e: Exception) { LOGGER.warn(\"Failed to switch back to default content\", e) }\n        }\n    }\n\n    /**\n     * Locates the truly focused element, even when it lives inside a cross-origin iframe.\n     *\n     * When the user taps inside a cross-origin iframe the main frame's\n     * `document.activeElement` is the `<iframe>` element itself.  This helper\n     * detects that case, switches Selenium into the iframe, resolves the real\n     * active element there, runs [action], and switches back to the default\n     * content so subsequent commands target the main frame again.\n     */\n    private fun withActiveElement(action: (WebElement) -> Unit) {\n        val driver = ensureOpen()\n        val jsExecutor = driver as JavascriptExecutor\n\n        val isIframeFocused = jsExecutor.executeScript(\n            \"return document.activeElement && document.activeElement.tagName.toLowerCase() === 'iframe'\"\n        ) as? Boolean ?: false\n\n        if (isIframeFocused) {\n            val iframe = jsExecutor.executeScript(\"return document.activeElement\") as WebElement\n            driver.switchTo().frame(iframe)\n            try {\n                jsExecutor.executeScript(\"$maestroWebScript\")\n                val xPath = jsExecutor.executeScript(\n                    \"return window.maestro.createXPathFromElement(document.activeElement)\"\n                ) as String\n                val element = driver.findElement(By.ByXPath(xPath))\n                action(element)\n            } finally {\n                try { driver.switchTo().defaultContent() }\n                catch (e: Exception) { LOGGER.warn(\"Failed to switch back to default content\", e) }\n            }\n        } else {\n            val xPath = executeJS(\"return window.maestro.createXPathFromElement(document.activeElement)\") as String\n            val element = driver.findElement(By.ByXPath(xPath))\n            action(element)\n        }\n    }\n\n    companion object {\n        private const val SCREENSHOT_DIFF_THRESHOLD = 0.005\n        private const val RETRY_FETCHING_CONTENT_DESCRIPTION = 10\n\n        private val LOGGER = LoggerFactory.getLogger(maestro.drivers.WebDriver::class.java)\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/js/GraalJsEngine.kt",
    "content": "package maestro.js\n\nimport maestro.utils.HttpClient\nimport net.datafaker.Faker\nimport net.datafaker.providers.base.AbstractProvider\nimport okhttp3.OkHttpClient\nimport okhttp3.Protocol\nimport org.graalvm.polyglot.Context\nimport org.graalvm.polyglot.HostAccess\nimport org.graalvm.polyglot.Source\nimport org.graalvm.polyglot.Value\nimport org.graalvm.polyglot.proxy.ProxyObject\nimport java.io.ByteArrayOutputStream\nimport java.util.logging.Handler\nimport java.util.logging.LogRecord\nimport kotlin.time.Duration.Companion.minutes\n\nprivate val NULL_HANDLER = object : Handler() {\n    override fun publish(record: LogRecord?) {}\n\n    override fun flush() {}\n\n    override fun close() {}\n}\n\nclass GraalJsEngine(\n    httpClient: OkHttpClient = HttpClient.build(\n        name = \"GraalJsEngine\",\n        readTimeout = 5.minutes,\n        writeTimeout = 5.minutes,\n        callTimeout = 5.minutes,\n        protocols = listOf(Protocol.HTTP_1_1)\n    ),\n    platform: String = \"unknown\"\n) : JsEngine {\n\n    private val httpBinding = GraalJsHttp(httpClient)\n    private val outputBinding = HashMap<String, Any>()\n    private val maestroBinding = HashMap<String, Any?>()\n    private val envBinding = HashMap<String, String>()\n    private val envScopeStack = mutableListOf<HashMap<String, String>>()  // for scope isolation\n\n    // Keys that should never be removed from context bindings\n    private val permanentBindingKeys = setOf(\n        \"http\", \"faker\", \"output\", \"maestro\",  // Kotlin-side bindings\n        \"json\", \"relativePoint\"                 // JS-defined helper functions\n    )\n\n    private val faker = Faker()\n    private val fakerPublicClasses = mutableSetOf<Class<*>>() // To avoid re-processing the same class multiple times\n\n    private var onLogMessage: (String) -> Unit = {}\n\n    private var platform = platform\n\n    // Single reusable context - created lazily on first evaluation\n    private var sharedContext: Context? = null\n\n    override fun close() {\n        sharedContext?.close()\n        sharedContext = null\n    }\n\n    override fun onLogMessage(callback: (String) -> Unit) {\n        onLogMessage = callback\n    }\n\n    override fun enterScope() {}\n\n    override fun leaveScope() {}\n\n    override fun putEnv(key: String, value: String) {\n        this.envBinding[key] = value\n    }\n\n    override fun setCopiedText(text: String?) {\n        this.maestroBinding[\"copiedText\"] = text\n    }\n\n    override fun evaluateScript(\n        script: String,\n        env: Map<String, String>,\n        sourceName: String,\n        runInSubScope: Boolean,\n    ): Value {\n        // Set current script directory for resolving relative file paths\n        httpBinding.setCurrentScriptDir(if (sourceName != \"inline-script\") sourceName else null)\n\n        if (runInSubScope) {\n            // Save current environment state\n            enterEnvScope()\n            try {\n                // Add the new env vars on top of the current scope\n                envBinding.putAll(env)\n                return evalWithIIFE(script, sourceName)\n            } finally {\n                // Restore previous environment state\n                leaveEnvScope()\n            }\n        } else {\n            // Original behavior - directly add to envBinding\n            envBinding.putAll(env)\n            return evalWithIIFE(script, sourceName)\n        }\n    }\n\n    /**\n     * Evaluates a script wrapped in an IIFE (Immediately Invoked Function Expression)\n     * to isolate variable declarations while reusing a single context.\n     *\n     * This approach solves the memory bloat issue where each evaluateScript() call\n     * previously created a new GraalJS context (~1MB each), causing OOM errors in\n     * flows with 1000+ iterations.\n     *\n     * The IIFE wrapper uses `eval()` internally to:\n     * - Scope variables (var/let/const) to the function\n     * - Return the value of the last expression (eval's natural behavior)\n     * - Keep shared bindings (output, maestro) accessible and persistent\n     */\n    private fun evalWithIIFE(script: String, sourceName: String): Value {\n        val context = getOrCreateContext()\n        syncBindingsToContext(context)\n\n        // Wrap script in IIFE with eval() to:\n        // 1. Isolate variable declarations to the function scope\n        // 2. Return the last expression's value (eval's behavior)\n        // We use a template literal to safely embed the script.\n        val escapedScript = script\n            .replace(\"\\\\\", \"\\\\\\\\\")\n            .replace(\"`\", \"\\\\`\")\n            .replace(\"\\${\", \"\\\\\\${\")\n        val wrappedScript = \"(function(){ return eval(`$escapedScript`) })()\"\n        val source = Source.newBuilder(\"js\", wrappedScript, sourceName).build()\n        return context.eval(source)\n    }\n\n    /**\n     * Syncs all bindings to the context. All binding management happens here.\n     */\n    private fun syncBindingsToContext(context: Context) {\n        val bindings = context.getBindings(\"js\")\n\n        // Set static bindings if not yet set\n        if (!bindings.hasMember(\"http\")) {\n            maestroBinding[\"platform\"] = platform\n            bindings.putMember(\"http\", httpBinding)\n            bindings.putMember(\"faker\", faker)\n            bindings.putMember(\"output\", ProxyObject.fromMap(outputBinding))\n            bindings.putMember(\"maestro\", ProxyObject.fromMap(maestroBinding))\n        }\n\n        // Clear non-permanent (env) bindings, then set current env vars\n        bindings.memberKeys\n            .filter { it !in permanentBindingKeys }\n            .forEach { bindings.removeMember(it) }\n        envBinding.filterKeys { it !in permanentBindingKeys }\n            .forEach { (k, v) -> bindings.putMember(k, v) }\n    }\n\n    val hostAccess = HostAccess.newBuilder()\n        .allowAccessAnnotatedBy(HostAccess.Export::class.java)\n        .allowAllPublicOf(Faker::class.java)\n        .build()\n\n    /**\n     * Returns the shared context, creating it lazily on first access.\n     * This ensures we only ever have one context, avoiding memory bloat.\n     */\n    private fun getOrCreateContext(): Context {\n        sharedContext?.let { return it }\n\n        val outputStream = object : ByteArrayOutputStream() {\n            override fun flush() {\n                super.flush()\n                val log = toByteArray().decodeToString().removeSuffix(\"\\n\")\n                onLogMessage(log)\n                reset()\n            }\n        }\n\n        val context = Context.newBuilder(\"js\")\n            .option(\"js.strict\", \"true\")\n            .logHandler(NULL_HANDLER)\n            .out(outputStream)\n            .allowHostAccess(hostAccess)\n            .build()\n\n        context.eval(\n            \"js\", \"\"\"\n            // Prevent a reference error on referencing undeclared variables. Enables patterns like {MY_ENV_VAR || 'default-value'}.\n            // Instead of throwing an error, undeclared variables will evaluate to undefined.\n            Object.setPrototypeOf(globalThis, new Proxy(Object.prototype, {\n                has(target, key) {\n                    return true;\n                }\n            }))\n            function json(text) {\n                return JSON.parse(text)\n            }\n            function relativePoint(x, y) {\n                var xPercent = Math.ceil(x * 100) + '%'\n                var yPercent = Math.ceil(y * 100) + '%'\n                return xPercent + ',' + yPercent\n            }\n        \"\"\".trimIndent()\n        )\n\n        sharedContext = context\n        return context\n    }\n\n    override fun enterEnvScope() {\n        // Create a new environment variable scope for flow isolation.\n        // For GraalJS, we manually manage environment variable scoping by\n        // saving the current environment state to a stack before allowing\n        // new variables to be added or existing ones to be overridden.\n        envScopeStack.add(HashMap(envBinding))\n    }\n\n    override fun leaveEnvScope() {\n        // Restore previous environment state\n        if (envScopeStack.isNotEmpty()) {\n            val previousEnv = envScopeStack.removeAt(envScopeStack.size - 1)\n            envBinding.clear()\n            envBinding.putAll(previousEnv)\n        }\n    }\n\n    private fun HostAccess.Builder.allowAllPublicOf(clazz: Class<*>): HostAccess.Builder {\n        if (clazz in fakerPublicClasses) return this\n        fakerPublicClasses.add(clazz)\n        clazz.methods.filter {\n            it.declaringClass != Object::class.java &&\n                    it.declaringClass != AbstractProvider::class.java &&\n                    java.lang.reflect.Modifier.isPublic(it.modifiers)\n        }.forEach { method ->\n            allowAccess(method)\n            if (AbstractProvider::class.java.isAssignableFrom(method.returnType) && !fakerPublicClasses.contains(method.returnType)) {\n                allowAllPublicOf(method.returnType)\n            }\n        }\n        return this\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/js/GraalJsHttp.kt",
    "content": "package maestro.js\n\nimport maestro.utils.HttpUtils.toMultipartBody\nimport okhttp3.Headers\nimport okhttp3.OkHttpClient\nimport okhttp3.Request\nimport okhttp3.RequestBody.Companion.toRequestBody\nimport org.graalvm.polyglot.HostAccess.Export\nimport org.graalvm.polyglot.proxy.ProxyObject\n\nclass GraalJsHttp(\n    private val httpClient: OkHttpClient\n) {\n    @Volatile\n    private var currentScriptDir: java.io.File? = null\n\n    fun setCurrentScriptDir(scriptPath: String?) {\n      currentScriptDir = scriptPath?.let { java.io.File(it).parentFile }\n    }\n\n    @JvmOverloads\n    @Export\n    fun get(\n        url: String,\n        params: Map<String, Any>? = null,\n    ): Any {\n        return executeRequest(url, \"GET\", params)\n    }\n\n    @JvmOverloads\n    @Export\n    fun post(\n        url: String,\n        params: Map<String, Any>? = null,\n    ): Any {\n        return executeRequest(url, \"POST\", params)\n    }\n\n    @JvmOverloads\n    @Export\n    fun put(\n        url: String,\n        params: Map<String, Any>? = null,\n    ): Any {\n        return executeRequest(url, \"PUT\", params)\n    }\n\n    @JvmOverloads\n    @Export\n    fun delete(\n        url: String,\n        params: Map<String, Any>? = null,\n    ): Any {\n        return executeRequest(url, \"DELETE\", params)\n    }\n\n    @JvmOverloads\n    @Export\n    fun request(\n        url: String,\n        params: Map<String, Any>? = null,\n    ): Any {\n        val method = params?.get(\"method\") as? String ?: \"GET\"\n        return executeRequest(\n            url,\n            method,\n            params,\n        )\n    }\n\n    private fun executeRequest(\n        url: String,\n        method: String,\n        params: Map<String, Any>?,\n    ): Any {\n        val requestBuilder = Request.Builder()\n            .url(url)\n\n        val body = params?.get(\"body\") as? String\n        val multipartForm = params?.get(\"multipartForm\") as? Map<*, *>\n\n        if (multipartForm == null) {\n            requestBuilder.method(method, body?.toRequestBody())\n        } else {\n            requestBuilder.method(method, multipartForm.toMultipartBody(currentScriptDir))\n        }\n\n        val headers: Map<*, *> = params?.get(\"headers\") as? Map<*, *> ?: emptyMap<Any, Any>()\n\n        headers.forEach { (key, value) ->\n            requestBuilder.addHeader(key.toString(), value.toString())\n        }\n\n        val request = requestBuilder.build()\n\n        val response = httpClient\n            .newCall(request)\n            .execute()\n\n        return ProxyObject.fromMap(mapOf(\n            \"ok\" to response.isSuccessful,\n            \"status\" to response.code,\n            \"body\" to response.body?.string(),\n            \"headers\" to convertHeaders(response.headers)\n        ))\n    }\n\n    private fun convertHeaders(headers: Headers): ProxyObject {\n        val headersMap = headers.toMultimap().mapValues { (_, values) ->\n            values.joinToString(\",\")\n        }\n        return ProxyObject.fromMap(headersMap)\n    }\n\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/js/Js.kt",
    "content": "package maestro.js\n\nobject Js {\n\n    val initScript = \"\"\"\n        function json(text) {\n            return JSON.parse(text)\n        }\n        \n        function relativePoint(x, y) {\n            var xPercent = Math.ceil(x * 100) + '%'\n            var yPercent = Math.ceil(y * 100) + '%'\n            \n            return xPercent + ',' + yPercent\n        }\n        \n        const output = {}\n        const maestro = {\n            copiedText: '',\n            platform: 'unknown'\n        }\n    \"\"\".trimIndent()\n\n    fun initScriptWithPlatform(platform: String): String {\n        return initScript.replace(\"platform: 'unknown'\", \"platform: '$platform'\")\n    }\n\n    fun sanitizeJs(text: String): String {\n        return text\n            .replace(\"\\n\", \"\")\n            .replace(\"'\", \"\\\\'\")\n    }\n\n}"
  },
  {
    "path": "maestro-client/src/main/java/maestro/js/JsConsole.kt",
    "content": "package maestro.js\n\nimport org.mozilla.javascript.ScriptableObject\n\nclass JsConsole(\n    private val onLogMessage: (String) -> Unit,\n) : ScriptableObject() {\n\n    fun log(message: String) {\n        onLogMessage(message)\n    }\n\n    override fun getClassName(): String {\n        return \"JsConsole\"\n    }\n}"
  },
  {
    "path": "maestro-client/src/main/java/maestro/js/JsEngine.kt",
    "content": "package maestro.js\n\ninterface JsEngine : AutoCloseable {\n    fun onLogMessage(callback: (String) -> Unit)\n    fun enterScope()\n    fun leaveScope()\n    fun putEnv(key: String, value: String)\n    fun setCopiedText(text: String?)\n    fun evaluateScript(\n        script: String,\n        env: Map<String, String> = emptyMap(),\n        sourceName: String = \"inline-script\",\n        runInSubScope: Boolean = false,\n    ): Any?\n    \n    fun enterEnvScope()\n    fun leaveEnvScope()\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/js/JsHttp.kt",
    "content": "package maestro.js\n\nimport maestro.utils.HttpUtils.toMultipartBody\nimport okhttp3.Headers\nimport okhttp3.OkHttpClient\nimport okhttp3.Request\nimport okhttp3.RequestBody.Companion.toRequestBody\nimport org.mozilla.javascript.NativeObject\nimport org.mozilla.javascript.ScriptableObject\nimport org.mozilla.javascript.Undefined\n\nclass JsHttp(\n    private val httpClient: OkHttpClient\n) : ScriptableObject() {\n    @Volatile\n    private var currentScriptDir: java.io.File? = null\n\n    fun setCurrentScriptDir(scriptPath: String?) {\n      currentScriptDir = scriptPath?.let { java.io.File(it).parentFile }\n    }\n\n    fun get(\n        url: String,\n        params: NativeObject?,\n    ): Any {\n        return executeRequest(url, \"GET\", params)\n    }\n\n    fun post(\n        url: String,\n        params: NativeObject?,\n    ): Any {\n        return executeRequest(url, \"POST\", params)\n    }\n\n    fun put(\n        url: String,\n        params: NativeObject?,\n    ): Any {\n        return executeRequest(url, \"PUT\", params)\n    }\n\n    fun delete(\n        url: String,\n        params: NativeObject?,\n    ): Any {\n        return executeRequest(url, \"DELETE\", params)\n    }\n\n    fun request(\n        url: String,\n        params: NativeObject?,\n    ): Any {\n        val method = params\n            ?.get(\"method\")\n            ?.let {\n                if (Undefined.isUndefined(it)) {\n                    null\n                } else {\n                    it.toString()\n                }\n            }\n            ?: \"GET\"\n\n        return executeRequest(\n            url,\n            method,\n            params,\n        )\n    }\n\n    private fun executeRequest(\n        url: String,\n        method: String,\n        params: NativeObject?,\n    ): Any {\n        val requestBuilder = Request.Builder()\n            .url(url)\n\n        val body = params\n            ?.get(\"body\")\n            ?.let {\n                if (Undefined.isUndefined(it)) {\n                    null\n                } else {\n                    it.toString()\n                }\n            }\n        val multipartForm = params?.get(\"multipartForm\") as? Map<*, *>\n\n        if (multipartForm == null) {\n            requestBuilder.method(method, body?.toRequestBody())\n        } else {\n            requestBuilder.method(method, multipartForm.toMultipartBody(currentScriptDir))\n        }\n\n        params\n            ?.get(\"headers\")\n            ?.let {\n                if (Undefined.isUndefined(it)) {\n                    null\n                } else {\n                    it as NativeObject\n                }\n            }\n            ?.let {\n                it.entries\n                    .forEach { (key, value) ->\n                        requestBuilder.addHeader(key.toString(), value.toString())\n                    }\n            }\n\n        val request = requestBuilder.build()\n\n        val response = httpClient\n            .newCall(request)\n            .execute()\n\n        val resultBuilder = JsObjectBuilder()\n        resultBuilder[\"ok\"] = response.isSuccessful\n        resultBuilder[\"status\"] = response.code\n        resultBuilder[\"body\"] = response.body?.string()\n        resultBuilder[\"headers\"] = convertHeaders(response.headers)\n\n        return resultBuilder.build()\n    }\n\n    private fun convertHeaders(headers: Headers): NativeObject {\n        val headersMap = headers.toMultimap().mapValues { (_, values) -> values.joinToString(\",\") }\n        val headersNativeObject = NativeObject()\n        headersMap.forEach { (key, value) -> headersNativeObject.put(key, headersNativeObject, value) }\n        return headersNativeObject\n    }\n\n    private class JsObjectBuilder {\n\n        private val obj = NativeObject()\n\n        operator fun set(key: String, value: Any?) {\n            if (value == null) {\n                return\n            }\n\n            obj.defineProperty(key, value, PERMANENT)\n        }\n\n        fun build(): NativeObject {\n            return obj\n        }\n\n    }\n\n    override fun getClassName(): String {\n        return \"JsHttp\"\n    }\n\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/js/JsScope.kt",
    "content": "package maestro.js\n\nimport org.mozilla.javascript.Scriptable\nimport org.mozilla.javascript.ScriptableObject\n\nclass JsScope(\n    private val root: Boolean,\n) : ScriptableObject() {\n\n    override fun get(name: String?, start: Scriptable?): Any? {\n        if (!root) {\n            return super.get(name, start)\n        }\n\n        val original = super.get(name, start)\n\n        if (original == NOT_FOUND) {\n            return null\n        }\n\n        return original\n    }\n\n    override fun get(index: Int, start: Scriptable?): Any? {\n        if (!root) {\n            return super.get(index, start)\n        }\n\n        val original = super.get(index, start)\n\n        if (original == NOT_FOUND) {\n            return null\n        }\n\n        return original\n    }\n\n    override fun get(key: Any?): Any? {\n        if (!root) {\n            return super.get(key)\n        }\n\n        val original = super.get(key)\n\n        if (original == NOT_FOUND) {\n            return null\n        }\n\n        return original\n    }\n\n    override fun getClassName(): String {\n        return \"JsScope\"\n    }\n\n}"
  },
  {
    "path": "maestro-client/src/main/java/maestro/js/RhinoJsEngine.kt",
    "content": "package maestro.js\n\nimport maestro.utils.HttpClient\nimport okhttp3.OkHttpClient\nimport okhttp3.Protocol\nimport org.mozilla.javascript.Context\nimport org.mozilla.javascript.ScriptableObject\nimport kotlin.time.Duration.Companion.minutes\n\nclass RhinoJsEngine(\n    httpClient: OkHttpClient = HttpClient.build(\n        name=\"RhinoJsEngine\",\n        readTimeout=5.minutes,\n        writeTimeout=5.minutes,\n        callTimeout = 5.minutes,\n        protocols=listOf(Protocol.HTTP_1_1)\n    ),\n    platform: String = \"unknown\"\n) : JsEngine {\n\n    private val context = Context.enter()\n\n    private var currentScope = JsScope(root = true)\n    private var onLogMessage: (String) -> Unit = {}\n    private val jsHttp = JsHttp(httpClient)\n\n    init {\n        context.initSafeStandardObjects(currentScope)\n\n        jsHttp.defineFunctionProperties(\n            arrayOf(\"request\", \"get\", \"post\", \"put\", \"delete\"),\n            JsHttp::class.java,\n            ScriptableObject.DONTENUM\n        )\n        context.initSafeStandardObjects(jsHttp)\n        currentScope.put(\"http\", currentScope, jsHttp)\n\n        val jsConsole = JsConsole(\n            onLogMessage = { onLogMessage(it) }\n        )\n        jsConsole.defineFunctionProperties(\n            arrayOf(\"log\"),\n            JsConsole::class.java,\n            ScriptableObject.DONTENUM\n        )\n        context.initSafeStandardObjects(jsConsole)\n        currentScope.put(\"console\", currentScope, jsConsole)\n\n        context.evaluateString(\n            currentScope,\n            Js.initScriptWithPlatform(platform),\n            \"maestro-runtime\",\n            1,\n            null\n        )\n        currentScope.sealObject()\n\n        // We are entering a sub-scope so that no more declarations can be made\n        // on the root scope that is now sealed.\n        enterScope()\n    }\n\n    override fun close() {\n        context.close()\n    }\n\n    override fun onLogMessage(callback: (String) -> Unit) {\n        onLogMessage = callback\n    }\n\n    override fun enterScope() {\n        val subScope = JsScope(root = false)\n        subScope.parentScope = currentScope\n        currentScope = subScope\n    }\n\n    override fun leaveScope() {\n        currentScope = currentScope.parentScope as JsScope\n    }\n\n    override fun setCopiedText(text: String?) {\n        evaluateScript(\"maestro.copiedText = '${Js.sanitizeJs(text ?: \"\")}'\")\n    }\n\n    override fun putEnv(key: String, value: String) {\n        val cleanValue = Js.sanitizeJs(value)\n        evaluateScript(\"var $key = '$cleanValue'\")\n    }\n\n    override fun evaluateScript(\n        script: String,\n        env: Map<String, String>,\n        sourceName: String,\n        runInSubScope: Boolean,\n    ): Any? {\n        // Set current script directory for resolving relative file paths\n        jsHttp.setCurrentScriptDir(if (sourceName != \"inline-script\") sourceName else null)\n\n        val scope = if (runInSubScope) {\n            // We create a new scope for each evaluation to prevent local variables\n            // from clashing with each other across multiple scripts.\n            // Only 'output' is shared across scopes.\n            JsScope(root = false)\n                .apply { parentScope = currentScope }\n        } else {\n            currentScope\n        }\n\n        if (env.isNotEmpty()) {\n            env.forEach { (key, value) ->\n                val wrappedValue = Context.javaToJS(value, scope)\n                ScriptableObject.putProperty(scope, key, wrappedValue)\n            }\n        }\n\n        return context.evaluateString(\n            scope,\n            script,\n            sourceName,\n            1,\n            null\n        )\n    }\n\n    override fun enterEnvScope() {\n        // Create a new environment variable scope for flow isolation.\n        // For RhinoJS, we can use the existing JavaScript scope mechanism\n        // which automatically handles variable isolation and cleanup.\n        enterScope()\n    }\n\n    override fun leaveEnvScope() {\n        // For RhinoJS, we can use the existing scope mechanism\n        leaveScope()\n    }\n\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/mockserver/MockInteractor.kt",
    "content": "package maestro.mockserver\n\nimport com.fasterxml.jackson.databind.DeserializationFeature\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport maestro.auth.ApiKey\nimport maestro.utils.HttpClient\nimport okhttp3.Protocol\nimport okhttp3.Request\nimport java.nio.file.Paths\nimport java.util.*\nimport kotlin.time.Duration.Companion.minutes\n\ndata class Auth(\n    val teamId: UUID,\n    val email: String?,\n    val id: UUID?,\n    val isMachine: Boolean,\n    val role: String?\n)\n\ndata class MockEvent(\n    val timestamp: String,\n    val path: String,\n    val matched: Boolean,\n    val response: Any,\n    val statusCode: Int,\n    val sessionId: UUID,\n    val projectId: UUID,\n    val method: String,\n    val bodyAsString: String? = null,\n    val headers: Map<String, String>? = null,\n)\n\ndata class GetEventsResponse(\n    val events: List<MockEvent>\n)\n\nclass MockInteractor {\n    private val client = HttpClient.build(\n        name = \"MockInteractor\",\n        readTimeout = 5.minutes,\n        writeTimeout = 5.minutes,\n        protocols = listOf(Protocol.HTTP_1_1)\n    )\n\n    fun getProjectId(): UUID? {\n        val authToken = ApiKey.getToken()\n\n        val request = try {\n            Request.Builder()\n                .get()\n                .header(\"Authorization\", \"Bearer $authToken\")\n                .url(\"$API_URL/auth\")\n                .build()\n        } catch (e: IllegalArgumentException) {\n            if (e.message?.contains(\"Unexpected char\") == true) {\n                return null\n            } else {\n                throw e\n            }\n        }\n        val response = client.newCall(request).execute()\n\n        if (response.code >= 400) error(\"Invalid token. Please run `maestro logout` and then `maestro login` to retrieve a valid token.\")\n\n        response.use {\n            try {\n                val auth = JSON.readValue(response.body?.bytes(), Auth::class.java)\n                return auth.teamId\n            } catch (e: Exception) {\n                error(\"Could not retrieve project id: ${e.message}\")\n            }\n        }\n        return null\n    }\n\n    fun getMockEvents(): List<MockEvent> {\n        val authToken = ApiKey.getToken()\n\n        val request = try {\n            Request.Builder()\n                .get()\n                .header(\"Authorization\", \"Bearer $authToken\")\n                .url(\"$API_URL/mms-events\")\n                .build()\n        } catch (e: Exception) {\n            throw e\n        }\n        val response = client.newCall(request).execute()\n\n        response.use {\n            try {\n                val response = JSON.readValue(response.body?.bytes(), GetEventsResponse::class.java)\n                return response.events\n            } catch (e: Exception) {\n                error(\"Could not retrieve mock events: ${e.message}\")\n            }\n        }\n        return emptyList()\n    }\n\n    companion object {\n        private val API_URL by lazy {\n            if (System.getProperty(\"MAESTRO_CLOUD_API_URL\").isNullOrEmpty()) {\n                \"https://api.copilot.mobile.dev\"\n            } else {\n                System.getProperty(\"MAESTRO_CLOUD_API_URL\")\n            }\n        }\n\n        private val cachedAuthTokenFile by lazy {\n            Paths.get(\n                System.getProperty(\"user.home\"),\n                \".mobiledev\",\n                \"authtoken\"\n            )\n        }\n\n        private val JSON = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)\n\n    }\n}\n\nfun main() {\n    MockInteractor().getMockEvents()\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/utils/BlockingStreamObserver.kt",
    "content": "package maestro.utils\n\nimport io.grpc.stub.StreamObserver\nimport java.util.concurrent.Semaphore\nimport java.util.concurrent.TimeUnit\nimport java.util.concurrent.TimeoutException\n\nclass BlockingStreamObserver<T> : StreamObserver<T> {\n\n    private val semaphore = Semaphore(0)\n\n    private var result: T? = null\n    private var error: Throwable? = null\n\n    override fun onNext(value: T) {\n        result = value\n    }\n\n    override fun onError(t: Throwable) {\n        error = t\n        semaphore.release()\n    }\n\n    override fun onCompleted() {\n        semaphore.release()\n    }\n\n    fun awaitResult(): T {\n        if (!semaphore.tryAcquire(10, TimeUnit.MINUTES)) {\n            throw TimeoutException(\"Timeout waiting for Stream to pass Error or Completed message\")\n        }\n\n        error?.let { throw it }\n\n        return result ?: throw IllegalStateException(\"Result is missing\")\n    }\n}"
  },
  {
    "path": "maestro-client/src/main/java/maestro/utils/FileUtils.kt",
    "content": "package maestro.utils\n\nimport java.io.File\nimport java.io.IOException\nimport java.nio.file.Files\nimport java.nio.file.Path\nimport java.util.zip.ZipEntry\nimport java.util.zip.ZipFile\nimport java.util.zip.ZipOutputStream\nimport kotlin.io.path.absolutePathString\nimport kotlin.io.path.createDirectories\nimport kotlin.io.path.exists\nimport kotlin.io.path.isDirectory\nimport kotlin.streams.toList\n\nobject FileUtils {\n\n    /**\n     * Zips directory\n     *\n     * @param from dir to zip\n     * @param to output zip file\n     */\n    fun zipDir(from: Path, to: Path) {\n        val stream = to.toFile().outputStream()\n        val files = Files.walk(from).filter { !it.isDirectory() }.toList()\n        ZipOutputStream(stream).use { zs ->\n            try {\n                files.forEach {\n                    val relativePath = from.relativize(it).toString()\n                    val entry = ZipEntry(relativePath)\n                    zs.putNextEntry(entry)\n                    Files.copy(it, zs)\n                    zs.closeEntry()\n                }\n            } catch (e: IOException) {\n                e.printStackTrace()\n            }\n        }\n    }\n\n    /**\n     * Unzips file\n     *\n     * @param from zip file\n     * @param to target dir\n     */\n    fun unzip(from: Path, to: Path) {\n        if (!to.exists()) to.createDirectories()\n        ZipFile(from.absolutePathString()).use { zip ->\n            zip.entries().asSequence().forEach { entry ->\n                zip.getInputStream(entry).use { input ->\n                    val filePath = to.resolve(entry.name)\n                    if (!entry.isDirectory) {\n                        filePath.parent.createDirectories()\n                        Files.copy(input, filePath)\n                    } else filePath.createDirectories()\n                }\n            }\n        }\n    }\n\n    /**\n     * Deletes a directory and all it's contents.\n     *\n     * WARNING Use with caution!\n     **/\n    fun deleteDir(dir: Path) {\n        Files.walk(dir)\n            .map(Path::toFile)\n            .sorted(Comparator.reverseOrder())\n            .forEach(File::delete)\n    }\n}"
  },
  {
    "path": "maestro-client/src/main/java/maestro/utils/HttpUtils.kt",
    "content": "package maestro.utils\n\nimport okhttp3.MediaType.Companion.toMediaTypeOrNull\nimport okhttp3.MultipartBody\nimport okhttp3.RequestBody.Companion.asRequestBody\nimport java.io.File\n\nobject HttpUtils {\n\n    fun Map<*, *>.toMultipartBody(scriptDir: File? = null): MultipartBody {\n        return MultipartBody.Builder()\n            .setType(MultipartBody.FORM)\n            .addAllFormDataParts(this, scriptDir)\n            .build()\n    }\n\n    private fun <T : Map<*, *>> MultipartBody.Builder.addAllFormDataParts(multipartForm: T?, scriptDir: File?): MultipartBody.Builder {\n        multipartForm?.forEach { (key, value) ->\n            val filePath = (value as? Map<*, *> ?: emptyMap<Any, Any>())[\"filePath\"]\n            if (filePath != null) {\n                val file = resolveFilePath(filePath.toString(), scriptDir)\n                val mediaType = (value as? Map<*, *> ?: emptyMap<Any, Any>())[\"mediaType\"].toString()\n                this.addFormDataPart(key.toString(), file.name, file.asRequestBody(mediaType.toMediaTypeOrNull()))\n            } else {\n                this.addFormDataPart(key.toString(), value.toString())\n            }\n        }\n        return this\n    }\n\n    private fun resolveFilePath(filePath: String, scriptDir: File?): File {\n        val file = File(filePath)\n\n        // If the file path is absolute and exists, use it directly\n        if (file.isAbsolute && file.exists()) {\n            return file\n        }\n\n        // If we have a workspace root, try to resolve relative to it\n        if (scriptDir != null) {\n            val resolvedFile = File(scriptDir, filePath)\n            if (resolvedFile.exists()) {\n                return resolvedFile\n            }\n        }\n\n        // Fall back to the original behavior (current working directory)\n        return file\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/utils/LocaleUtils.kt",
    "content": "package maestro.utils\n\nimport maestro.device.Platform\nimport kotlin.DeprecationLevel\n\nopen class LocaleValidationException(message: String): Exception(message)\n\n@Deprecated(\n  message = \"LocaleValidationIosException is deprecated and will be removed in a future version. Please migrate to DeviceLocale (maestro.locale.LocaleValidationException) for locale handling.\",\n  level = DeprecationLevel.WARNING\n)\nclass LocaleValidationIosException : LocaleValidationException(\"Failed to validate iOS device locale\")\n\n@Deprecated(\n  message = \"LocaleValidationAndroidLanguageException is deprecated and will be removed in a future version. Please migrate to DeviceLocale (maestro.locale.LocaleValidationException) for locale handling.\",\n  level = DeprecationLevel.WARNING\n)\nclass LocaleValidationAndroidLanguageException(val language: String) : LocaleValidationException(\"Failed to validate Android device language\")\n\n@Deprecated(\n  message = \"LocaleValidationAndroidCountryException is deprecated and will be removed in a future version. Please migrate to DeviceLocale (maestro.locale.LocaleValidationException) for locale handling.\",\n  level = DeprecationLevel.WARNING\n)\nclass LocaleValidationAndroidCountryException(val country: String) : LocaleValidationException(\"Failed to validate Android device country\")\n\n@Deprecated(\n  message = \"LocaleValidationNotSupportedPlatformException is deprecated and will be removed in a future version. Please migrate to DeviceLocale (maestro.locale.LocaleValidationException) for locale handling.\",\n  level = DeprecationLevel.WARNING\n)\nclass LocaleValidationNotSupportedPlatformException : LocaleValidationException(\"Failed to validate device locale - not supported platform provided\")\n\n@Deprecated(\n  message = \"LocaleValidationWrongLocaleFormatException is deprecated and will be removed in a future version. Please migrate to DeviceLocale (maestro.locale.LocaleValidationException) for locale handling.\",\n  level = DeprecationLevel.WARNING\n)\nclass LocaleValidationWrongLocaleFormatException : LocaleValidationException(\"Failed to validate device locale - wrong locale format is used\")\n\n@Deprecated(\n    message = \"LocaleUtils is deprecated and will be removed in a future version. Please migrate to DeviceLocale (maestro.locale.DeviceLocale) for locale handling.\",\n    level = DeprecationLevel.WARNING\n)\nobject LocaleUtils {\n    val ANDROID_SUPPORTED_LANGUAGES = listOf(\n        \"ar\" to \"Arabic\",\n        \"bg\" to \"Bulgarian\",\n        \"ca\" to \"Catalan\",\n        \"zh\" to \"Chinese\",\n        \"hr\" to \"Croatian\",\n        \"cs\" to \"Czech\",\n        \"da\" to \"Danish\",\n        \"nl\" to \"Dutch\",\n        \"en\" to \"English\",\n        \"fi\" to \"Finnish\",\n        \"fr\" to \"French\",\n        \"de\" to \"German\",\n        \"el\" to \"Greek\",\n        \"he\" to \"Hebrew\",\n        \"hi\" to \"Hindi\",\n        \"hu\" to \"Hungarian\",\n        \"id\" to \"Indonesian\",\n        \"it\" to \"Italian\",\n        \"ja\" to \"Japanese\",\n        \"ko\" to \"Korean\",\n        \"lv\" to \"Latvian\",\n        \"lt\" to \"Lithuanian\",\n        \"nb\" to \"Norwegian-Bokmol\",\n        \"pl\" to \"Polish\",\n        \"pt\" to \"Portuguese\",\n        \"ro\" to \"Romanian\",\n        \"ru\" to \"Russian\",\n        \"sr\" to \"Serbian\",\n        \"sk\" to \"Slovak\",\n        \"sl\" to \"Slovenian\",\n        \"es\" to \"Spanish\",\n        \"sv\" to \"Swedish\",\n        \"tl\" to \"Tagalog\",\n        \"th\" to \"Thai\",\n        \"tr\" to \"Turkish\",\n        \"uk\" to \"Ukrainian\",\n        \"vi\" to \"Vietnamese\"\n    )\n\n    val ANDROID_SUPPORTED_COUNTRIES = listOf(\n        \"AU\" to \"Australia\",\n        \"AT\" to \"Austria\",\n        \"BE\" to \"Belgium\",\n        \"BR\" to \"Brazil\",\n        \"GB\" to \"Britain\",\n        \"BG\" to \"Bulgaria\",\n        \"CA\" to \"Canada\",\n        \"HR\" to \"Croatia\",\n        \"CZ\" to \"Czech Republic\",\n        \"DK\" to \"Denmark\",\n        \"EG\" to \"Egypt\",\n        \"FI\" to \"Finland\",\n        \"FR\" to \"France\",\n        \"DE\" to \"Germany\",\n        \"GR\" to \"Greece\",\n        \"HK\" to \"Hong-Kong\",\n        \"HU\" to \"Hungary\",\n        \"IN\" to \"India\",\n        \"ID\" to \"Indonesia\",\n        \"IE\" to \"Ireland\",\n        \"IL\" to \"Israel\",\n        \"IT\" to \"Italy\",\n        \"JP\" to \"Japan\",\n        \"KR\" to \"Korea\",\n        \"LV\" to \"Latvia\",\n        \"LI\" to \"Liechtenstein\",\n        \"LT\" to \"Lithuania\",\n        \"ES\" to \"Mexico\",\n        \"NL\" to \"Netherlands\",\n        \"NZ\" to \"New Zealand\",\n        \"NO\" to \"Norway\",\n        \"PH\" to \"Philippines\",\n        \"PL\" to \"Poland\",\n        \"PT\" to \"Portugal\",\n        \"CN\" to \"PRC\",\n        \"RO\" to \"Romania\",\n        \"RU\" to \"Russia\",\n        \"RS\" to \"Serbia\",\n        \"SG\" to \"Singapore\",\n        \"SK\" to \"Slovakia\",\n        \"SI\" to \"Slovenia\",\n        \"ES\" to \"Spain\",\n        \"SE\" to \"Sweden\",\n        \"CH\" to \"Switzerland\",\n        \"TW\" to \"Taiwan\",\n        \"TH\" to \"Thailand\",\n        \"TR\" to \"Turkey\",\n        \"UA\" to \"Ukraine\",\n        \"US\" to \"US\",\n        \"US\" to \"USA\",\n        \"VN\" to \"Vietnam\",\n        \"ZA\" to \"Zimbabwe\"\n    )\n\n    val IOS_SUPPORTED_LOCALES = listOf(\n        \"en_AU\" to \"Australia\",\n        \"nl_BE\" to \"Belgium (Dutch)\",\n        \"fr_BE\" to \"Belgium (French)\",\n        \"ms_BN\" to \"Brunei Darussalam\",\n        \"en_CA\" to \"Canada (English)\",\n        \"fr_CA\" to \"Canada (French)\",\n        \"cs_CZ\" to \"Czech Republic\",\n        \"fi_FI\" to \"Finland\",\n        \"de_DE\" to \"Germany\",\n        \"el_GR\" to \"Greece\",\n        \"hu_HU\" to \"Hungary\",\n        \"hi_IN\" to \"India\",\n        \"id_ID\" to \"Indonesia\",\n        \"he_IL\" to \"Israel\",\n        \"it_IT\" to \"Italy\",\n        \"ja_JP\" to \"Japan\",\n        \"ms_MY\" to \"Malaysia\",\n        \"nl_NL\" to \"Netherlands\",\n        \"en_NZ\" to \"New Zealand\",\n        \"nb_NO\" to \"Norway\",\n        \"tl_PH\" to \"Philippines\",\n        \"pl_PL\" to \"Poland\",\n        \"zh_CN\" to \"PRC\",\n        \"ro_RO\" to \"Romania\",\n        \"ru_RU\" to \"Russia\",\n        \"en_SG\" to \"Singapore\",\n        \"sk_SK\" to \"Slovakia\",\n        \"ko_KR\" to \"Korea\",\n        \"sv_SE\" to \"Sweden\",\n        \"zh_TW\" to \"Taiwan\",\n        \"th_TH\" to \"Thailand\",\n        \"tr_TR\" to \"Turkey\",\n        \"en_GB\" to \"UK\",\n        \"uk_UA\" to \"Ukraine\",\n        \"es_US\" to \"USA (Spanish)\",\n        \"en_US\" to \"USA (English)\",\n        \"vi_VN\" to \"Vietnam\",\n        \"pt-BR\" to \"Brazil\",\n        \"zh-Hans\" to \"China (Simplified)\",\n        \"zh-Hant\" to \"China (Traditional)\",\n        \"zh-HK\" to \"Hong Kong\",\n        \"en-IN\" to \"India (English)\",\n        \"en-IE\" to \"Ireland\",\n        \"es-419\" to \"Latin America\",\n        \"es-MX\" to \"Mexico\",\n        \"en-ZA\" to \"South Africa\",\n        \"es_ES\" to \"Spain\",\n        \"fr_FR\" to \"France\",\n    )\n\n    fun findIOSLocale(language: String, country: String): String? {\n        val searchedPair = \"$language[_-]$country\".toRegex()\n\n        for (pair in IOS_SUPPORTED_LOCALES) {\n            if (searchedPair.matches(pair.first)) {\n                return pair.first\n            }\n        }\n\n        return null\n    }\n\n    fun parseLocaleParams(deviceLocale: String, platform: Platform): Pair<String, String> {\n        var parts = deviceLocale.split(\"_\")\n\n        if (parts.size == 2) {\n            val language = parts[0]\n            val country = parts[1]\n\n            validateLocale(language, country, platform)\n\n            return Pair(language, country)\n        } else {\n            parts = deviceLocale.split(\"-\")\n\n            if (parts.size == 2) {\n                val language = parts[0]\n                val country = parts[1]\n\n                validateLocale(language, country, platform)\n\n                return Pair(language, country)\n            }\n\n            throw LocaleValidationWrongLocaleFormatException()\n        }\n    }\n\n    private fun validateLocale(language: String, country: String, platform: Platform) {\n        when (platform) {\n            Platform.IOS -> {\n                if (findIOSLocale(language, country) == null) {\n                    throw LocaleValidationIosException()\n                }\n            }\n            Platform.ANDROID -> {\n                if (!ANDROID_SUPPORTED_LANGUAGES.map { it.first }.contains(language)) {\n                    throw LocaleValidationAndroidLanguageException(language)\n                }\n\n                if (!ANDROID_SUPPORTED_COUNTRIES.map { it.first }.contains(country)) {\n                    throw LocaleValidationAndroidCountryException(country)\n                }\n            }\n            else -> throw LocaleValidationNotSupportedPlatformException()\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/utils/ScreenshotUtils.kt",
    "content": "package maestro.utils\n\nimport com.github.romankh3.image.comparison.ImageComparison\nimport maestro.Driver\nimport maestro.ViewHierarchy\nimport okio.Buffer\nimport okio.Sink\nimport org.slf4j.LoggerFactory\nimport java.awt.image.BufferedImage\nimport javax.imageio.ImageIO\n\nclass ScreenshotUtils {\n    companion object {\n        private val LOGGER = LoggerFactory.getLogger(ScreenshotUtils::class.java)\n\n        fun takeScreenshot(out: Sink, compressed: Boolean, driver: Driver) {\n            LOGGER.trace(\"Taking screenshot to output sink\")\n\n            driver.takeScreenshot(out, compressed)\n        }\n\n        fun takeScreenshot(compressed: Boolean, driver: Driver): ByteArray {\n            LOGGER.trace(\"Taking screenshot to byte array\")\n\n            val buffer = Buffer()\n            takeScreenshot(buffer, compressed, driver)\n\n            return buffer.readByteArray()\n        }\n\n        fun tryTakingScreenshot(driver: Driver) = try {\n            ImageIO.read(takeScreenshot(true, driver).inputStream())\n        } catch (e: Exception) {\n            LOGGER.warn(\"Failed to take screenshot\", e)\n            null\n        }\n\n        fun waitForAppToSettle(\n            initialHierarchy: ViewHierarchy?,\n            driver: Driver,\n            timeoutMs: Int? = null\n        ): ViewHierarchy {\n            var latestHierarchy: ViewHierarchy\n            if (timeoutMs != null) {\n                val endTime = System.currentTimeMillis() + timeoutMs\n                latestHierarchy = initialHierarchy ?: viewHierarchy(driver)\n                do {\n                    val hierarchyAfter = viewHierarchy(driver)\n                    if (latestHierarchy == hierarchyAfter) {\n                        val isLoading = latestHierarchy.root.attributes.getOrDefault(\"is-loading\", \"false\").toBoolean()\n                        if (!isLoading) {\n                            return hierarchyAfter\n                        }\n                    }\n                    latestHierarchy = hierarchyAfter\n                } while (System.currentTimeMillis() < endTime)\n            } else {\n                latestHierarchy = initialHierarchy ?: viewHierarchy(driver)\n                repeat(10) {\n                    val hierarchyAfter = viewHierarchy(driver)\n                    if (latestHierarchy == hierarchyAfter) {\n                        val isLoading = latestHierarchy.root.attributes.getOrDefault(\"is-loading\", \"false\").toBoolean()\n                        if (!isLoading) {\n                            return hierarchyAfter\n                        }\n                    }\n                    latestHierarchy = hierarchyAfter\n\n                    MaestroTimer.sleep(MaestroTimer.Reason.WAIT_TO_SETTLE, 200)\n                }\n            }\n\n            return latestHierarchy\n        }\n\n        fun waitUntilScreenIsStatic(timeoutMs: Long, threshold: Double, driver: Driver): Boolean {\n            return MaestroTimer.retryUntilTrue(timeoutMs) {\n                val startScreenshot: BufferedImage? = tryTakingScreenshot(driver)\n                val endScreenshot: BufferedImage? = tryTakingScreenshot(driver)\n\n                if (startScreenshot != null &&\n                    endScreenshot != null &&\n                    startScreenshot.width == endScreenshot.width &&\n                    startScreenshot.height == endScreenshot.height\n                ) {\n                    val imageDiff = ImageComparison(\n                        startScreenshot,\n                        endScreenshot\n                    ).compareImages().differencePercent\n\n                    return@retryUntilTrue imageDiff <= threshold\n                }\n\n                return@retryUntilTrue false\n            }\n        }\n\n        private fun viewHierarchy(driver: Driver): ViewHierarchy {\n            return ViewHierarchy.from(driver, false)\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/main/java/maestro/utils/StringUtils.kt",
    "content": "package maestro.utils\n\nobject StringUtils {\n\n    fun String.toRegexSafe(option: RegexOption) = toRegexSafe(setOf(option))\n\n    fun String.toRegexSafe(options: Set<RegexOption> = emptySet()): Regex {\n        return try {\n            toRegex(options)\n        } catch (e: Exception) {\n            Regex.escape(this).toRegex(options)\n        }\n    }\n\n}"
  },
  {
    "path": "maestro-client/src/main/java/maestro/utils/TemporaryDirectory.kt",
    "content": "package maestro.utils\n\nimport java.nio.file.Files\nimport java.nio.file.Path\n\nobject TemporaryDirectory {\n\n    inline fun <T> use(block: (tmpDir: Path) -> T): T {\n        val tmpDir = Files.createTempDirectory(null)\n        return try {\n            block(tmpDir)\n        } finally {\n            tmpDir.toFile().deleteRecursively()\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/main/resources/maestro-web.js",
    "content": "(function ( maestro ) {\n    const INVALID_TAGS = new Set(['noscript', 'script', 'br', 'img', 'svg', 'g', 'path', 'style'])\n\n    const isInvalidTag = (node) => {\n        return INVALID_TAGS.has(node.tagName.toLowerCase())\n    }\n\n    // Synthetic nodes do not truly have a visual representation in the DOM, but they are still visible to the user.\n    const isSynthetic = (node) => {\n        return node.tagName.toLowerCase() === 'option'\n    }\n\n    const getNodeText = (node) => {\n        switch (node.tagName.toLowerCase()) {\n            case 'input':\n                return node.value || node.placeholder || node.ariaLabel || ''\n\n            case 'select':\n                return Array.from(node.selectedOptions).map((option) => option.text).join(', ')\n\n            default:\n                const childNodes = [...(node.childNodes || [])].filter(node => node.nodeType === Node.TEXT_NODE)\n                return childNodes.map(node => node.textContent.replace('\\n', '').replace('\\t', '')).join('')\n        }\n    }\n\n    const getIndexInParent = (node) => {\n        if (!node.parentElement) return -1;\n\n        const siblings = Array.from(node.parentElement.children);\n        return siblings.indexOf(node);\n    }\n\n    const getSyntheticNodeBounds = (node) => {\n        // If the node is synthetic, we return bounds in a special coordinate space that doesn't interfere\n        // with the rest of the DOM. We do this by adding 100000 offset to the x and y coordinates.\n\n        const idx = getIndexInParent(node);\n\n        const width = 100;\n        const height = 20;\n\n        const offset = 100000;\n\n        const x = offset;\n        const y = offset + (idx * height);\n\n        const l = x;\n        const t = y;\n        const r = x + width;\n        const b = y + height;\n\n        return `[${Math.round(l)},${Math.round(t)}][${Math.round(r)},${Math.round(b)}]`\n    }\n\n    const getNodeBounds = (node, iframeOffsetX = 0, iframeOffsetY = 0) => {\n        if (isSynthetic(node)) {\n            return getSyntheticNodeBounds(node);\n        }\n\n        const rect = node.getBoundingClientRect()\n        const vpx = maestro.viewportX;\n        const vpy = maestro.viewportY;\n        const vpw = maestro.viewportWidth || window.innerWidth;\n        const vph = maestro.viewportHeight || window.innerHeight;\n\n        const scaleX = vpw / window.innerWidth;\n        const scaleY = vph / window.innerHeight;\n        const l = (rect.x + iframeOffsetX) * scaleX + vpx;\n        const t = (rect.y + iframeOffsetY) * scaleY + vpy;\n        const r = (rect.x + rect.width + iframeOffsetX) * scaleX + vpx;\n        const b = (rect.y + rect.height + iframeOffsetY) * scaleY + vpy;\n\n        return `[${Math.round(l)},${Math.round(t)}][${Math.round(r)},${Math.round(b)}]`\n    }\n\n    const isDocumentLoading = () => document.readyState !== 'complete'\n\n    const traverse = (node, includeChildren = true, iframeOffsetX = 0, iframeOffsetY = 0) => {\n      if (!node || isInvalidTag(node)) return null\n\n      // Traverse into same-origin iframes; skip cross-origin ones silently\n      if (node.tagName.toLowerCase() === 'iframe') {\n          try {\n              const iframeDoc = node.contentDocument || node.contentWindow?.document;\n              if (iframeDoc && iframeDoc.body) {\n                  const iframeRect = node.getBoundingClientRect();\n                  return traverse(\n                      iframeDoc.body,\n                      true,\n                      iframeOffsetX + iframeRect.x,\n                      iframeOffsetY + iframeRect.y\n                  );\n              }\n          } catch (e) {\n              // Cross-origin — emit a marker so the CDP driver can inject content from the iframe target\n              try {\n                  return {\n                      attributes: {\n                          text: '',\n                          bounds: getNodeBounds(node, iframeOffsetX, iframeOffsetY),\n                          '__crossOriginIframe': node.src,\n                      },\n                      children: []\n                  };\n              } catch (e2) { return null; }\n          }\n          return null;\n      }\n\n      const children = includeChildren\n        ? [...node.children || []].map(child => traverse(child, true, iframeOffsetX, iframeOffsetY)).filter(el => !!el)\n        : []\n\n      const attributes = {\n          text: getNodeText(node),\n          bounds: getNodeBounds(node, iframeOffsetX, iframeOffsetY),\n      }\n\n      // If this is an <option> element, we only want to include it if the parent <select> element is focused.\n      if (node.tagName.toLowerCase() === 'option' && !node.parentElement.matches(':focus-within')) {\n        return null;\n      }\n\n      if (!!node.id || !!node.ariaLabel || !!node.name || !!node.title || !!node.htmlFor || !!node.attributes['data-testid']) {\n        const title = typeof node.title === 'string' ? node.title : null\n        attributes['resource-id'] = node.id || node.ariaLabel || node.name || title || node.htmlFor || node.attributes['data-testid']?.value\n      }\n\n      if (node.tagName.toLowerCase() === 'body') {\n        attributes['is-loading'] = isDocumentLoading()\n      }\n\n      if (node.selected) {\n        attributes['selected'] = true\n      }\n\n      if (isSynthetic(node)) {\n        attributes['synthetic'] = true\n        attributes['ignoreBoundsFiltering'] = true\n      }\n\n      return {\n        attributes,\n        children,\n      }\n    }\n\n    // -------------- Public API --------------\n    maestro.viewportX = 0;\n    maestro.viewportY = 0;\n    maestro.viewportWidth = 0;\n    maestro.viewportHeight = 0;\n\n    maestro.getContentDescription = () => {\n        return traverse(document.body)\n    }\n\n    maestro.queryCss = (selector) => {\n        // Returns a list of matching elements for the given CSS selector.\n        // Does not include children of discovered elements.\n        const elements = document.querySelectorAll(selector);\n\n        return Array.from(elements).map(el => {\n            return traverse(el, false);\n        });\n    }\n\n    maestro.tapOnSyntheticElement = (x, y) => {\n        // This function is used to tap on synthetic elements like <option> that do not have a visual representation.\n        // It will return the bounds of the synthetic element in a special coordinate space.\n\n        const syntheticElements = Array.from(document.querySelectorAll('option'));\n        if (syntheticElements.length === 0) {\n            throw new Error('No synthetic elements found');\n        }\n\n        for (const option of syntheticElements) {\n            const bounds = getSyntheticNodeBounds(option);\n            const [left, top] = bounds.match(/\\d+/g).map(Number);\n            const [right, bottom] = bounds.match(/\\d+/g).slice(2).map(Number);\n\n            if (x >= left && x <= right && y >= top && y <= bottom) {\n                const select = option.parentElement;\n                option.selected = true;\n\n                // Without this, browser will not update the select element's value.\n                select.dispatchEvent(new Event(\"change\", { bubbles: true }));\n\n                // This is needed to hide the <select> dropdown after selection.\n                select.blur();\n\n                return;\n            }\n        }\n    }\n\n    // https://stackoverflow.com/a/5178132\n    maestro.createXPathFromElement = (domElement) => {\n        var allNodes = document.getElementsByTagName('*');\n        for (var segs = []; domElement && domElement.nodeType == 1; domElement = domElement.parentNode)\n        {\n            if (domElement.hasAttribute('id')) {\n                    var uniqueIdCount = 0;\n                    for (var n=0;n < allNodes.length;n++) {\n                        if (allNodes[n].hasAttribute('id') && allNodes[n].id == domElement.id) uniqueIdCount++;\n                        if (uniqueIdCount > 1) break;\n                    }\n                    if ( uniqueIdCount == 1) {\n                        segs.unshift('id(\"' + domElement.getAttribute('id') + '\")');\n                        return segs.join('/');\n                    } else {\n                        segs.unshift(domElement.localName.toLowerCase() + '[@id=\"' + domElement.getAttribute('id') + '\"]');\n                    }\n            } else if (domElement.hasAttribute('class')) {\n                segs.unshift(domElement.localName.toLowerCase() + '[@class=\"' + domElement.getAttribute('class') + '\"]');\n            } else {\n                for (i = 1, sib = domElement.previousSibling; sib; sib = sib.previousSibling) {\n                    if (sib.localName == domElement.localName)  i++; }\n                    segs.unshift(domElement.localName.toLowerCase() + '[' + i + ']');\n            }\n        }\n        return segs.length ? '/' + segs.join('/') : null;\n    }\n\n    // -------------- Cross-origin iframe viewport params --------------\n\n    maestro.getIframeViewportParams = (iframeSrc) => {\n        const iframe = [...document.querySelectorAll('iframe')].find(f => f.src === iframeSrc);\n        if (!iframe) return null;\n        const rect = iframe.getBoundingClientRect();\n        const vpx = maestro.viewportX || 0;\n        const vpy = maestro.viewportY || 0;\n        const vpw = maestro.viewportWidth || window.innerWidth;\n        const vph = maestro.viewportHeight || window.innerHeight;\n        const scaleX = vpw / window.innerWidth;\n        const scaleY = vph / window.innerHeight;\n        return {\n            viewportX: rect.x * scaleX + vpx,\n            viewportY: rect.y * scaleY + vpy,\n            viewportWidth: rect.width * scaleX,\n            viewportHeight: rect.height * scaleY,\n        };\n    };\n\n    // -------------- Flutter Web Scrolling Support --------------\n    \n    maestro.isFlutterApp = () => {\n        // Detect if this is a Flutter web app by checking for Flutter-specific elements\n        const flutterView = document.querySelector('flutter-view');\n        const glassPane = document.querySelector('flt-glass-pane');\n        const fltRenderer = document.querySelector('[flt-renderer]');\n        \n        const isFlutter = !!(flutterView || glassPane || fltRenderer);\n        \n        return isFlutter;\n    }\n\n    maestro.smoothScrollFlutterByDelta = (totalDeltaX, totalDeltaY, durationMs = 500) => {\n        // Core smooth animated scrolling for Flutter web using explicit delta values\n        return new Promise((resolve) => {\n            const target = document.querySelector('flutter-view') || \n                          document.querySelector('flt-glass-pane');\n            \n            if (!target) {\n                console.error('[Maestro] Flutter root element not found');\n                resolve(false);\n                return;\n            }\n\n            const duration = typeof durationMs === 'number' && durationMs > 0 ? durationMs : 500;\n            const x = window.innerWidth / 2;\n            const y = window.innerHeight / 2;\n            const start = performance.now();\n            let lastX = 0;\n            let lastY = 0;\n            \n            function animate(now) {\n                const progress = Math.min((now - start) / duration, 1);\n                \n                // Cubic ease-in-out for natural animation\n                const eased = progress < 0.5 \n                    ? 4 * progress * progress * progress \n                    : 1 - Math.pow(-2 * progress + 2, 3) / 2;\n                \n                const nextX = eased * totalDeltaX;\n                const nextY = eased * totalDeltaY;\n                const deltaX = nextX - lastX;\n                const deltaY = nextY - lastY;\n                lastX = nextX;\n                lastY = nextY;\n                \n                if (Math.abs(deltaX) > 0.01 || Math.abs(deltaY) > 0.01) {\n                    target.dispatchEvent(new MouseEvent('mouseover', {\n                        clientX: x,\n                        clientY: y,\n                        bubbles: true\n                    }));\n                    target.dispatchEvent(new MouseEvent('mousemove', {\n                        clientX: x,\n                        clientY: y,\n                        bubbles: true\n                    }));\n                    target.dispatchEvent(new WheelEvent('wheel', {\n                        deltaX: deltaX,\n                        deltaY: deltaY,\n                        deltaMode: 0,\n                        clientX: x,\n                        clientY: y,\n                        bubbles: true,\n                        cancelable: true\n                    }));\n                }\n                \n                if (progress < 1) {\n                    requestAnimationFrame(animate);\n                } else {\n                    // Animation complete, wait for Flutter to update DOM\n                    setTimeout(() => resolve(true), 100);\n                }\n            }\n            \n            requestAnimationFrame(animate);\n        });\n    };\n\n    maestro.smoothScrollFlutter = (direction, durationMs = 500) => {\n        // Direction-based scrolling - converts direction to deltas and delegates to smoothScrollFlutterByDelta\n        const normalizedDirection = (direction || 'UP').toString().toUpperCase();\n        const isVertical = normalizedDirection === 'UP' || normalizedDirection === 'DOWN';\n        const isHorizontal = normalizedDirection === 'LEFT' || normalizedDirection === 'RIGHT';\n        \n        if (!isVertical && !isHorizontal) {\n            console.error('[Maestro] Unsupported Flutter scroll direction:', direction);\n            return Promise.resolve(false);\n        }\n\n        const duration = typeof durationMs === 'number' && durationMs > 0 ? durationMs : 500;\n        const distance = Math.max(1, Math.round(duration * 2));\n        const totalX = normalizedDirection === 'LEFT' ? distance :\n            normalizedDirection === 'RIGHT' ? -distance : 0;\n        const totalY = normalizedDirection === 'UP' ? distance :\n            normalizedDirection === 'DOWN' ? -distance : 0;\n        \n        return maestro.smoothScrollFlutterByDelta(totalX, totalY, durationMs);\n    };\n}( window.maestro = window.maestro || {} ));\n"
  },
  {
    "path": "maestro-client/src/test/java/maestro/FiltersTest.kt",
    "content": "package maestro\n\nimport com.google.common.truth.Truth.assertThat\nimport org.junit.jupiter.api.Test\n\nclass FiltersTest {\n\n    @Test\n    fun `index returns element at positive position`() {\n        val nodes = sampleNodes()\n\n        val result = Filters.index(1)(nodes)\n\n        assertThat(result).containsExactly(nodes[1])\n    }\n\n    @Test\n    fun `index supports negative values`() {\n        val nodes = sampleNodes()\n\n        val result = Filters.index(-1)(nodes)\n\n        assertThat(result).containsExactly(nodes.last())\n    }\n\n    @Test\n    fun `index supports negative value matching collection size`() {\n        val nodes = sampleNodes()\n\n        val result = Filters.index(-nodes.size)(nodes)\n\n        assertThat(result).containsExactly(nodes.first())\n    }\n\n    @Test\n    fun `index returns empty when negative value exceeds bounds`() {\n        val nodes = sampleNodes()\n\n        val result = Filters.index(-4)(nodes)\n\n        assertThat(result).isEmpty()\n    }\n\n    private fun sampleNodes(): List<TreeNode> {\n        return listOf(\n            node(bounds(0, 0)),\n            node(bounds(10, 10)),\n            node(bounds(20, 20)),\n        )\n    }\n\n    private fun node(bounds: String): TreeNode {\n        return TreeNode(attributes = mutableMapOf(\"bounds\" to bounds))\n    }\n\n    private fun bounds(x: Int, y: Int): String {\n        val size = 5\n        return \"[${x},${y}][${x + size},${y + size}]\"\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/test/java/maestro/PointTest.kt",
    "content": "package maestro\n\nimport com.google.common.truth.Truth.assertThat\nimport com.google.gson.Gson\nimport org.junit.jupiter.api.Test\n\n/**\n * Maestro Cloud uses Gson for serialization. This test ensures that Point class\n * can be deserialized correctly.\n *\n * See https://github.com/mobile-dev-inc/maestro/pull/627\n */\ninternal class PointTest {\n\n    @Test\n    internal fun `Deserialize Point`() {\n        // Given\n        val json = \"\"\"\n            {\n                \"x\": 1,\n                \"y\": 2\n            }\n        \"\"\".trimIndent()\n\n        // When\n        val point = Gson().fromJson(json, Point::class.java)\n\n        // Then\n        assertThat(point).isEqualTo(Point(1, 2))\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/test/java/maestro/UiElementTest.kt",
    "content": "package maestro\n\nimport com.google.common.truth.Truth.assertThat\nimport org.junit.jupiter.api.Test\n\ninternal class UiElementTest {\n\n    private val screenHeight = 1280\n    private val screenWidth = 720\n\n    @Test\n    internal fun `check visible percentage on screen - full`() {\n        val element = UiElement(\n            TreeNode(),\n            bounds = Bounds(\n                x = 50,\n                y = 50,\n                width = 200,\n                height = 100\n            )\n        )\n        val percent = element.getVisiblePercentage(screenWidth, screenHeight)\n        assertThat(percent).isEqualTo(1)\n    }\n\n    @Test\n    internal fun `check visible percentage on screen - left bottom 15 percent`() {\n        val element = UiElement(\n            TreeNode(),\n            bounds = Bounds(\n                x = -50,\n                y = 1260,\n                width = 200,\n                height = 100\n            )\n        )\n        val percent = element.getVisiblePercentage(screenWidth, screenHeight)\n        assertThat(percent).isEqualTo(0.15)\n    }\n\n    @Test\n    internal fun `check visible percentage on screen - right bottom 10 percent`() {\n        val element = UiElement(\n            TreeNode(),\n            bounds = Bounds(\n                x = 680,\n                y = 1200,\n                width = 200,\n                height = 100\n            )\n        )\n        val percent = element.getVisiblePercentage(screenWidth, screenHeight)\n        assertThat(percent).isEqualTo(0.16)\n    }\n\n    @Test\n    internal fun `check visible percentage on screen - out of bounds`() {\n        val element = UiElement(\n            TreeNode(),\n            bounds = Bounds(\n                x = -200,\n                y = 1300,\n                width = 200,\n                height = 100\n            )\n        )\n\n        val percent = element.getVisiblePercentage(screenWidth, screenHeight)\n        assertThat(percent).isEqualTo(0)\n    }\n}"
  },
  {
    "path": "maestro-client/src/test/java/maestro/android/AndroidAppFilesTest.kt",
    "content": "package maestro.android\n\nimport dadb.Dadb\nimport org.junit.jupiter.api.BeforeEach\nimport org.junit.jupiter.api.Disabled\nimport org.junit.jupiter.api.Test\nimport java.io.File\n\n@Disabled(\"Local testing only\")\ninternal class AndroidAppFilesTest {\n\n    private val home = System.getenv(\"HOME\")\n\n    private lateinit var dadb: Dadb\n\n    @BeforeEach\n    fun setUp() {\n        dadb = Dadb.discover(\"localhost\") ?: throw IllegalStateException(\"Could not find local emulator\")\n    }\n\n    @Test\n    fun pull() {\n        val appZipFile = File(\"$home/Downloads/com.reddit.frontpage.zip\")\n        AndroidAppFiles.pull(dadb, \"com.reddit.frontpage\", appZipFile)\n    }\n\n    @Test\n    fun push() {\n        val appZipFile = File(\"$home/Downloads/com.reddit.frontpage.zip\")\n        AndroidAppFiles.push(dadb, \"com.reddit.frontpage\", appZipFile)\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/test/java/maestro/android/AndroidLaunchArgumentsTest.kt",
    "content": "package maestro.android\n\nimport com.google.common.truth.Truth.assertThat\nimport maestro.android.AndroidLaunchArguments.toAndroidLaunchArguments\nimport maestro_android.MaestroAndroid\nimport org.junit.jupiter.api.Test\n\nclass AndroidLaunchArgumentsTest {\n\n    @Test\n    fun `it correctly parses to android launch arguments`() {\n        // given\n        val arguments = mapOf<String, Any>(\n            \"isMaestro\" to true,\n            \"cartValue\" to 4,\n            \"cartValueDouble\" to 4.4,\n            \"cartColor\" to \"Hello this is cart value which is orange\",\n            \"cartTimeStamp\" to 1683113805263,\n            \"cartZeroValue\" to 0\n        )\n\n        // when\n        val launchArguments = arguments.toAndroidLaunchArguments()\n\n        // then\n        assertThat(launchArguments).isEqualTo(\n            listOf(\n                provideArgumentValue(\"isMaestro\", \"true\", Boolean::class.java.name),\n                provideArgumentValue(\"cartValue\", \"4\", Int::class.java.name),\n                provideArgumentValue(\"cartValueDouble\", \"4.4\", Double::class.java.name),\n                provideArgumentValue(\"cartColor\", \"Hello this is cart value which is orange\", String::class.java.name),\n                provideArgumentValue(\"cartTimeStamp\", \"1683113805263\", Long::class.java.name),\n                provideArgumentValue(\"cartZeroValue\", \"0\", Int::class.java.name)\n            )\n        )\n    }\n\n    private fun provideArgumentValue(key: String, value: String, type: String): MaestroAndroid.ArgumentValue {\n        return MaestroAndroid.ArgumentValue.newBuilder()\n            .setKey(key)\n            .setValue(value)\n            .setType(type)\n            .build()\n    }\n}"
  },
  {
    "path": "maestro-client/src/test/java/maestro/android/chromedevtools/AndroidWebViewHierarchyClientTest.kt",
    "content": "package maestro.android.chromedevtools\n\nimport com.google.common.truth.Truth.assertThat\nimport maestro.TreeNode\nimport maestro.UiElement.Companion.toUiElementOrNull\nimport org.junit.jupiter.api.Test\n\nclass AndroidWebViewHierarchyClientTest {\n\n    @Test\n    fun testMergeHierarchies1() {\n        testMergeHierarchies(\n            \"[1-2,text=foo]\",\n            \"\",\n            \"[1-2,text=foo]\"\n        )\n    }\n\n    @Test\n    fun testMergeHierarchies2() {\n        testMergeHierarchies(\n            \"[1-2,text=foo]\",\n            \"[1-2,text=bar]\",\n            \"[1-2,text=foo][1-2,text=bar]\"\n        )\n    }\n\n    @Test\n    fun testMergeHierarchies3() {\n        testMergeHierarchies(\n            \"[1-3,text=foo]\",\n            \"[2-4,text=foo]\",\n            \"[1-3,text=foo]\"\n        )\n    }\n\n    @Test\n    fun testMergeHierarchies4() {\n        testMergeHierarchies(\n            \"[1-2,text=foobar]\",\n            \"[1-2,text=foo]\",\n            \"[1-2,text=foobar]\"\n        )\n    }\n\n    @Test\n    fun testMergeHierarchies5() {\n        testMergeHierarchies(\n            \"[1-3,text=foobar]\",\n            \"[2-4,text=foo]\",\n            \"[1-3,text=foobar]\"\n        )\n    }\n\n    @Test\n    fun testMergeHierarchies6() {\n        testMergeHierarchies(\n            \"[1-2,text=foobar]\",\n            \"[2-4,text=foo]\",\n            \"[1-2,text=foobar][2-4,text=foo]\"\n        )\n    }\n\n    @Test\n    fun testMergeHierarchies7() {\n        testMergeHierarchies(\n            \"[1-2,text=foo]\",\n            \"[1-2,id=foo]\",\n            \"[1-2,text=foo,id=foo]\"\n        )\n    }\n\n    @Test\n    fun testMergeHierarchies8() {\n        testMergeHierarchies(\n            \"[1-2,text=foo][2-3,text=bar]\",\n            \"[2-3,text=foo]\",\n            \"[1-2,text=foo][2-3,text=bar][2-3,text=foo]\"\n        )\n    }\n\n    @Test\n    fun testMergeHierarchies9() {\n        testMergeHierarchies(\n            \"[1-2,text=foo,id=bar]\",\n            \"[1-2,text=foo]\",\n            \"[1-2,text=foo,id=bar]\"\n        )\n    }\n\n    @Test\n    fun testMergeHierarchies10() {\n        testMergeHierarchies(\n            \"[1-3,text=foobar]\",\n            \"[2-4,text=foo,id=bar]\",\n            \"[1-3,text=foobar,id=bar]\"\n        )\n    }\n\n    @Test\n    fun testMergeHierarchies11() {\n        testMergeHierarchies(\n            \"[1-2,text=foo][2-3,text=bar]\",\n            \"[1-2,id=foo][2-3,id=bar]\",\n            \"[1-2,text=foo,id=foo][2-3,text=bar,id=bar]\"\n        )\n    }\n\n    @Test\n    fun testMergeHierarchies12() {\n        testMergeHierarchies(\n            \"[1-2,text=foo]\",\n            \"[2-3,text=foo]\",\n            \"[1-2,text=foo][2-3,text=foo]\"\n        )\n    }\n\n    @Test\n    fun testMergeHierarchies13() {\n        testMergeHierarchies(\n            \"[1-2,text=foo]\",\n            \"[2-3,text=]\",\n            \"[1-2,text=foo]\"\n        )\n    }\n\n    @Test\n    fun testMergeHierarchies14() {\n        testMergeHierarchies(\n            \"[1-2,text=foo,id=][3-4,text=,id=bar]\",\n            \"[1-2,text=foo,id=bar][3-4,text=foo,id=bar]\",\n            \"[1-2,text=foo,id=bar][3-4,text=foo,id=bar]\"\n        )\n    }\n\n    @Test\n    fun testMergeHierarchies15() {\n        testMergeHierarchies(\n            \"[1-2,accessibilityText=foo]\",\n            \"[1-2,text=foo,id=bar]\",\n            \"[1-2,accessibilityText=foo,id=bar]\"\n        )\n    }\n\n    @Test\n    fun testMergeHierarchies16() {\n        testMergeHierarchies(\n            \"[1-2,hintText=foo]\",\n            \"[1-2,text=foo,id=bar]\",\n            \"[1-2,hintText=foo,id=bar]\"\n        )\n    }\n\n    private fun testMergeHierarchies(\n        base: String,\n        webview: String,\n        expected: String,\n    ) {\n        val baseHierarchy = stringToHierarchy(base)\n        val webviewHierarchy = stringToHierarchy(webview)\n\n        val mergedHierarchy = AndroidWebViewHierarchyClient.mergeHierarchies(baseHierarchy, webviewHierarchy.children)\n        assertThat(hierarchyToString(mergedHierarchy)).isEqualTo(expected)\n    }\n\n    @Test\n    fun stringToHierarchyTest() {\n        val hierarchy1 = stringToHierarchy(\"[0-1]\")\n        assertThat(hierarchy1.children).hasSize(1)\n        assertThat(hierarchy1.children[0]).isEqualTo(TreeNode(\n            attributes = mutableMapOf(\n                \"bounds\" to \"[0,0][1,100]\"\n            )\n        ))\n        assertThat(hierarchyToString(hierarchy1)).isEqualTo(\"[0-1]\")\n\n        val hierarchy2 = stringToHierarchy(\"[1-2,text=foo]\")\n        assertThat(hierarchy2.children).containsExactly(TreeNode(\n            attributes = mutableMapOf(\n                \"text\" to \"foo\",\n                \"bounds\" to \"[1,0][2,100]\"\n            )\n        )).inOrder()\n        assertThat(hierarchyToString(hierarchy2)).isEqualTo(\"[1-2,text=foo]\")\n\n        val hierarchy3 = stringToHierarchy(\"[1-2,text=foo,id=bar]\")\n        assertThat(hierarchy3.children).containsExactly(TreeNode(\n            attributes = mutableMapOf(\n                \"text\" to \"foo\",\n                \"resource-id\" to \"bar\",\n                \"bounds\" to \"[1,0][2,100]\"\n            )\n        )).inOrder()\n        assertThat(hierarchyToString(hierarchy3)).isEqualTo(\"[1-2,text=foo,id=bar]\")\n\n        val hierarchy4 = stringToHierarchy(\"[1-2,text=foo,id=bar][2-3,text=baz,id=boo]\")\n        assertThat(hierarchy4.children).containsExactly(TreeNode(\n            attributes = mutableMapOf(\n                \"text\" to \"foo\",\n                \"resource-id\" to \"bar\",\n                \"bounds\" to \"[1,0][2,100]\"\n            )\n        ), TreeNode(\n            attributes = mutableMapOf(\n                \"text\" to \"baz\",\n                \"resource-id\" to \"boo\",\n                \"bounds\" to \"[2,0][3,100]\"\n            )\n        )).inOrder()\n        assertThat(hierarchyToString(hierarchy4)).isEqualTo(\"[1-2,text=foo,id=bar][2-3,text=baz,id=boo]\")\n\n        val hierarchy5 = stringToHierarchy(\"\")\n        assertThat(hierarchy5.children).isEmpty()\n        assertThat(hierarchyToString(hierarchy5)).isEqualTo(\"\")\n    }\n\n    private fun hierarchyToString(hierarchy: TreeNode): String {\n        return hierarchy.aggregate().mapNotNull {\n            it.toUiElementOrNull()\n        }.map {\n            assertThat(it.bounds.y).isEqualTo(0)\n            assertThat(it.bounds.height).isEqualTo(100)\n            val range = it.bounds.x.toString() + \"-\" + (it.bounds.x + it.bounds.width).toString()\n            val attributes = it.treeNode.attributes.entries.filter { it.key != \"bounds\" }.map { (key, value) ->\n                val finalKey = when (key) {\n                    \"resource-id\" -> \"id\"\n                    else -> key\n                }\n                \"$finalKey=$value\"\n            }\n            return@map \"[${(listOf(range) + attributes).joinToString(\",\")}]\"\n        }.joinToString(\"\")\n    }\n\n    private fun stringToHierarchy(hierarchy: String): TreeNode {\n        val nodes = hierarchy.split(\"]\")\n            .map { it.trim('[', ']') }\n            .filter { it.isNotEmpty() }\n            .map {\n                val parts = it.split(',')\n                val (start, end) = parts.first().split('-').map(String::toInt)\n                val bounds = \"[$start,0][$end,100]\"\n                val attributes = parts.drop(1).map {\n                    val (key, value) = it.split(\"=\")\n                    val finalKey = when (key) {\n                        \"id\" -> \"resource-id\"\n                        else -> key\n                    }\n                    finalKey to value\n                }.toMap() + mapOf(\"bounds\" to bounds)\n                TreeNode(attributes.toMutableMap())\n            }\n        return TreeNode(children = nodes)\n    }\n}"
  },
  {
    "path": "maestro-client/src/test/java/maestro/device/DeviceServiceTest.kt",
    "content": "package maestro.device\n\nimport com.google.common.truth.Truth.assertThat\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.io.TempDir\nimport java.io.File\n\ninternal class DeviceServiceTest {\n\n    @TempDir\n    lateinit var avdHome: File\n\n    // -------------------------------------------------------------------------\n    // Helpers\n    // -------------------------------------------------------------------------\n\n    /** Creates <avdHome>/<avdName>.avd/config.ini with the given content. */\n    private fun writeConfigIni(avdName: String, content: String) {\n        val dir = File(avdHome, \"$avdName.avd\").also { it.mkdirs() }\n        File(dir, \"config.ini\").writeText(content)\n    }\n\n    private fun List<AvdInfo>.named(name: String) = find { it.name == name }\n\n    // -------------------------------------------------------------------------\n    // Happy-path\n    // -------------------------------------------------------------------------\n\n    @Test\n    fun `single AVD with all fields and config ini present`() {\n        writeConfigIni(\n            \"Pixel_6_API_34\",\n            \"image.sysdir.1=system-images/android-34/google_apis/arm64-v8a/\\n\"\n        )\n\n        val output = \"\"\"\n            Available Android Virtual Devices:\n                Name: Pixel_6_API_34\n              Device: pixel_6 (Google Pixel 6)\n                Path: /home/user/.android/avd/Pixel_6_API_34.avd\n              Target: Google APIs (Google Inc.)\n                      Based on: Android 14.0 (\"UpsideDownCake\") Tag/ABI: google_apis/arm64-v8a\n                Skin: pixel_6\n              Sdcard: 512M\n        \"\"\".trimIndent()\n\n        val result = DeviceService.parseAvdInfo(output, avdHome)\n\n        assertThat(result).hasSize(1)\n        assertThat(result.named(\"Pixel_6_API_34\")).isEqualTo(AvdInfo(name = \"Pixel_6_API_34\", model = \"pixel_6\", os = \"android-34\"))\n    }\n\n    @Test\n    fun `multiple AVDs are all parsed`() {\n        writeConfigIni(\"Pixel_6_API_34\", \"image.sysdir.1=system-images/android-34/google_apis/arm64-v8a/\\n\")\n        writeConfigIni(\"Pixel_7_API_33\", \"image.sysdir.1=system-images/android-33/google_apis/x86_64/\\n\")\n\n        val output = \"\"\"\n            Available Android Virtual Devices:\n                Name: Pixel_6_API_34\n              Device: pixel_6 (Google Pixel 6)\n                Path: /home/user/.android/avd/Pixel_6_API_34.avd\n              Target: Google APIs (Google Inc.)\n                      Based on: Android 14.0 Tag/ABI: google_apis/arm64-v8a\n            ---------\n                Name: Pixel_7_API_33\n              Device: pixel_7 (Google Pixel 7)\n                Path: /home/user/.android/avd/Pixel_7_API_33.avd\n              Target: Google APIs (Google Inc.)\n                      Based on: Android 13.0 Tag/ABI: google_apis/x86_64\n        \"\"\".trimIndent()\n\n        val result = DeviceService.parseAvdInfo(output, avdHome)\n\n        assertThat(result).hasSize(2)\n        assertThat(result.named(\"Pixel_6_API_34\")).isEqualTo(AvdInfo(name = \"Pixel_6_API_34\", model = \"pixel_6\", os = \"android-34\"))\n        assertThat(result.named(\"Pixel_7_API_33\")).isEqualTo(AvdInfo(name = \"Pixel_7_API_33\", model = \"pixel_7\", os = \"android-33\"))\n    }\n\n    // -------------------------------------------------------------------------\n    // Model (Device:) field edge cases\n    // -------------------------------------------------------------------------\n\n    @Test\n    fun `Device field with no parenthetical description uses full token as model`() {\n        writeConfigIni(\"Pixel_6_API_34\", \"image.sysdir.1=system-images/android-34/google_apis/arm64-v8a/\\n\")\n\n        val output = \"\"\"\n            Available Android Virtual Devices:\n                Name: Pixel_6_API_34\n              Device: pixel_6\n        \"\"\".trimIndent()\n\n        val result = DeviceService.parseAvdInfo(output, avdHome)\n\n        assertThat(result.named(\"Pixel_6_API_34\")).isEqualTo(AvdInfo(name = \"Pixel_6_API_34\", model = \"pixel_6\", os = \"\"))\n    }\n\n    @Test\n    fun `missing Device field results in empty model string`() {\n        writeConfigIni(\"Pixel_6_API_34\", \"image.sysdir.1=system-images/android-34/google_apis/arm64-v8a/\\n\")\n\n        val output = \"\"\"\n            Available Android Virtual Devices:\n                Name: Pixel_6_API_34\n                Path: /home/user/.android/avd/Pixel_6_API_34.avd\n        \"\"\".trimIndent()\n\n        val result = DeviceService.parseAvdInfo(output, avdHome)\n\n        assertThat(result.named(\"Pixel_6_API_34\")).isEqualTo(AvdInfo(name = \"Pixel_6_API_34\", model = \"\", os = \"android-34\"))\n    }\n\n    // -------------------------------------------------------------------------\n    // OS / config.ini edge cases\n    // -------------------------------------------------------------------------\n\n    @Test\n    fun `config ini missing results in empty OS string`() {\n        // No config.ini written for this AVD\n\n        val output = \"\"\"\n            Available Android Virtual Devices:\n                Name: No_Config_AVD\n              Device: pixel_6 (Google Pixel 6)\n                Path: /home/user/.android/avd/No_Config_AVD.avd\n        \"\"\".trimIndent()\n\n        val result = DeviceService.parseAvdInfo(output, avdHome)\n\n        assertThat(result.named(\"No_Config_AVD\")).isEqualTo(AvdInfo(name = \"No_Config_AVD\", model = \"pixel_6\", os = \"\"))\n    }\n\n    @Test\n    fun `config ini present but lacks image sysdir line results in empty OS string`() {\n        writeConfigIni(\"AVD_No_Sysdir\", \"hw.ramSize=2048\\ntarget=android-34\\n\")\n\n        // Path: line triggers the config.ini fallback branch\n        val output = \"\"\"\n            Available Android Virtual Devices:\n                Name: AVD_No_Sysdir\n              Device: pixel_6 (Google Pixel 6)\n                Path: /home/user/.android/avd/AVD_No_Sysdir.avd\n        \"\"\".trimIndent()\n\n        val result = DeviceService.parseAvdInfo(output, avdHome)\n\n        assertThat(result.named(\"AVD_No_Sysdir\")).isEqualTo(AvdInfo(name = \"AVD_No_Sysdir\", model = \"pixel_6\", os = \"\"))\n    }\n\n    @Test\n    fun `config ini sysdir with no android-XX segment results in empty OS string`() {\n        writeConfigIni(\"AVD_Weird_Sysdir\", \"image.sysdir.1=custom-images/vendor-image/arm64-v8a/\\n\")\n\n        // Path: line triggers the config.ini fallback branch\n        val output = \"\"\"\n            Available Android Virtual Devices:\n                Name: AVD_Weird_Sysdir\n              Device: pixel_6 (Google Pixel 6)\n                Path: /home/user/.android/avd/AVD_Weird_Sysdir.avd\n        \"\"\".trimIndent()\n\n        val result = DeviceService.parseAvdInfo(output, avdHome)\n\n        assertThat(result.named(\"AVD_Weird_Sysdir\")).isEqualTo(AvdInfo(name = \"AVD_Weird_Sysdir\", model = \"pixel_6\", os = \"\"))\n    }\n\n    @Test\n    fun `config ini sysdir with android-XX at a non-first segment is still found`() {\n        writeConfigIni(\n            \"Nested_AVD\",\n            \"image.sysdir.1=sdk/system-images/android-30/google_apis_playstore/x86/\\n\"\n        )\n\n        // A non-Name/non-Device line (e.g. Path:) is required to trigger the\n        // config.ini fallback branch — it only fires on \"other\" lines.\n        val output = \"\"\"\n            Available Android Virtual Devices:\n                Name: Nested_AVD\n              Device: pixel_4 (Google Pixel 4)\n                Path: /home/user/.android/avd/Nested_AVD.avd\n        \"\"\".trimIndent()\n\n        val result = DeviceService.parseAvdInfo(output, avdHome)\n\n        assertThat(result.named(\"Nested_AVD\")).isEqualTo(AvdInfo(name = \"Nested_AVD\", model = \"pixel_4\", os = \"android-30\"))\n    }\n\n    // -------------------------------------------------------------------------\n    // Empty / degenerate input\n    // -------------------------------------------------------------------------\n\n    @Test\n    fun `empty output returns empty list`() {\n        val result = DeviceService.parseAvdInfo(\"\", avdHome)\n        assertThat(result).isEmpty()\n    }\n\n    @Test\n    fun `output with no AVDs returns empty list`() {\n        val output = \"Available Android Virtual Devices:\\n\"\n        val result = DeviceService.parseAvdInfo(output, avdHome)\n        assertThat(result).isEmpty()\n    }\n\n    @Test\n    fun `Name line with no subsequent Device or config ini still saved with empty values`() {\n        // AVD block containing only a Name: line — no Device:, no config.ini\n        val output = \"\"\"\n            Available Android Virtual Devices:\n                Name: Bare_AVD\n        \"\"\".trimIndent()\n\n        val result = DeviceService.parseAvdInfo(output, avdHome)\n\n        assertThat(result).hasSize(1)\n        assertThat(result.named(\"Bare_AVD\")).isEqualTo(AvdInfo(name = \"Bare_AVD\", model = \"\", os = \"\"))\n    }\n\n}\n"
  },
  {
    "path": "maestro-client/src/test/java/maestro/device/DeviceSpecTest.kt",
    "content": "package maestro.device\n\nimport com.google.common.truth.Truth.assertThat\nimport maestro.device.locale.LocaleValidationException\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.assertThrows\n\ninternal class DeviceSpecTest {\n    @Test\n    fun `resolve Android with no overrides uses defaults`() {\n        val spec = DeviceSpec.fromRequest(DeviceSpecRequest.Android()) as DeviceSpec.Android\n\n        assertThat(spec.platform).isEqualTo(Platform.ANDROID)\n        assertThat(spec.model).isEqualTo(\"pixel_6\")\n        assertThat(spec.os).isEqualTo(\"android-33\")\n        assertThat(spec.locale.code).isEqualTo(\"en_US\")\n        assertThat(spec.orientation).isEqualTo(DeviceOrientation.PORTRAIT)\n        assertThat(spec.disableAnimations).isEqualTo(true)\n    }\n\n    @Test\n    fun `resolve iOS with no overrides uses defaults`() {\n        val spec = DeviceSpec.fromRequest(DeviceSpecRequest.Ios()) as DeviceSpec.Ios\n\n        assertThat(spec.platform).isEqualTo(Platform.IOS)\n        assertThat(spec.model).isEqualTo(\"iPhone-11\")\n        assertThat(spec.os).isEqualTo(\"iOS-17-5\")\n        assertThat(spec.locale.code).isEqualTo(\"en_US\")\n        assertThat(spec.orientation).isEqualTo(DeviceOrientation.PORTRAIT)\n        assertThat(spec.disableAnimations).isEqualTo(true)\n        assertThat(spec.snapshotKeyHonorModalViews).isEqualTo(true)\n    }\n\n    @Test\n    fun `resolve Web with no overrides uses defaults`() {\n        val spec = DeviceSpec.fromRequest(DeviceSpecRequest.Web()) as DeviceSpec.Web\n\n        assertThat(spec.platform).isEqualTo(Platform.WEB)\n        assertThat(spec.model).isEqualTo(\"chromium\")\n        assertThat(spec.os).isEqualTo(\"default\")\n        assertThat(spec.locale.code).isEqualTo(\"en_US\")\n    }\n\n    @Test\n    fun `resolve uses explicit values when provided`() {\n        val spec = DeviceSpec.fromRequest(\n            DeviceSpecRequest.Android(\n                model = \"pixel_xl\",\n                os = \"android-33\",\n                locale = \"de_DE\",\n                orientation = DeviceOrientation.LANDSCAPE_LEFT,\n                cpuArchitecture = CPU_ARCHITECTURE.ARM64,\n            )\n        ) as DeviceSpec.Android\n\n        assertThat(spec.model).isEqualTo(\"pixel_xl\")\n        assertThat(spec.os).isEqualTo(\"android-33\")\n        assertThat(spec.emulatorImage).isEqualTo(\"system-images;android-33;google_apis;arm64-v8a\")\n        assertThat(spec.locale.languageCode).isEqualTo(\"de\")\n        assertThat(spec.locale.countryCode).isEqualTo(\"DE\")\n        assertThat(spec.orientation).isEqualTo(DeviceOrientation.LANDSCAPE_LEFT)\n    }\n\n    @Test\n    fun `resolve also update image when system architecture is different`() {\n        val spec = DeviceSpec.fromRequest(\n            DeviceSpecRequest.Android(\n                model = \"pixel_xl\",\n                os = \"android-33\",\n                locale = \"de_DE\",\n                orientation = DeviceOrientation.LANDSCAPE_LEFT,\n                cpuArchitecture = CPU_ARCHITECTURE.X86_64,\n            )\n        ) as DeviceSpec.Android\n\n        assertThat(spec.emulatorImage).isEqualTo(\"system-images;android-33;google_apis;x86_64\")\n    }\n\n    @Test\n    fun `resolve Android throws on invalid locale combination like ar_US`() {\n        assertThrows<LocaleValidationException> {\n            DeviceSpec.fromRequest(DeviceSpecRequest.Android(locale = \"ar_US\"))\n        }\n    }\n\n    @Test\n    fun `resolve Android throws on unsupported language code`() {\n        assertThrows<LocaleValidationException> {\n            DeviceSpec.fromRequest(DeviceSpecRequest.Android(locale = \"xx_US\"))\n        }\n    }\n\n    @Test\n    fun `resolve Android throws on malformed locale missing country`() {\n        assertThrows<LocaleValidationException> {\n            DeviceSpec.fromRequest(DeviceSpecRequest.Android(locale = \"en\"))\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/test/java/maestro/device/serialization/DeviceSpecSerializationTest.kt",
    "content": "package maestro.device.serialization\n\nimport com.fasterxml.jackson.databind.ObjectMapper\nimport com.fasterxml.jackson.module.kotlin.registerKotlinModule\nimport com.google.common.truth.Truth\nimport maestro.device.CPU_ARCHITECTURE\nimport maestro.device.DeviceOrientation\nimport maestro.device.DeviceSpec\nimport maestro.device.DeviceSpecRequest\nimport org.junit.jupiter.api.Test\n\nclass DeviceSpecSerializationTest {\n\n    private val mapper = ObjectMapper()\n        .registerKotlinModule()\n        .registerModule(DeviceSpecModule())\n\n    @Test\n    fun `round-trip Android DeviceSpec`() {\n        val spec = DeviceSpec.fromRequest(\n          DeviceSpecRequest.Android()\n        )\n\n        val json = mapper.writeValueAsString(spec)\n        val deserialized = mapper.readValue(json, DeviceSpec::class.java)\n\n        Truth.assertThat(deserialized).isEqualTo(spec)\n    }\n\n    @Test\n    fun `round-trip iOS DeviceSpec`() {\n        val spec = DeviceSpec.fromRequest(\n            DeviceSpecRequest.Ios()\n        )\n\n        val json = mapper.writeValueAsString(spec)\n        val deserialized = mapper.readValue(json, DeviceSpec::class.java)\n\n        Truth.assertThat(deserialized).isEqualTo(spec)\n    }\n\n    @Test\n    fun `round-trip Web DeviceSpec`() {\n        val spec = DeviceSpec.fromRequest(\n          DeviceSpecRequest.Web()\n        )\n\n        val json = mapper.writeValueAsString(spec)\n        val deserialized = mapper.readValue(json, DeviceSpec::class.java)\n\n        Truth.assertThat(deserialized).isEqualTo(spec)\n    }\n\n    @Test\n    fun `serialized JSON has expected structure`() {\n        val spec = DeviceSpec.fromRequest(\n            DeviceSpecRequest.Android(\n                model = \"pixel_6\",\n                os = \"android-33\",\n                locale = \"en_US\",\n                orientation = DeviceOrientation.PORTRAIT,\n                disableAnimations = false,\n                cpuArchitecture = CPU_ARCHITECTURE.ARM64,\n            )\n        )\n\n        val json = mapper.readTree(mapper.writeValueAsString(spec))\n\n        Truth.assertThat(json.get(\"platform\").asText()).isEqualTo(\"ANDROID\")\n        Truth.assertThat(json.get(\"model\").asText()).isEqualTo(\"pixel_6\")\n        Truth.assertThat(json.get(\"os\").asText()).isEqualTo(\"android-33\")\n        Truth.assertThat(json.get(\"locale\").get(\"code\").asText()).isEqualTo(\"en_US\")\n        Truth.assertThat(json.get(\"locale\").get(\"platform\").asText()).isEqualTo(\"ANDROID\")\n        Truth.assertThat(json.get(\"orientation\").asText()).isEqualTo(\"PORTRAIT\")\n        Truth.assertThat(json.get(\"disableAnimations\").asBoolean()).isFalse()\n        Truth.assertThat(json.get(\"cpuArchitecture\").asText()).isEqualTo(\"ARM64\")\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/test/java/maestro/ios/MockXCTestInstaller.kt",
    "content": "package maestro.ios\n\nimport com.google.common.truth.Truth.assertThat\nimport xcuitest.XCTestClient\nimport xcuitest.installer.XCTestInstaller\n\nclass MockXCTestInstaller(\n    private val simulator: Simulator,\n) : XCTestInstaller {\n\n    private var attempts = 0\n\n    override fun start(): XCTestClient {\n        attempts++\n        for (i in 0..simulator.installationRetryCount) {\n            assertThat(simulator.runningApps()).doesNotContain(\"dev.mobile.maestro-driver-iosUITests.xctrunner\")\n        }\n        simulator.installXCTestDriver()\n        return XCTestClient(\"localhost\", 22807)\n    }\n\n    override fun uninstall(): Boolean {\n        simulator.uninstallXCTestDriver()\n        return true\n    }\n\n    override fun isChannelAlive(): Boolean {\n        return simulator.isXCTestRunnerAlive()\n    }\n\n    override fun close() {\n        simulator.uninstallXCTestDriver()\n    }\n\n    fun assertInstallationRetries(expectedRetries: Int) {\n        assertThat(attempts).isEqualTo(expectedRetries)\n    }\n\n    data class Simulator(\n        val installationRetryCount: Int = 0,\n        val shouldInstall: Boolean = true\n    ) {\n\n        private val runningApps = mutableListOf<String>()\n\n        fun runningApps() = runningApps\n\n        fun isXCTestRunnerAlive() = runningApps.contains(\"dev.mobile.maestro-driver-iosUITests.xctrunner\")\n\n        fun uninstallXCTestDriver() = runningApps.clear()\n\n        fun installXCTestDriver() = runningApps.add(\"dev.mobile.maestro-driver-iosUITests.xctrunner\")\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/test/java/maestro/locale/DeviceLocaleTest.kt",
    "content": "package maestro.locale\n\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.assertThrows\nimport com.google.common.truth.Truth.assertThat\nimport maestro.device.Platform\nimport maestro.device.locale.DeviceLocale\nimport maestro.device.locale.LocaleValidationException\n\ninternal class DeviceLocaleTest {\n  @Test\n  internal fun `fromString when invalid locale format is received throws WrongLocaleFormat exception`() {\n    val exception = assertThrows<LocaleValidationException> {\n      DeviceLocale.fromString(\"someInvalidLocale\", Platform.ANDROID)\n    }\n    assertThat(exception.message).contains(\"Failed to validate device locale\")\n    assertThat(exception.message).contains(\"someInvalidLocale is not a valid format\")\n    assertThat(exception.message).contains(\"Expected format: language_country, e.g., en_US\")\n  }\n\n  @Test\n  internal fun `fromString when not supported locale is received and platform is Web throws LocaleValidationException`() {\n    val exception = assertThrows<LocaleValidationException> {\n      DeviceLocale.fromString(\"de_DE\", Platform.WEB)\n    }\n    assertThat(exception.message).contains(\"Failed to validate web browser locale\")\n    assertThat(exception.message).contains(\"de_DE\")\n    assertThat(exception.message).contains(\"Here is a full list of supported locales\")\n  }\n\n  @Test\n  internal fun `fromString when the combination is not valid and platform is Android throws LocaleValidationException`() {\n    val exception = assertThrows<LocaleValidationException> {\n      DeviceLocale.fromString(\"ar_US\", Platform.ANDROID)\n    }\n    assertThat(exception.message).contains(\"Failed to validate Android device locale combination\")\n    assertThat(exception.message).contains(\"ar_US is not a valid locale combination\")\n    assertThat(exception.message).contains(\"Here is a full list of supported locales\")\n  }\n\n  @Test\n  internal fun `fromString when not supported locale is received and platform is iOS throws LocaleValidationException`() {\n    val exception = assertThrows<LocaleValidationException> {\n      DeviceLocale.fromString(\"de_IN\", Platform.IOS)\n    }\n    assertThat(exception.message).contains(\"Failed to validate iOS device locale\")\n    assertThat(exception.message).contains(\"Here is a full list of supported locales\")\n  }\n\n  @Test\n  internal fun `fromString when not supported locale language is received and platform is Android throws LocaleValidationException`() {\n    val exception = assertThrows<LocaleValidationException> {\n      DeviceLocale.fromString(\"ee_IN\", Platform.ANDROID)\n    }\n    assertThat(exception.message).contains(\"Failed to validate Android device language\")\n    assertThat(exception.message).contains(\"ee is not a supported Android language\")\n    assertThat(exception.message).contains(\"Here is a full list of supported languageCode\")\n  }\n\n  @Test\n  internal fun `fromString when not supported locale country is received and platform is Android throws LocaleValidationException`() {\n    val exception = assertThrows<LocaleValidationException> {\n      DeviceLocale.fromString(\"hi_EE\", Platform.ANDROID)\n    }\n    assertThat(exception.message).contains(\"Failed to validate Android device country\")\n    assertThat(exception.message).contains(\"EE is not a supported Android country\")\n    assertThat(exception.message).contains(\"Here is a full list of supported countryCode\")\n  }\n\n  @Test\n  internal fun `fromString when supported locale is received returns correct language and country codes`() {\n    val locale1 = DeviceLocale.fromString(\"de_DE\", Platform.ANDROID)\n    val locale2 = DeviceLocale.fromString(\"es_ES\", Platform.IOS)\n\n    assertThat(locale1.languageCode).isEqualTo(\"de\")\n    assertThat(locale1.countryCode).isEqualTo(\"DE\")\n    assertThat(locale2.languageCode).isEqualTo(\"es\")\n    assertThat(locale2.countryCode).isEqualTo(\"ES\")\n  }\n\n  @Test\n  internal fun `isValid returns true for valid locales`() {\n    assertThat(DeviceLocale.isValid(\"en_US\", Platform.ANDROID)).isTrue()\n    assertThat(DeviceLocale.isValid(\"he-IL\", Platform.ANDROID)).isTrue()\n    assertThat(DeviceLocale.isValid(\"es_ES\", Platform.IOS)).isTrue()\n    assertThat(DeviceLocale.isValid(\"he-IL\", Platform.IOS)).isTrue()\n    assertThat(DeviceLocale.isValid(\"en_US\", Platform.WEB)).isTrue()\n  }\n\n  @Test\n  internal fun `isValid returns false for invalid locales`() {\n    assertThat(DeviceLocale.isValid(\"de_DE\", Platform.WEB)).isFalse()\n    assertThat(DeviceLocale.isValid(\"he-IL\", Platform.WEB)).isFalse()\n    assertThat(DeviceLocale.isValid(\"invalid\", Platform.ANDROID)).isFalse()\n    assertThat(DeviceLocale.isValid(\"ar_US\", Platform.ANDROID)).isFalse()\n  }\n\n  @Test\n  internal fun `all returns list of supported locales for each platform`() {\n    val androidLocales = DeviceLocale.all(Platform.ANDROID)\n    val iosLocales = DeviceLocale.all(Platform.IOS)\n    val webLocales = DeviceLocale.all(Platform.WEB)\n\n    assertThat(androidLocales).isNotEmpty()\n    assertThat(iosLocales).isNotEmpty()\n    assertThat(webLocales).isNotEmpty()\n  }\n\n  @Test\n  internal fun `allCodes returns set of supported locale codes for each platform`() {\n    val androidCodes = DeviceLocale.allCodes(Platform.ANDROID)\n    val iosCodes = DeviceLocale.allCodes(Platform.IOS)\n    val webCodes = DeviceLocale.allCodes(Platform.WEB)\n\n    assertThat(androidCodes).isNotEmpty()\n    assertThat(iosCodes).isNotEmpty()\n    assertThat(webCodes).isNotEmpty()\n\n    assertThat(androidCodes).contains(\"en_US\")\n    assertThat(iosCodes).contains(\"es_ES\")\n  }\n\n  @Test\n  internal fun `find returns correct locale code when language and country match`() {\n    val androidLocale = DeviceLocale.find(\"en\", \"US\", Platform.ANDROID)\n    val iosLocale = DeviceLocale.find(\"es\", \"ES\", Platform.IOS)\n\n    assertThat(androidLocale).isEqualTo(\"en_US\")\n    assertThat(iosLocale).isEqualTo(\"es_ES\")\n  }\n\n  @Test\n  internal fun `find returns null when locale not found`() {\n    val result = DeviceLocale.find(\"xx\", \"YY\", Platform.ANDROID)\n    assertThat(result).isNull()\n  }\n\n  @Test\n  internal fun `code property returns locale code`() {\n    val locale = DeviceLocale.fromString(\"en_US\", Platform.ANDROID)\n    assertThat(locale.code).isEqualTo(\"en_US\")\n  }\n\n  @Test\n  internal fun `displayName property returns display name`() {\n    val locale = DeviceLocale.fromString(\"en_US\", Platform.ANDROID)\n    assertThat(locale.displayName).isNotEmpty()\n  }\n\n  @Test\n  internal fun `platform property returns correct platform`() {\n    val androidLocale = DeviceLocale.fromString(\"en_US\", Platform.ANDROID)\n    val iosLocale = DeviceLocale.fromString(\"es_ES\", Platform.IOS)\n\n    assertThat(androidLocale.platform).isEqualTo(Platform.ANDROID)\n    assertThat(iosLocale.platform).isEqualTo(Platform.IOS)\n  }\n}\n"
  },
  {
    "path": "maestro-client/src/test/java/maestro/utils/HttpUtilsTest.kt",
    "content": "package maestro.utils\n\nimport com.google.common.truth.Truth.assertThat\nimport maestro.utils.HttpUtils.toMultipartBody\nimport okhttp3.MultipartBody\nimport org.junit.jupiter.api.Test\n\ninternal class HttpUtilsTest {\n\n    @Test\n    internal fun `toMultipartBody should successfully parse a map containing a filePath along with mediaType`() {\n        // Given\n        val map = mapOf(\n            \"uploadType\" to \"import\",\n            \"data\" to (listOf(\n                \"filePath\" to \"testFilePath\",\n                \"mediaType\" to \"text/csv\"\n            ))\n        )\n\n        // When\n        val multipartBody = map.toMultipartBody()\n\n        // Then\n        assertThat(multipartBody.size).isEqualTo(2)\n        assertThat(multipartBody.type).isEqualTo(MultipartBody.FORM)\n    }\n\n    @Test\n    internal fun `toMultipartBody should successfully parse a map containing a filePath without mediaType`() {\n        // Given\n        val map = mapOf(\n            \"uploadType\" to \"import\",\n            \"data\" to (listOf(\n                \"filePath\" to \"testFilePath\"\n            ))\n        )\n\n        // When\n        val multipartBody = map.toMultipartBody()\n\n        // Then\n        assertThat(multipartBody.size).isEqualTo(2)\n        assertThat(multipartBody.type).isEqualTo(MultipartBody.FORM)\n    }\n\n    @Test\n    internal fun `toMultipartBody should successfully parse a map without a filePath`() {\n        // Given\n        val map = mapOf(\n            \"data1\" to \"test1\",\n            \"data2\" to \"test2\",\n            \"data3\" to \"test3\",\n        )\n\n        // When\n        val multipartBody = map.toMultipartBody()\n\n        // Then\n        assertThat(multipartBody.size).isEqualTo(3)\n        assertThat(multipartBody.type).isEqualTo(MultipartBody.FORM)\n    }\n}"
  },
  {
    "path": "maestro-client/src/test/java/maestro/utils/StringUtilsTest.kt",
    "content": "package maestro.utils\n\nimport com.google.common.truth.Truth.assertThat\nimport maestro.utils.StringUtils.toRegexSafe\nimport org.junit.jupiter.api.Test\n\ninternal class StringUtilsTest {\n\n    @Test\n    internal fun `toRegexSafe should escape string if regex is invalid`() {\n        // Given\n        val input = \"*Oświadczam, że zapoznałem się z treścią Regulaminu serwisu i akceptuję jego postanowienia.\"\n\n        // When\n        val regex = input.toRegexSafe()\n\n        // Then\n        assertThat(regex.matches(input)).isTrue()\n    }\n\n}"
  },
  {
    "path": "maestro-client/src/test/java/maestro/xctestdriver/XCTestDriverClientTest.kt",
    "content": "package maestro.xctestdriver\n\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport com.google.common.truth.Truth.assertThat\nimport maestro.ios.MockXCTestInstaller\nimport maestro.utils.network.XCUITestServerError\nimport okhttp3.mockwebserver.MockResponse\nimport okhttp3.mockwebserver.MockWebServer\nimport okhttp3.mockwebserver.SocketPolicy\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.assertThrows\nimport org.junit.jupiter.params.ParameterizedTest\nimport org.junit.jupiter.params.provider.MethodSource\nimport xcuitest.XCTestClient\nimport xcuitest.XCTestDriverClient\nimport xcuitest.api.DeviceInfo\nimport xcuitest.api.Error\nimport java.net.InetAddress\n\nclass XCTestDriverClientTest {\n\n    @Test\n    fun `it should return the 4xx response as is without retrying`() {\n        // given\n        val mockWebServer = MockWebServer()\n        val mapper = jacksonObjectMapper()\n        val error = Error(errorMessage = \"This is bad request, failure\", errorCode = \"bad-request\")\n        val mockResponse = MockResponse().apply {\n            setResponseCode(401)\n            setBody(mapper.writeValueAsString(error))\n        }\n        mockWebServer.enqueue(mockResponse)\n        mockWebServer.start(InetAddress.getByName(\"localhost\"), 22087)\n        val httpUrl = mockWebServer.url(\"/deviceInfo\")\n\n        // when\n        val simulator = MockXCTestInstaller.Simulator()\n        val mockXCTestInstaller = MockXCTestInstaller(simulator)\n        val xcTestDriverClient = XCTestDriverClient(\n            mockXCTestInstaller,\n            XCTestClient(\"localhost\", 22087)\n        )\n\n\n        // then\n        assertThrows<XCUITestServerError.BadRequest> {\n            xcTestDriverClient.deviceInfo(httpUrl)\n        }\n        mockXCTestInstaller.assertInstallationRetries(0)\n        mockWebServer.shutdown()\n    }\n\n    @Test\n    fun `it should return the 200 response as is without retrying`() {\n        // given\n        val mockWebServer = MockWebServer()\n        val mapper = jacksonObjectMapper()\n        val expectedDeviceInfo = DeviceInfo(1123, 5000, 1223, 1123)\n        val mockResponse = MockResponse().apply {\n            setResponseCode(200)\n            setBody(mapper.writeValueAsString(expectedDeviceInfo))\n        }\n        mockWebServer.enqueue(mockResponse)\n        mockWebServer.start(InetAddress.getByName(\"localhost\"), 22087)\n        val httpUrl = mockWebServer.url(\"/deviceInfo\")\n\n        // when\n        val simulator = MockXCTestInstaller.Simulator()\n        val mockXCTestInstaller = MockXCTestInstaller(simulator)\n        val xcTestDriverClient = XCTestDriverClient(\n            mockXCTestInstaller,\n            XCTestClient(\"localhost\", 22087)\n        )\n        val actualDeviceInfo = xcTestDriverClient.deviceInfo(httpUrl)\n\n        // then\n        assertThat(actualDeviceInfo).isEqualTo(expectedDeviceInfo)\n        mockXCTestInstaller.assertInstallationRetries(0)\n        mockWebServer.shutdown()\n    }\n\n    @ParameterizedTest\n    @MethodSource(\"provideAppCrashMessage\")\n    fun `it should throw app crash exception correctly`(errorMessage: String) {\n        // given\n        val mockWebServer = MockWebServer()\n        val mapper = jacksonObjectMapper()\n        val expectedDeviceInfo = Error(errorMessage = errorMessage, errorCode = \"internal\")\n        val mockResponse = MockResponse().apply {\n            setResponseCode(500)\n            setBody(mapper.writeValueAsString(expectedDeviceInfo))\n        }\n        mockWebServer.enqueue(mockResponse)\n        mockWebServer.start(InetAddress.getByName( \"localhost\"), 22087)\n        val httpUrl = mockWebServer.url(\"/deviceInfo\")\n\n        // when\n        val simulator = MockXCTestInstaller.Simulator()\n        val mockXCTestInstaller = MockXCTestInstaller(simulator)\n        val xcTestDriverClient = XCTestDriverClient(\n            mockXCTestInstaller,\n            XCTestClient(\"localhost\", 22087)\n        )\n\n\n        // then\n        assertThrows<XCUITestServerError.AppCrash> {\n            xcTestDriverClient.deviceInfo(httpUrl)\n        }\n        mockXCTestInstaller.assertInstallationRetries(0)\n        mockWebServer.shutdown()\n    }\n\n    companion object {\n\n        @JvmStatic\n        fun provideAppCrashMessage(): Array<String> {\n            return arrayOf(\n                \"Application com.app.id is not running\",\n                \"Lost connection to the application (pid 19985).\",\n                \"Error getting main window kAXErrorCannotComplete\",\n                \"Error getting main window Unknown kAXError value -25218\"\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-client/src/test/resources/logback-test.xml",
    "content": "<configuration>\n    <appender name=\"CONSOLE\" class=\"ch.qos.logback.core.ConsoleAppender\">\n        <encoder>\n            <pattern>[%-5level] %logger{36} - %msg%n</pattern>\n        </encoder>\n    </appender>\n\n    <logger name=\"CONSOLE\" level=\"INFO\">\n        <appender-ref ref=\"CONSOLE\" />\n    </logger>\n\n    <root level=\"INFO\">\n        <appender-ref ref=\"CONSOLE\" />\n    </root>\n</configuration>"
  },
  {
    "path": "maestro-ios/README.md",
    "content": "# iOS Device Config\n\nA wrapper around `simctl` and XCTest to communicate with iOS devices.\n\n## Prerequisites\n\n### Xcode\n\nInstall the latest Xcode (Command Line Tools are not enough, install the full IDE).\n\n### IntelliJ setup\n\nIf you are working with this subproject, update your IntelliJ config (Help -> Edit Custom Properties) by including the following lines:\n\n```\n# Needed for working with idb.proto definition\nidea.max.intellisense.filesize=4000\n```\n\nThen restart the IDE.\n"
  },
  {
    "path": "maestro-ios/build.gradle.kts",
    "content": "import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask\nimport com.vanniktech.maven.publish.SonatypeHost\n\nplugins {\n    id(\"maven-publish\")\n    alias(libs.plugins.kotlin.jvm)\n    alias(libs.plugins.mavenPublish)\n}\n\nsourceSets {\n    main {\n        java {\n            srcDirs(\n                \"build/generated/source/proto/main/java\",\n                \"build/generated/source/proto/main/kotlin\",\n            )\n        }\n    }\n}\n\ndependencies {\n    implementation(project(\":maestro-utils\"))\n    implementation(project(\":maestro-ios-driver\"))\n\n    implementation(libs.kotlin.result)\n\n    implementation(libs.logging.sl4j)\n    implementation(libs.logging.api)\n    implementation(libs.logging.layout.template)\n    implementation(libs.log4j.core)\n\n    implementation(libs.square.okio)\n    implementation(libs.square.okio.jvm)\n    api(libs.google.gson)\n    api(libs.square.okhttp)\n    api(libs.appdirs)\n    api(libs.jackson.module.kotlin)\n\n    testImplementation(libs.junit.jupiter.api)\n    testRuntimeOnly(libs.junit.jupiter.engine)\n    testImplementation(libs.google.truth)\n}\n\njava {\n    sourceCompatibility = JavaVersion.VERSION_17\n    targetCompatibility = JavaVersion.VERSION_17\n}\n\nkotlin {\n    jvmToolchain {\n        languageVersion.set(JavaLanguageVersion.of(17))\n    }\n}\n\ntasks.named(\"compileKotlin\", KotlinCompilationTask::class.java) {\n    compilerOptions {\n        freeCompilerArgs.addAll(\"-Xjdk-release=17\")\n    }\n}\n\nmavenPublishing {\n    publishToMavenCentral(true)\n    signAllPublications()\n}\n\ntasks.named<Test>(\"test\") {\n    useJUnitPlatform()\n}\n"
  },
  {
    "path": "maestro-ios/gradle.properties",
    "content": "POM_NAME=Maestro iOS\nPOM_ARTIFACT_ID=maestro-ios\nPOM_PACKAGING=jar"
  },
  {
    "path": "maestro-ios/src/main/java/ios/IOSDeviceErrors.kt",
    "content": "package ios\n\nsealed class IOSDeviceErrors : Throwable() {\n    data class AppCrash(val errorMessage: String): IOSDeviceErrors()\n    data class OperationTimeout(val errorMessage: String): IOSDeviceErrors()\n}"
  },
  {
    "path": "maestro-ios/src/main/java/ios/LocalIOSDevice.kt",
    "content": "package ios\n\nimport com.github.michaelbull.result.*\nimport device.IOSDevice\nimport device.IOSScreenRecording\nimport xcuitest.api.DeviceInfo\nimport ios.xctest.XCTestIOSDevice\nimport okio.Sink\nimport java.io.InputStream\nimport hierarchy.ViewHierarchy\nimport maestro.utils.Insight\nimport maestro.utils.Insights\nimport maestro.utils.NoopInsights\nimport java.util.concurrent.Executors\nimport java.util.concurrent.TimeUnit\n\nclass LocalIOSDevice(\n    override val deviceId: String?,\n    private val xcTestDevice: XCTestIOSDevice,\n    private val deviceController: IOSDevice,\n    private val insights: Insights = NoopInsights\n) : IOSDevice {\n\n    private val executor by lazy { Executors.newSingleThreadScheduledExecutor() }\n\n    override fun open() {\n        xcTestDevice.open()\n    }\n\n    override fun deviceInfo(): DeviceInfo {\n        return xcTestDevice.deviceInfo()\n    }\n\n    override fun viewHierarchy(excludeKeyboardElements: Boolean): ViewHierarchy {\n        var isViewHierarchyInProgress = true\n        val future = executor.schedule(\n            {\n                if (isViewHierarchyInProgress) {\n                    insights.report(\n                        Insight(\n                            message = \"Retrieving the hierarchy is taking longer than usual. This might be due to a \" +\n                                    \"deep hierarchy in the current view. Please wait a bit more to complete the operation.\",\n                            level = Insight.Level.WARNING,\n                        )\n                    )\n                }\n            }, 15, TimeUnit.SECONDS\n        )\n        val result = xcTestDevice.viewHierarchy(excludeKeyboardElements)\n        isViewHierarchyInProgress = false\n        if (!future.isDone) {\n            future.cancel(false)\n        }\n        return result\n    }\n\n    override fun tap(x: Int, y: Int) {\n        return xcTestDevice.tap(x, y)\n    }\n\n    override fun longPress(x: Int, y: Int, durationMs: Long) {\n        xcTestDevice.longPress(x, y, durationMs)\n    }\n\n    override fun pressKey(name: String) {\n        xcTestDevice.pressKey(name)\n    }\n\n    override fun pressButton(name: String) {\n        xcTestDevice.pressButton(name)\n    }\n\n    override fun scroll(\n        xStart: Double,\n        yStart: Double,\n        xEnd: Double,\n        yEnd: Double,\n        duration: Double\n    ) {\n        xcTestDevice.scrollV2(xStart, yStart, xEnd, yEnd, duration)\n    }\n\n    override fun input(text: String) {\n        xcTestDevice.input(text)\n    }\n\n    override fun install(stream: InputStream) {\n        deviceController.install(stream)\n    }\n\n    override fun uninstall(id: String) {\n        deviceController.uninstall(id)\n    }\n\n    override fun clearAppState(id: String) {\n        deviceController.clearAppState(id)\n    }\n\n    override fun clearKeychain(): Result<Unit, Throwable> {\n        return deviceController.clearKeychain()\n    }\n\n    override fun launch(\n        id: String,\n        launchArguments: Map<String, Any>,\n    ) {\n        deviceController.launch(id, launchArguments)\n    }\n\n    override fun stop(id: String) {\n        xcTestDevice.stop(id)\n    }\n\n    override fun isKeyboardVisible(): Boolean {\n        return xcTestDevice.isKeyboardVisible()\n    }\n\n    override fun openLink(link: String): Result<Unit, Throwable> {\n        return deviceController.openLink(link)\n    }\n\n    override fun takeScreenshot(out: Sink, compressed: Boolean) {\n        xcTestDevice.takeScreenshot(out, compressed)\n    }\n\n    override fun startScreenRecording(out: Sink): IOSScreenRecording {\n        return deviceController.startScreenRecording(out)\n    }\n\n    override fun setLocation(latitude: Double, longitude: Double): Result<Unit, Throwable> {\n        return deviceController.setLocation(latitude, longitude)\n    }\n\n    override fun setOrientation(orientation: String) {\n        return xcTestDevice.setOrientation(orientation)\n    }\n\n    override fun isShutdown(): Boolean {\n        return xcTestDevice.isShutdown()\n    }\n\n    override fun close() {\n        xcTestDevice.close()\n    }\n\n    override fun isScreenStatic(): Boolean {\n        return xcTestDevice.isScreenStatic()\n    }\n\n    override fun setPermissions(id: String, permissions: Map<String, String>) {\n        deviceController.setPermissions(id, permissions)\n        xcTestDevice.setPermissions(id, permissions)\n    }\n\n    override fun eraseText(charactersToErase: Int) {\n        xcTestDevice.eraseText(charactersToErase)\n    }\n\n    override fun addMedia(path: String) {\n        deviceController.addMedia(path)\n    }\n}\n"
  },
  {
    "path": "maestro-ios/src/main/java/ios/devicectl/DeviceControlIOSDevice.kt",
    "content": "package ios.devicectl\n\nimport com.github.michaelbull.result.Result\nimport device.IOSDevice\nimport device.IOSScreenRecording\nimport hierarchy.ViewHierarchy\nimport okio.Sink\nimport org.slf4j.LoggerFactory\nimport util.LocalIOSDevice\nimport xcuitest.api.DeviceInfo\nimport xcuitest.installer.LocalXCTestInstaller\nimport java.io.InputStream\n\nclass DeviceControlIOSDevice(override val deviceId: String) : IOSDevice {\n\n    private val localIOSDevice by lazy { LocalIOSDevice() }\n\n    companion object {\n        private val logger = LoggerFactory.getLogger(DeviceControlIOSDevice::class.java)\n    }\n\n    override fun open() {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun deviceInfo(): DeviceInfo {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun viewHierarchy(excludeKeyboardElements: Boolean): ViewHierarchy {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun tap(x: Int, y: Int) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun longPress(x: Int, y: Int, durationMs: Long) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun scroll(xStart: Double, yStart: Double, xEnd: Double, yEnd: Double, duration: Double) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun input(text: String) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun install(stream: InputStream) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun uninstall(id: String) {\n        localIOSDevice.uninstall(deviceId, id)\n    }\n\n    override fun clearAppState(id: String) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun clearKeychain(): Result<Unit, Throwable> {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun launch(id: String, launchArguments: Map<String, Any>) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun stop(id: String) {\n        error(\"not supported\")\n    }\n\n    override fun isKeyboardVisible(): Boolean {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun openLink(link: String): Result<Unit, Throwable> {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun takeScreenshot(out: Sink, compressed: Boolean) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun startScreenRecording(out: Sink): IOSScreenRecording {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun setLocation(latitude: Double, longitude: Double): Result<Unit, Throwable> {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun setOrientation(orientation: String) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun isShutdown(): Boolean {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun isScreenStatic(): Boolean {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun setPermissions(id: String, permissions: Map<String, String>) {\n        /* noop */\n    }\n\n    override fun pressKey(name: String) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun pressButton(name: String) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun eraseText(charactersToErase: Int) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun addMedia(path: String) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun close() {\n        logger.info(\"[Start] Uninstall the runner app\")\n        uninstall(id = LocalXCTestInstaller.UI_TEST_RUNNER_APP_BUNDLE_ID)\n        logger.info(\"[Done] Uninstall the runner app\")\n    }\n}"
  },
  {
    "path": "maestro-ios/src/main/java/ios/xctest/XCTestIOSDevice.kt",
    "content": "package ios.xctest\n\nimport com.github.michaelbull.result.Result\nimport device.IOSDevice\nimport hierarchy.ViewHierarchy\nimport ios.IOSDeviceErrors\nimport device.IOSScreenRecording\nimport xcuitest.api.DeviceInfo\nimport maestro.utils.DepthTracker\nimport maestro.utils.network.XCUITestServerError\nimport okio.Sink\nimport okio.buffer\nimport org.slf4j.LoggerFactory\nimport xcuitest.XCTestDriverClient\nimport java.io.InputStream\n\nclass XCTestIOSDevice(\n    override val deviceId: String?,\n    private val client: XCTestDriverClient,\n    private val getInstalledApps: () -> Set<String>,\n) : IOSDevice {\n    private val logger = LoggerFactory.getLogger(XCTestIOSDevice::class.java)\n\n    override fun open() {\n        logger.trace(\"Opening a connection\")\n        client.restartXCTestRunner()\n    }\n\n    override fun deviceInfo(): DeviceInfo {\n        return execute {\n            val deviceInfo = client.deviceInfo()\n            deviceInfo\n        }\n    }\n\n    override fun viewHierarchy(excludeKeyboardElements: Boolean): ViewHierarchy {\n        return execute {\n            // TODO(as): remove this list of apps from here once tested on cloud, we are not using this appIds now on server.\n            val viewHierarchy = client.viewHierarchy(installedApps = emptySet(), excludeKeyboardElements)\n            DepthTracker.trackDepth(viewHierarchy.depth)\n            viewHierarchy\n        }\n    }\n\n    override fun tap(x: Int, y: Int) {\n        execute {\n            client.tap(\n                x = x.toFloat(),\n                y = y.toFloat(),\n            )\n        }\n    }\n\n    override fun longPress(x: Int, y: Int, durationMs: Long) {\n        execute {\n            client.tap(\n                x = x.toFloat(),\n                y = y.toFloat(),\n                duration = durationMs.toDouble() / 1000\n            )\n        }\n    }\n\n    override fun pressKey(name: String) {\n        execute { client.pressKey(name) }\n    }\n\n    override fun pressButton(name: String) {\n        execute { client.pressButton(name) }\n    }\n\n    override fun addMedia(path: String) {\n        error(\"Not supported\")\n    }\n\n    override fun scroll(\n        xStart: Double,\n        yStart: Double,\n        xEnd: Double,\n        yEnd: Double,\n        duration: Double,\n    ) {\n        execute {\n            client.swipe(\n                appId = activeAppId(),\n                startX = xStart,\n                startY = yStart,\n                endX = xEnd,\n                endY = yEnd,\n                duration = duration\n            )\n        }\n    }\n\n    fun scrollV2(\n        xStart: Double,\n        yStart: Double,\n        xEnd: Double,\n        yEnd: Double,\n        duration: Double,\n    ) {\n        execute {\n            // TODO(as): remove this list of apps from here once tested on cloud, we are not using this appIds now on server.\n            client.swipeV2(\n                installedApps = emptySet(),\n                startX = xStart,\n                startY = yStart,\n                endX = xEnd,\n                endY = yEnd,\n                duration = duration,\n            )\n        }\n    }\n\n    override fun input(text: String) {\n       execute {\n           // TODO(as): remove this list of apps from here once tested on cloud, we are not using this appIds now on server.\n           client.inputText(\n               text = text,\n               appIds = emptySet(),\n           )\n       }\n    }\n\n    override fun install(stream: InputStream) {\n        error(\"Not supported\")\n    }\n\n    override fun uninstall(id: String) {\n        error(\"Not supported\")\n    }\n\n    override fun clearAppState(id: String) {\n        error(\"Not supported\")\n    }\n\n    override fun clearKeychain(): Result<Unit, Throwable> {\n        error(\"Not supported\")\n    }\n\n    override fun launch(\n        id: String,\n        launchArguments: Map<String, Any>,\n    ) {\n        execute {\n            client.launchApp(id)\n        }\n    }\n\n    override fun stop(id: String) {\n        execute {\n            client.terminateApp(appId = id)\n        }\n    }\n\n    override fun isKeyboardVisible(): Boolean {\n        val appIds = getInstalledApps()\n        return execute { client.keyboardInfo(appIds).isKeyboardVisible }\n    }\n\n    override fun openLink(link: String): Result<Unit, Throwable> {\n        error(\"Not supported\")\n    }\n\n    override fun takeScreenshot(out: Sink, compressed: Boolean) {\n        execute {\n            val bytes = client.screenshot(compressed)\n            out.buffer().use { it.write(bytes) }\n        }\n    }\n\n    override fun startScreenRecording(out: Sink): IOSScreenRecording {\n        error(\"Not supported\")\n    }\n\n    override fun setLocation(latitude: Double, longitude: Double): Result<Unit, Throwable> {\n        error(\"Not supported\")\n    }\n\n    override fun setOrientation(orientation: String) {\n        execute { client.setOrientation(orientation) }\n    }\n\n    override fun isShutdown(): Boolean {\n        return !client.isChannelAlive()\n    }\n\n    override fun close() {\n        client.close()\n    }\n\n    override fun isScreenStatic(): Boolean {\n        return execute {\n            val isScreenStatic = client.isScreenStatic().isScreenStatic\n            isScreenStatic\n        }\n    }\n\n    override fun setPermissions(id: String, permissions: Map<String, String>) {\n        val mutable = permissions.toMutableMap()\n        if (mutable.containsKey(\"all\")) {\n            val value = mutable.remove(\"all\")\n            allPermissions.forEach {\n                when (value) {\n                    \"allow\" -> mutable.putIfAbsent(it, \"allow\")\n                    \"deny\" -> mutable.putIfAbsent(it, \"deny\")\n                    \"unset\" -> mutable.putIfAbsent(it, \"unset\")\n                    else -> throw IllegalArgumentException(\"Permission 'all' can be set to 'allow', 'deny' or 'unset', not '$value'\")\n                }\n            }\n        }\n\n        execute { client.setPermissions(mutable) }\n    }\n\n    override fun eraseText(charactersToErase: Int) {\n        // TODO(as): remove this list of apps from here once tested on cloud, we are not using this appIds now on server.\n        execute { client.eraseText(charactersToErase, appIds = emptySet()) }\n    }\n\n    private fun activeAppId(): String {\n        return execute {\n            val appIds = getInstalledApps()\n            logger.info(\"installed apps: $appIds\")\n\n            client.runningAppId(appIds).runningAppBundleId\n        }\n    }\n\n    private fun <T> execute(call: () -> T): T {\n        return try {\n            call()\n        } catch (appCrashException: XCUITestServerError.AppCrash) {\n            throw IOSDeviceErrors.AppCrash(\n                \"App crashed or stopped while executing flow, please check diagnostic logs: \" +\n                        \"~/Library/Logs/DiagnosticReports directory\"\n            )\n        } catch (timeout: XCUITestServerError.OperationTimeout) {\n            throw IOSDeviceErrors.OperationTimeout(timeout.errorResponse)\n        }\n    }\n\n    companion object {\n        private val allPermissions = listOf(\n            \"notifications\"\n        )\n    }\n\n}\n"
  },
  {
    "path": "maestro-ios-driver/build.gradle.kts",
    "content": "import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask\n\nplugins {\n    id(\"maven-publish\")\n    alias(libs.plugins.kotlin.jvm)\n    alias(libs.plugins.mavenPublish)\n}\n\nmavenPublishing {\n    publishToMavenCentral(true)\n    signAllPublications()\n}\n\ndependencies {\n    implementation(project(\":maestro-utils\"))\n    implementation(libs.commons.io)\n\n    api(libs.square.okhttp)\n    api(libs.square.okio.jvm)\n    api(libs.square.okhttp.logs)\n    api(libs.jackson.module.kotlin)\n    api(libs.jarchivelib)\n    api(libs.kotlin.result)\n\n    api(libs.logging.sl4j)\n    api(libs.logging.api)\n    api(libs.logging.layout.template)\n    api(libs.log4j.core)\n\n    api(libs.appdirs)\n\n    testImplementation(libs.junit.jupiter.api)\n    testRuntimeOnly(libs.junit.jupiter.engine)\n    testImplementation(libs.google.truth)\n    testImplementation(libs.mockk)\n}\n\njava {\n    sourceCompatibility = JavaVersion.VERSION_17\n    targetCompatibility = JavaVersion.VERSION_17\n}\n\nkotlin {\n    jvmToolchain {\n        languageVersion.set(JavaLanguageVersion.of(17))\n    }\n}\n\ntasks.named(\"compileKotlin\", KotlinCompilationTask::class.java) {\n    compilerOptions {\n        freeCompilerArgs.addAll(\"-Xjdk-release=17\")\n    }\n}\n\ntasks.named<Test>(\"test\") {\n    useJUnitPlatform()\n}\n"
  },
  {
    "path": "maestro-ios-driver/gradle.properties",
    "content": "POM_NAME=Maestro XCUITest Driver\nPOM_ARTIFACT_ID=maestro-ios-driver\nPOM_PACKAGING=jar\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/device/IOSDevice.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage device\n\nimport com.github.michaelbull.result.Result\nimport hierarchy.ViewHierarchy\nimport xcuitest.api.DeviceInfo\nimport okio.Sink\nimport java.io.InputStream\n\ninterface IOSDevice : AutoCloseable {\n\n    val deviceId: String?\n\n    fun open()\n\n    fun deviceInfo(): DeviceInfo\n\n    fun viewHierarchy(excludeKeyboardElements: Boolean): ViewHierarchy\n\n    fun tap(x: Int, y: Int)\n\n    fun longPress(x: Int, y: Int, durationMs: Long)\n\n    fun scroll(\n        xStart: Double,\n        yStart: Double,\n        xEnd: Double,\n        yEnd: Double,\n        duration: Double,\n    )\n\n    /**\n     * Inputs text into the currently focused element.\n     */\n    fun input(text: String)\n\n    /**\n     * Installs application on the device.\n     *\n     * @param stream - input stream of zipped .app bundle\n     */\n    fun install(stream: InputStream)\n\n    /**\n     * Uninstalls the app.\n     *\n     * Idempotent. Operation succeeds if app is not installed.\n     *\n     * @param id = bundle id of the app to uninstall\n     */\n    fun uninstall(id: String)\n\n    /**\n     * Clears state of a given application.\n     *\n     * @param id = bundle id of the app to clear\n     */\n    fun clearAppState(id: String)\n\n    /**\n     * Clears device keychain.\n     */\n    fun clearKeychain(): Result<Unit, Throwable>\n\n    /**\n     * Launches the app.\n     *\n     * @param id - bundle id of the app to launch\n     */\n    fun launch(\n        id: String,\n        launchArguments: Map<String, Any>,\n    )\n\n    /**\n     * Terminates the app.\n     *\n     * @param id - bundle id of the app to terminate\n     */\n    fun stop(id: String)\n\n    fun isKeyboardVisible(): Boolean\n\n    /**\n     * Opens a link\n     *\n     * @param link - link to open\n     */\n    fun openLink(link: String): Result<Unit, Throwable>\n\n    /**\n     * Takes a screenshot and writes it into output sink\n     *\n     * @param out - output sink\n     */\n    fun takeScreenshot(out: Sink, compressed: Boolean)\n\n    /**\n     * Start a screen recording\n     *\n     * @param out - output sink\n     */\n    fun startScreenRecording(out: Sink): IOSScreenRecording\n\n    /**\n     * Sets the geolocation\n     *\n     * @param lat - latitude\n     * @param long - longitude\n     */\n    fun setLocation(latitude: Double, longitude: Double): Result<Unit, Throwable>\n\n    /**\n     * Sets the device's orientation.\n     *\n     * @param link - link to open\n     */\n    fun setOrientation(orientation: String)\n\n    /**\n     * @return true if the connection to the device (not device itself) is shut down\n     */\n    fun isShutdown(): Boolean\n\n    /**\n     * @return false if 2 consequent screenshots are equal, true if screen is static\n     */\n    fun isScreenStatic(): Boolean\n\n    fun setPermissions(id: String, permissions: Map<String, String>)\n\n    fun pressKey(name: String)\n\n    fun pressButton(name: String)\n\n    fun eraseText(charactersToErase: Int)\n\n    fun addMedia(path: String)\n}\n\ninterface IOSScreenRecording : AutoCloseable\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/device/SimctlIOSDevice.kt",
    "content": "package device\n\nimport com.github.michaelbull.result.Result\nimport com.github.michaelbull.result.onFailure\nimport com.github.michaelbull.result.runCatching\nimport hierarchy.ViewHierarchy\nimport maestro.utils.TempFileHandler\nimport xcuitest.api.DeviceInfo\nimport okio.Sink\nimport okio.buffer\nimport okio.source\nimport org.slf4j.LoggerFactory\nimport util.IOSLaunchArguments.toIOSLaunchArguments\nimport util.LocalSimulatorUtils\nimport xcuitest.installer.LocalXCTestInstaller\nimport java.io.File\nimport java.io.InputStream\nimport java.nio.channels.Channels\nimport java.nio.file.Files\n\nclass SimctlIOSDevice(\n    override val deviceId: String,\n    val tempFileHandler: TempFileHandler = TempFileHandler(),\n) : IOSDevice {\n\n    companion object {\n        private val logger = LoggerFactory.getLogger(SimctlIOSDevice::class.java)\n    }\n\n    private val localSimulatorUtils by lazy { LocalSimulatorUtils(tempFileHandler) }\n\n    private var screenRecording: LocalSimulatorUtils.ScreenRecording? = null\n\n    override fun open() {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun deviceInfo(): DeviceInfo {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun viewHierarchy(excludeKeyboardElements: Boolean): ViewHierarchy {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun tap(x: Int, y: Int) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun longPress(x: Int, y: Int, durationMs: Long) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun pressKey(name: String) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun pressButton(name: String) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun scroll(xStart: Double, yStart: Double, xEnd: Double, yEnd: Double, duration: Double) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun input(text: String) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun install(stream: InputStream) {\n        localSimulatorUtils.install(deviceId, stream)\n    }\n\n    override fun uninstall(id: String) {\n        localSimulatorUtils.uninstall(deviceId, id)\n    }\n\n    override fun clearAppState(id: String) {\n        localSimulatorUtils.clearAppState(deviceId, id)\n    }\n\n    override fun clearKeychain(): Result<Unit, Throwable> {\n        return runCatching {\n            localSimulatorUtils.clearKeychain(deviceId)\n        }\n    }\n\n    override fun launch(\n        id: String,\n        launchArguments: Map<String, Any>,\n    ) {\n        val iOSLaunchArguments = launchArguments.toIOSLaunchArguments()\n        localSimulatorUtils.launch(\n            deviceId = deviceId,\n            bundleId = id,\n            launchArguments = iOSLaunchArguments,\n        )\n    }\n\n    override fun stop(id: String) {\n        localSimulatorUtils.terminate(deviceId, bundleId = id)\n    }\n\n    override fun isKeyboardVisible(): Boolean {\n        error(\"Not Supported\")\n    }\n\n    override fun openLink(link: String): Result<Unit, Throwable> {\n        return runCatching {\n            localSimulatorUtils.openURL(deviceId, link)\n        }\n    }\n\n    override fun takeScreenshot(out: Sink, compressed: Boolean) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun startScreenRecording(out: Sink): IOSScreenRecording {\n        val screenRecording = localSimulatorUtils.startScreenRecording(deviceId)\n        this.screenRecording = screenRecording\n\n        return object : IOSScreenRecording {\n            override fun close() {\n                val file = stopScreenRecording() ?: return\n                val byteChannel = Files.newByteChannel(file.toPath())\n                val source = Channels.newInputStream(byteChannel).source().buffer()\n                val buffer = out.buffer()\n\n                buffer.writeAll(source)\n\n                byteChannel.close()\n                buffer.close()\n                file.delete()\n            }\n        }\n    }\n\n    private fun stopScreenRecording(): File? {\n        return screenRecording\n            ?.let { localSimulatorUtils.stopScreenRecording(it) }\n            .also { screenRecording = null }\n    }\n\n    override fun addMedia(path: String) {\n        localSimulatorUtils.addMedia(deviceId, path)\n    }\n\n    override fun setLocation(latitude: Double, longitude: Double): Result<Unit, Throwable> {\n        return runCatching {\n            localSimulatorUtils.setLocation(deviceId, latitude, longitude)\n        }\n    }\n\n    override fun setOrientation(orientation: String) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun isShutdown(): Boolean {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun isScreenStatic(): Boolean {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun setPermissions(id: String, permissions: Map<String, String>) {\n        val formattedPermissions = permissions.entries.joinToString(separator = \", \") { \"${it.key}=${it.value}\" }\n\n        runCatching {\n            logger.info(\"[Start] Setting permissions $formattedPermissions through applesimutils\")\n            localSimulatorUtils.setAppleSimutilsPermissions(deviceId, id, permissions)\n            logger.info(\"[Done] Setting permissions through applesimutils\")\n        }.onFailure {\n            logger.error(\"Failed setting permissions $permissions via applesimutils\", it)\n        }\n\n        logger.info(\"[Start] Setting Permissions $formattedPermissions through simctl\")\n        localSimulatorUtils.setSimctlPermissions(deviceId, id, permissions)\n        logger.info(\"[Done] Setting Permissions $formattedPermissions through simctl\")\n    }\n\n    override fun eraseText(charactersToErase: Int) {\n        TODO(\"Not yet implemented\")\n    }\n\n    override fun close() {\n        stopScreenRecording()\n\n        logger.info(\"[Start] Stop and uninstall the runner app\")\n        stop(id = LocalXCTestInstaller.UI_TEST_RUNNER_APP_BUNDLE_ID)\n        uninstall(id = LocalXCTestInstaller.UI_TEST_RUNNER_APP_BUNDLE_ID)\n        logger.info(\"[Done] Stop and uninstall the runner app\")\n    }\n\n}\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/hierarchy/AXElement.kt",
    "content": "package hierarchy\n\nimport com.fasterxml.jackson.annotation.JsonProperty\n\ndata class AXFrame(\n    @JsonProperty(\"X\") val x: Float,\n    @JsonProperty(\"Y\") val y: Float,\n    @JsonProperty(\"Width\") val width: Float,\n    @JsonProperty(\"Height\") val height: Float,\n) {\n    val left = x\n    val right = x + width\n    val top = y\n    val bottom = y + height\n    val boundsString = \"[${left.toInt()},${top.toInt()}][${right.toInt()},${bottom.toInt()}]\"\n}\n\ndata class ViewHierarchy(\n    val axElement: AXElement,\n    val depth: Int\n)\n\ndata class AXElement(\n    val label: String,\n    val elementType: Int,\n    val identifier: String,\n    val horizontalSizeClass: Int,\n    val windowContextID: Long,\n    val verticalSizeClass: Int,\n    val selected: Boolean,\n    val displayID: Int,\n    val hasFocus: Boolean,\n    val placeholderValue: String?,\n    val value: String?,\n    val frame: AXFrame,\n    val enabled: Boolean,\n    val title: String?,\n    val children: ArrayList<AXElement>,\n)\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/util/CommandLineUtils.kt",
    "content": "package util\n\nimport java.io.File\nimport java.util.concurrent.TimeUnit\nimport java.util.concurrent.TimeoutException\nimport okio.buffer\nimport okio.source\nimport org.slf4j.LoggerFactory\n\nobject CommandLineUtils {\n\n    private val isWindows = System.getProperty(\"os.name\").startsWith(\"Windows\")\n    private val nullFile = File(if (isWindows) \"NUL\" else \"/dev/null\")\n    private val logger = LoggerFactory.getLogger(CommandLineUtils::class.java)\n\n    @Suppress(\"SpreadOperator\")\n    fun runCommand(\n            parts: List<String>,\n            waitForCompletion: Boolean = true,\n            outputFile: File? = null,\n            params: Map<String, String> = emptyMap()\n    ): Process {\n        logger.info(\"Running command line operation: $parts with $params\")\n\n        val processBuilder =\n                if (outputFile != null) {\n                    ProcessBuilder(*parts.toTypedArray())\n                            .redirectOutput(outputFile)\n                            .redirectError(outputFile)\n                } else {\n                    ProcessBuilder(*parts.toTypedArray())\n                            .redirectOutput(nullFile)\n                            .redirectError(ProcessBuilder.Redirect.PIPE)\n                }\n\n        processBuilder.environment().putAll(params)\n        val process = processBuilder.start()\n\n        if (waitForCompletion) {\n            if (!process.waitFor(5, TimeUnit.MINUTES)) {\n                throw TimeoutException()\n            }\n\n            if (process.exitValue() != 0) {\n                val processOutput = process.errorStream.source().buffer().readUtf8()\n\n                logger.error(\"Process failed with exit code ${process.exitValue()}\")\n                logger.error(\"Error output $processOutput\")\n\n                throw IllegalStateException(processOutput)\n            }\n        }\n\n        return process\n    }\n}\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/util/IOSDevice.kt",
    "content": "package util\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties\n\nenum class IOSDeviceType {\n    REAL,\n    SIMULATOR\n}\n\n@JsonIgnoreProperties(ignoreUnknown = true)\ndata class DeviceCtlResponse(\n    val result: Result\n) {\n    @JsonIgnoreProperties(ignoreUnknown = true)\n    data class Result(\n        val devices: List<Device>\n    )\n\n    @JsonIgnoreProperties(ignoreUnknown = true)\n    data class Device(\n        val identifier: String,\n        val deviceProperties: DeviceProperties?,\n        val hardwareProperties: HardwareProperties?,\n        val connectionProperties: ConnectionProperties,\n    )\n\n    @JsonIgnoreProperties(ignoreUnknown = true)\n    data class ConnectionProperties(\n        val tunnelState: String,\n    ) {\n        companion object {\n            const val CONNECTED  = \"connected\"\n        }\n    }\n\n    @JsonIgnoreProperties(ignoreUnknown = true)\n    data class DeviceProperties(\n        val name: String?,\n        val osVersionNumber: String?,\n    )\n\n    @JsonIgnoreProperties(ignoreUnknown = true)\n    data class HardwareProperties(\n        val udid: String?\n    )\n}\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/util/IOSLaunchArguments.kt",
    "content": "package util\n\nobject IOSLaunchArguments {\n\n    fun Map<String, Any>.toIOSLaunchArguments(): List<String> {\n        if (isEmpty()) return emptyList()\n\n        val iOSLaunchArgumentsMap = mutableMapOf<String, Any>()\n        forEach { (key, value) ->\n            if (value is Boolean) {\n                iOSLaunchArgumentsMap[key] = value\n            } else {\n                if (!key.startsWith(\"-\")) {\n                    iOSLaunchArgumentsMap[\"-$key\"] = value\n                } else {\n                    iOSLaunchArgumentsMap[key] = value\n                }\n            }\n        }\n        return iOSLaunchArgumentsMap.toList().flatMap { listOf(it.first, it.second.toString()) }\n    }\n}"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/util/LocalIOSDevice.kt",
    "content": "package util\n\nimport com.fasterxml.jackson.databind.DeserializationFeature\nimport com.fasterxml.jackson.module.kotlin.KotlinFeature\nimport com.fasterxml.jackson.module.kotlin.KotlinModule\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport com.fasterxml.jackson.module.kotlin.readValue\nimport java.io.File\n\nclass DeviceCtlProcess {\n\n    fun devicectlDevicesOutput(): File {\n        val tempOutput = File.createTempFile(\"devicectl_response\", \".json\")\n        ProcessBuilder(listOf(\"xcrun\", \"devicectl\", \"--json-output\", tempOutput.path, \"list\", \"devices\"))\n            .redirectError(ProcessBuilder.Redirect.PIPE).start().apply {\n                waitFor()\n            }\n\n        return tempOutput\n    }\n}\n\nclass LocalIOSDevice(private val deviceCtlProcess: DeviceCtlProcess = DeviceCtlProcess()) {\n\n    fun uninstall(deviceId: String, bundleIdentifier: String) {\n        CommandLineUtils.runCommand(\n            listOf(\n                \"xcrun\",\n                \"devicectl\",\n                \"device\",\n                \"uninstall\",\n                \"app\",\n                \"--device\",\n                deviceId,\n                bundleIdentifier\n            )\n        )\n    }\n\n    fun listDeviceViaDeviceCtl(deviceId: String): DeviceCtlResponse.Device {\n        val tempOutput = File.createTempFile(\"devicectl_response\", \".json\")\n        try {\n            ProcessBuilder(listOf(\"xcrun\" , \"devicectl\", \"--json-output\", tempOutput.path, \"list\", \"devices\"))\n                .redirectError(ProcessBuilder.Redirect.PIPE).start().apply {\n                    waitFor()\n                }\n            val bytes = tempOutput.readBytes()\n            val response = String(bytes)\n\n            val jacksonObjectMapper = jacksonObjectMapper()\n            jacksonObjectMapper.configure(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES, false)\n            val deviceCtlResponse = jacksonObjectMapper.readValue<DeviceCtlResponse>(response)\n            return deviceCtlResponse.result.devices.find {\n                it.hardwareProperties?.udid == deviceId\n            } ?: throw IllegalArgumentException(\"iOS device with identifier $deviceId not connected or available\")\n        } finally {\n            tempOutput.delete()\n        }\n    }\n\n    fun listDeviceViaDeviceCtl(): List<DeviceCtlResponse.Device> {\n        val tempOutput = deviceCtlProcess.devicectlDevicesOutput()\n        try {\n            val bytes = tempOutput.readBytes()\n            val response = String(bytes)\n\n            val deviceCtlResponse = jacksonObjectMapper().readValue<DeviceCtlResponse>(response)\n            return deviceCtlResponse.result.devices\n        } finally {\n            tempOutput.delete()\n        }\n    }\n}"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/util/LocalIOSDeviceController.kt",
    "content": "package util\n\nimport util.CommandLineUtils.runCommand\nimport java.io.File\nimport java.nio.file.Path\nimport java.time.LocalDateTime\nimport java.time.format.DateTimeFormatter\n\nobject LocalIOSDeviceController {\n\n    private const val LOG_DIR_DATE_FORMAT = \"yyyy-MM-dd_HHmmss\"\n    private val dateFormatter by lazy { DateTimeFormatter.ofPattern(LOG_DIR_DATE_FORMAT) }\n    private val date = dateFormatter.format(LocalDateTime.now())\n\n    fun install(deviceId: String, path: Path) {\n        runCommand(\n            listOf(\n                \"xcrun\",\n                \"devicectl\",\n                \"device\",\n                \"install\",\n                \"app\",\n                \"--device\",\n                deviceId,\n                path.toAbsolutePath().toString(),\n            )\n        )\n    }\n\n    fun launchRunner(deviceId: String, port: Int, snapshotKeyHonorModalViews: Boolean?) {\n        val outputFile = File(XCRunnerCLIUtils.logDirectory, \"xctest_runner_$date.log\")\n        val params = mutableMapOf(\"SIMCTL_CHILD_PORT\" to port.toString())\n        if (snapshotKeyHonorModalViews != null) {\n            params[\"SIMCTL_CHILD_snapshotKeyHonorModalViews\"] = snapshotKeyHonorModalViews.toString()\n        }\n        runCommand(\n            listOf(\n                \"xcrun\",\n                \"devicectl\",\n                \"device\",\n                \"process\",\n                \"launch\",\n                \"--terminate-existing\",\n                \"--device\",\n                deviceId,\n                \"dev.mobile.maestro-driver-iosUITests.xctrunner\"\n            ),\n            params = params,\n            waitForCompletion = false,\n            outputFile = outputFile\n        )\n    }\n}"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/util/LocalSimulatorUtils.kt",
    "content": "package util\n\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport com.fasterxml.jackson.module.kotlin.readValue\nimport maestro.utils.MaestroTimer\nimport maestro.utils.TempFileHandler\nimport org.rauschig.jarchivelib.ArchiveFormat\nimport org.rauschig.jarchivelib.ArchiverFactory\nimport org.slf4j.LoggerFactory\nimport util.CommandLineUtils.runCommand\nimport java.io.File\nimport java.io.InputStream\nimport java.lang.ProcessBuilder.Redirect.PIPE\nimport java.nio.file.Files\nimport java.nio.file.Path\nimport java.time.LocalDateTime\nimport java.time.format.DateTimeFormatter\nimport kotlin.io.path.Path\n\nclass LocalSimulatorUtils(private val tempFileHandler: TempFileHandler) {\n\n    data class SimctlError(override val message: String, override val cause: Throwable? = null) : Throwable(message, cause)\n\n    companion object {\n        private const val LOG_DIR_DATE_FORMAT = \"yyyy-MM-dd_HHmmss\"\n    }\n\n    private val homedir = System.getProperty(\"user.home\")\n    private val dateFormatter by lazy { DateTimeFormatter.ofPattern(LOG_DIR_DATE_FORMAT) }\n    private val date = dateFormatter.format(LocalDateTime.now())\n\n    private val logger = LoggerFactory.getLogger(LocalSimulatorUtils::class.java)\n\n    private val allPermissions = listOf(\n        \"calendar\",\n        \"camera\",\n        \"contacts\",\n        \"faceid\",\n        \"homekit\",\n        \"medialibrary\",\n        \"microphone\",\n        \"motion\",\n        \"photos\",\n        \"reminders\",\n        \"siri\",\n        \"speech\",\n        \"userTracking\",\n    )\n\n    private val simctlPermissions = listOf(\n        \"location\"\n    )\n\n    fun list(): SimctlList {\n        val command = listOf(\"xcrun\", \"simctl\", \"list\", \"-j\")\n\n        val process = ProcessBuilder(command).start()\n        val json = String(process.inputStream.readBytes())\n\n        return jacksonObjectMapper().readValue(json)\n    }\n\n    fun awaitLaunch(deviceId: String) {\n        MaestroTimer.withTimeout(60000) {\n            if (list()\n                    .devices\n                    .values\n                    .flatten()\n                    .find { it.udid.equals(deviceId, ignoreCase = true) }\n                    ?.state == \"Booted\"\n            ) true else null\n        } ?: throw SimctlError(\"Device $deviceId did not boot in time\")\n    }\n\n    fun awaitShutdown(deviceId: String, timeoutMs: Long = 60000) {\n        MaestroTimer.withTimeout(timeoutMs) {\n            if (list()\n                    .devices\n                    .values\n                    .flatten()\n                    .find { it.udid.equals(deviceId, ignoreCase = true) }\n                    ?.state == \"Shutdown\"\n            ) true else null\n        } ?: throw SimctlError(\"Device $deviceId did not shutdown in time\")\n    }\n\n    private fun xcodePath(): String {\n        val process = ProcessBuilder(listOf(\"xcode-select\", \"-p\"))\n            .start()\n\n        return process.inputStream.bufferedReader().readLine()\n    }\n\n    fun bootSimulator(deviceId: String) {\n        runCommand(\n            listOf(\n                \"xcrun\",\n                \"simctl\",\n                \"boot\",\n                deviceId\n            ),\n            waitForCompletion = true\n        )\n        awaitLaunch(deviceId)\n    }\n\n    fun shutdownSimulator(deviceId: String) {\n        runCommand(\n            listOf(\n                \"xcrun\",\n                \"simctl\",\n                \"shutdown\",\n                deviceId\n            ),\n            waitForCompletion = true\n        )\n        awaitShutdown(deviceId)\n    }\n\n    fun launchSimulator(deviceId: String) {\n        val simulatorPath = \"${xcodePath()}/Applications/Simulator.app\"\n        var exceptionToThrow: Exception? = null\n\n        // Up to 10 iterations => max wait time of 1 second\n        repeat(10) {\n            try {\n                runCommand(\n                    listOf(\n                        \"open\",\n                        \"-a\",\n                        simulatorPath,\n                        \"--args\",\n                        \"-CurrentDeviceUDID\",\n                        deviceId\n                    )\n                )\n                return\n            } catch (e: Exception) {\n                exceptionToThrow = e\n                Thread.sleep(100)\n            }\n        }\n\n        exceptionToThrow?.let { throw it }\n    }\n\n    fun reboot(\n        deviceId: String,\n    ) {\n        shutdownSimulator(deviceId)\n        bootSimulator(deviceId)\n    }\n\n    fun addTrustedCertificate(\n        deviceId: String,\n        certificate: File,\n    ) {\n        runCommand(\n            listOf(\n                \"xcrun\",\n                \"simctl\",\n                \"keychain\",\n                deviceId,\n                \"add-root-cert\",\n                certificate.absolutePath,\n            ),\n            waitForCompletion = true\n        )\n\n        reboot(deviceId)\n    }\n\n    fun terminate(deviceId: String, bundleId: String) {\n        // Ignore error return: terminate will fail if the app is not running\n        logger.info(\"[Start] Terminating app $bundleId\")\n        runCatching {\n            runCommand(\n                listOf(\n                    \"xcrun\",\n                    \"simctl\",\n                    \"terminate\",\n                    deviceId,\n                    bundleId\n                )\n            )\n        }.onFailure {\n            if (it.message?.contains(\"found nothing to terminate\") == false) {\n                logger.info(\"The bundle $bundleId is already terminated\")\n                throw it\n            }\n        }\n        logger.info(\"[Done] Terminating app $bundleId\")\n    }\n\n    private fun isAppRunning(deviceId: String, bundleId: String): Boolean {\n        val process = ProcessBuilder(\n            listOf(\n                \"xcrun\",\n                \"simctl\",\n                \"spawn\",\n                deviceId,\n                \"launchctl\",\n                \"list\",\n            )\n        ).start()\n\n        return String(process.inputStream.readBytes()).trimEnd().contains(bundleId)\n    }\n\n    private fun ensureStopped(deviceId: String, bundleId: String) {\n        MaestroTimer.withTimeout(10000) {\n            while (true) {\n                if (isAppRunning(deviceId, bundleId)) {\n                    Thread.sleep(1000)\n                } else {\n                    return@withTimeout\n                }\n            }\n        } ?: throw SimctlError(\"App $bundleId did not stop in time\")\n    }\n\n    private fun ensureRunning(deviceId: String, bundleId: String) {\n        MaestroTimer.withTimeout(10000) {\n            while (true) {\n                if (isAppRunning(deviceId, bundleId)) {\n                    return@withTimeout\n                } else {\n                    Thread.sleep(1000)\n                }\n            }\n        } ?: throw SimctlError(\"App $bundleId did not start in time\")\n    }\n\n    private fun copyDirectoryRecursively(source: Path, target: Path) {\n        Files.walk(source).forEach { path ->\n            val targetPath = target.resolve(source.relativize(path).toString())\n            if (Files.isDirectory(path)) {\n                Files.createDirectories(targetPath)\n            } else {\n                Files.copy(path, targetPath)\n            }\n        }\n    }\n\n    private fun deleteFolderRecursively(folder: File): Boolean {\n        if (folder.isDirectory) {\n            folder.listFiles()?.forEach { child ->\n                deleteFolderRecursively(child)\n            }\n        }\n        return folder.delete()\n    }\n\n    private fun reinstallApp(deviceId: String, bundleId: String) {\n        val appBinaryPath = getAppBinaryDirectory(deviceId, bundleId)\n        if (appBinaryPath.isEmpty()) {\n            throw SimctlError(\"Could not find app binary for bundle $bundleId at $appBinaryPath\")\n        }\n\n        val pathToBinary = Path(appBinaryPath)\n        if (Files.isDirectory(pathToBinary)) {\n            val tmpDir = tempFileHandler.createTempDirectory().toPath()\n            val tmpBundlePath = tmpDir.resolve(\"$bundleId-${System.currentTimeMillis()}.app\")\n\n            logger.info(\"Copying app binary from $pathToBinary to $tmpBundlePath\")\n            Files.copy(pathToBinary, tmpBundlePath)\n            copyDirectoryRecursively(pathToBinary, tmpBundlePath)\n\n            logger.info(\"Reinstalling and launching $bundleId\")\n            uninstall(deviceId, bundleId)\n            install(deviceId, tmpBundlePath)\n            deleteFolderRecursively(tmpBundlePath.toFile())\n            logger.info(\"App $bundleId reinstalled and launched\")\n        } else {\n            throw SimctlError(\"Could not find app binary for bundle $bundleId at $pathToBinary\")\n        }\n    }\n\n    fun clearAppState(deviceId: String, bundleId: String) {\n        logger.info(\"Clearing app $bundleId state\")\n        // Stop the app before clearing the file system\n        // This prevents the app from saving its state after it has been cleared\n        terminate(deviceId, bundleId)\n        ensureStopped(deviceId, bundleId)\n\n        // reinstall the app as that is the most stable way to clear state\n        reinstallApp(deviceId, bundleId)\n    }\n\n    private fun getAppBinaryDirectory(deviceId: String, bundleId: String): String {\n        val process = ProcessBuilder(\n            listOf(\n                \"xcrun\",\n                \"simctl\",\n                \"get_app_container\",\n                deviceId,\n                bundleId,\n            )\n        ).start()\n\n        val output = String(process.inputStream.readBytes()).trimEnd()\n        val errorOutput = String(process.errorStream.readBytes()).trimEnd()\n        val exitCode = process.waitFor() //avoiding race conditions\n\n        if (exitCode != 0) {\n            throw SimctlError(\"Failed to get app binary directory for bundle $bundleId on device $deviceId: $errorOutput\")\n        }\n        return output\n    }\n\n    private fun getApplicationDataDirectory(deviceId: String, bundleId: String): String {\n        val process = ProcessBuilder(\n            listOf(\n                \"xcrun\",\n                \"simctl\",\n                \"get_app_container\",\n                deviceId,\n                bundleId,\n                \"data\"\n            )\n        ).start()\n\n        return String(process.inputStream.readBytes()).trimEnd()\n    }\n\n    fun launch(\n        deviceId: String,\n        bundleId: String,\n        launchArguments: List<String> = emptyList(),\n    ) {\n        runCommand(\n            listOf(\n                \"xcrun\",\n                \"simctl\",\n                \"launch\",\n                deviceId,\n                bundleId,\n            ) + launchArguments,\n        )\n    }\n\n    fun launchUITestRunner(\n        deviceId: String,\n        port: Int,\n        snapshotKeyHonorModalViews: Boolean?,\n    ) {\n        val outputFile = File(XCRunnerCLIUtils.logDirectory, \"xctest_runner_$date.log\")\n        val params = mutableMapOf(\"SIMCTL_CHILD_PORT\" to port.toString())\n        if (snapshotKeyHonorModalViews != null) {\n            params[\"SIMCTL_CHILD_snapshotKeyHonorModalViews\"] = snapshotKeyHonorModalViews.toString()\n        }\n        runCommand(\n            listOf(\n                \"xcrun\",\n                \"simctl\",\n                \"launch\",\n                \"--console\",\n                \"--terminate-running-process\",\n                deviceId,\n                \"dev.mobile.maestro-driver-iosUITests.xctrunner\"\n            ),\n            params = params,\n            outputFile = outputFile,\n            waitForCompletion = false,\n        )\n    }\n\n    fun setLocation(deviceId: String, latitude: Double, longitude: Double) {\n        runCommand(\n            listOf(\n                \"xcrun\",\n                \"simctl\",\n                \"location\",\n                deviceId,\n                \"set\",\n                \"$latitude,$longitude\",\n            )\n        )\n    }\n\n    fun openURL(deviceId: String, url: String) {\n        runCommand(\n            listOf(\n                \"xcrun\",\n                \"simctl\",\n                \"openurl\",\n                deviceId,\n                url,\n            )\n        )\n    }\n\n    fun uninstall(deviceId: String, bundleId: String) {\n        runCommand(\n            listOf(\n                \"xcrun\",\n                \"simctl\",\n                \"uninstall\",\n                deviceId,\n                bundleId\n            )\n        )\n    }\n\n    fun addMedia(deviceId: String, path: String) {\n        runCommand(\n            listOf(\n                \"xcrun\",\n                \"simctl\",\n                \"addmedia\",\n                deviceId,\n                path\n            )\n        )\n    }\n\n    fun clearKeychain(deviceId: String) {\n        runCommand(\n            listOf(\"xcrun\", \"simctl\", \"keychain\", deviceId, \"reset\")\n        )\n    }\n\n    fun setAppleSimutilsPermissions(deviceId: String, bundleId: String, permissions: Map<String, String>) {\n        val permissionsMap = permissions.toMutableMap()\n        val effectivePermissionsMap = mutableMapOf<String, String>()\n\n        if (permissionsMap.containsKey(\"all\")) {\n            val value = permissionsMap.remove(\"all\")\n            allPermissions.forEach {\n                when (value) {\n                    \"allow\" -> effectivePermissionsMap.putIfAbsent(it, allowValueForPermission(it))\n                    \"deny\" -> effectivePermissionsMap.putIfAbsent(it, denyValueForPermission(it))\n                    \"unset\" -> effectivePermissionsMap.putIfAbsent(it, \"unset\")\n                    else -> throw IllegalArgumentException(\"Permission 'all' can be set to 'allow', 'deny' or 'unset', not '$value'\")\n                }\n            }\n        }\n\n        // Write the explicit permissions, potentially overriding the 'all' permissions\n        permissionsMap.forEach {\n            if (allPermissions.contains(it.key)) {\n                effectivePermissionsMap[it.key] = it.value\n            }\n        }\n\n        val permissionsArgument = effectivePermissionsMap\n            .filter { allPermissions.contains(it.key) }\n            .map { \"${it.key}=${translatePermissionValue(it.value)}\" }\n            .joinToString(\",\")\n\n        if (permissionsArgument.isNotEmpty()) {\n            try {\n                logger.info(\"[Start] Setting permissions via pinned applesimutils\")\n                runCommand(\n                    listOf(\n                        \"$homedir/.maestro/deps/applesimutils\",\n                        \"--byId\",\n                        deviceId,\n                        \"--bundle\",\n                        bundleId,\n                        \"--setPermissions\",\n                        permissionsArgument\n                    )\n                )\n                logger.info(\"[Done] Setting permissions pinned applesimutils\")\n            } catch (e: Exception) {\n                logger.error(\"Exception while setting permissions through pinned applesimutils ${e.message}\", e)\n                logger.info(\"[Start] Setting permissions via applesimutils as fallback\")\n                runCommand(\n                    listOf(\n                        \"applesimutils\",\n                        \"--byId\",\n                        deviceId,\n                        \"--bundle\",\n                        bundleId,\n                        \"--setPermissions\",\n                        permissionsArgument\n                    )\n                )\n                logger.info(\"[Done] Setting permissions via applesimutils as fallback\")\n            }\n        }\n    }\n\n    fun setSimctlPermissions(deviceId: String, bundleId: String, permissions: Map<String, String>) {\n        val permissionsMap = permissions.toMutableMap()\n        val effectivePermissionsMap = mutableMapOf<String, String>()\n\n        permissionsMap.remove(\"all\")?.let { value ->\n            val transformedPermissions = simctlPermissions.associateWith { permission ->\n                val newValue = when (value) {\n                    \"allow\" -> allowValueForPermission(permission)\n                    \"deny\" -> denyValueForPermission(permission)\n                    \"unset\" -> \"unset\"\n                    else -> throw IllegalArgumentException(\"Permission 'all' can be set to 'allow', 'deny', or 'unset', not '$value'\")\n                }\n                newValue\n            }\n\n            effectivePermissionsMap.putAll(transformedPermissions)\n        }\n\n        // Write the explicit permissions, potentially overriding the 'all' permissions\n        permissionsMap.forEach {\n            if (simctlPermissions.contains(it.key)) {\n                effectivePermissionsMap[it.key] = it.value\n            }\n        }\n\n        effectivePermissionsMap\n            .forEach {\n                if (simctlPermissions.contains(it.key)) {\n                    when (it.key) {\n                        // TODO: more simctl supported permissions can be migrated here\n                        \"location\" -> {\n                            setLocationPermission(deviceId, bundleId, it.value)\n                        }\n                    }\n                }\n            }\n    }\n\n    private fun setLocationPermission(deviceId: String, bundleId: String, value: String) {\n        when (value) {\n            \"always\" -> {\n                runCommand(\n                    listOf(\n                        \"xcrun\",\n                        \"simctl\",\n                        \"privacy\",\n                        deviceId,\n                        \"grant\",\n                        \"location-always\",\n                        bundleId\n                    )\n                )\n            }\n\n            \"inuse\" -> {\n                runCommand(\n                    listOf(\n                        \"xcrun\",\n                        \"simctl\",\n                        \"privacy\",\n                        deviceId,\n                        \"grant\",\n                        \"location\",\n                        bundleId\n                    )\n                )\n            }\n\n            \"never\" -> {\n                runCommand(\n                    listOf(\n                        \"xcrun\",\n                        \"simctl\",\n                        \"privacy\",\n                        deviceId,\n                        \"revoke\",\n                        \"location-always\",\n                        bundleId\n                    )\n                )\n            }\n\n            \"unset\" -> {\n                runCommand(\n                    listOf(\n                        \"xcrun\",\n                        \"simctl\",\n                        \"privacy\",\n                        deviceId,\n                        \"reset\",\n                        \"location-always\",\n                        bundleId\n                    )\n                )\n            }\n\n            else -> throw IllegalArgumentException(\"wrong argument value '$value' was provided for 'location' permission\")\n        }\n    }\n\n    private fun translatePermissionValue(value: String): String {\n        return when (value) {\n            \"allow\" -> \"YES\"\n            \"deny\" -> \"NO\"\n            else -> value\n        }\n    }\n\n    private fun allowValueForPermission(permission: String): String {\n        return when (permission) {\n            \"location\" -> \"always\"\n            else -> \"YES\"\n        }\n    }\n\n    private fun denyValueForPermission(permission: String): String {\n        return when (permission) {\n            \"location\" -> \"never\"\n            else -> \"NO\"\n        }\n    }\n\n    fun install(deviceId: String, path: Path) {\n        runCommand(\n            listOf(\n                \"xcrun\",\n                \"simctl\",\n                \"install\",\n                deviceId,\n                path.toAbsolutePath().toString(),\n            )\n        )\n    }\n\n    fun install(deviceId: String, stream: InputStream) {\n        val extractDir = tempFileHandler.createTempDirectory()\n\n        ArchiverFactory\n            .createArchiver(ArchiveFormat.ZIP)\n            .extract(stream, extractDir)\n\n        val app = extractDir.walk()\n            .filter { it.name.endsWith(\".app\") }\n            .first()\n\n        runCommand(\n            listOf(\n                \"xcrun\",\n                \"simctl\",\n                \"install\",\n                deviceId,\n                app.absolutePath,\n            )\n        )\n    }\n\n    data class ScreenRecording(\n        val process: Process,\n        val file: File,\n    )\n\n    fun startScreenRecording(deviceId: String): ScreenRecording {\n        val tempDir = tempFileHandler.createTempDirectory()\n        val inputStream = LocalSimulatorUtils::class.java.getResourceAsStream(\"/screenrecord.sh\")\n        if (inputStream != null) {\n            val recording = File(tempDir, \"screenrecording.mov\")\n\n            val processBuilder = ProcessBuilder(\n                listOf(\n                    \"bash\",\n                    \"-c\",\n                    inputStream.bufferedReader().readText()\n                )\n            )\n            val environment = processBuilder.environment()\n            environment[\"DEVICE_ID\"] = deviceId\n            environment[\"RECORDING_PATH\"] = recording.path\n\n            val recordingProcess = processBuilder\n                .redirectInput(PIPE)\n                .redirectErrorStream(true)\n                .start()\n\n            val firstLine = recordingProcess.inputStream.bufferedReader().readLine()\n\n            if (firstLine == null || !firstLine.startsWith(\"RECORDING_STARTED\")) {\n                recordingProcess.waitFor()\n                throw SimctlError(\n                    \"Screen recording failed to start: ${firstLine ?: \"no output from recording process\"}\"\n                )\n            }\n\n            return ScreenRecording(\n                recordingProcess,\n                recording\n            )\n        } else {\n            throw IllegalStateException(\"screenrecord.sh file not found\")\n        }\n    }\n\n    fun setDeviceLanguage(deviceId: String, language: String) {\n        runCommand(\n            listOf(\n                \"xcrun\",\n                \"simctl\",\n                \"spawn\",\n                deviceId,\n                \"defaults\",\n                \"write\",\n                \".GlobalPreferences.plist\",\n                \"AppleLanguages\",\n                \"($language)\"\n            )\n        )\n    }\n\n    fun setDeviceLocale(deviceId: String, locale: String) {\n        runCommand(\n            listOf(\n                \"xcrun\",\n                \"simctl\",\n                \"spawn\",\n                deviceId,\n                \"defaults\",\n                \"write\",\n                \".GlobalPreferences.plist\",\n                \"AppleLocale\",\n                \"-string\",\n                locale\n            )\n        )\n    }\n\n    fun stopScreenRecording(screenRecording: ScreenRecording): File {\n        screenRecording.process.outputStream.close()\n        screenRecording.process.waitFor()\n        return screenRecording.file\n    }\n}\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/util/PrintUtils.kt",
    "content": "package util\n\nobject PrintUtils {\n\n    fun log(message: String) {\n        println(message)\n    }\n}"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/util/SimctlList.kt",
    "content": "package util\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties\n\n@JsonIgnoreProperties(ignoreUnknown = true)\ndata class SimctlList(\n    val devicetypes: List<DeviceType>,\n    val runtimes: List<Runtime>,\n    val devices: Map<String, List<Device>>,\n    val pairs: Map<String, Pair>,\n) {\n    @JsonIgnoreProperties(ignoreUnknown = true)\n    data class DeviceType(\n        val identifier: String,\n        val name: String,\n        val bundlePath: String,\n        val productFamily: String,\n        val maxRuntimeVersion: Long?,\n        val maxRuntimeVersionString: String?,\n        val minRuntimeVersion: Long?,\n        val minRuntimeVersionString: String?,\n        val modelIdentifier: String?,\n    )\n\n    @JsonIgnoreProperties(ignoreUnknown = true)\n    data class Runtime(\n        val bundlePath: String,\n        val buildversion: String,\n        val platform: String?,\n        val runtimeRoot: String,\n        val identifier: String,\n        val version: String,\n        val isInternal: Boolean,\n        val isAvailable: Boolean,\n        val name: String,\n        val supportedDeviceTypes: List<DeviceType>,\n    )\n\n    @JsonIgnoreProperties(ignoreUnknown = true)\n    data class Device(\n        val name: String,\n        val dataPath: String?,\n        val logPath: String?,\n        val udid: String,\n        val isAvailable: Boolean,\n        val deviceTypeIdentifier: String?,\n        val state: String,\n        val availabilityError: String?,\n    )\n\n    @JsonIgnoreProperties(ignoreUnknown = true)\n    data class Pair(\n        val watch: Device,\n        val phone: Device,\n        val state: String,\n    )\n}\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/util/XCRunnerCLIUtils.kt",
    "content": "package util\n\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport maestro.utils.TempFileHandler\nimport net.harawata.appdirs.AppDirsFactory\nimport java.io.File\nimport java.nio.file.Files\nimport java.time.LocalDateTime\nimport java.time.format.DateTimeFormatter\nimport java.util.concurrent.TimeUnit\nimport kotlin.io.path.absolutePathString\n\nclass XCRunnerCLIUtils(private val tempFileHandler: TempFileHandler = TempFileHandler()) {\n\n    companion object {\n        private const val APP_NAME = \"maestro\"\n        private const val APP_AUTHOR = \"mobile_dev\"\n        private const val LOG_DIR_DATE_FORMAT = \"yyyy-MM-dd_HHmmss\"\n        private const val MAX_COUNT_XCTEST_LOGS = 5\n\n        internal val logDirectory by lazy {\n            val parentName = AppDirsFactory.getInstance().getUserLogDir(APP_NAME, null, APP_AUTHOR)\n            val logsDirectory = File(parentName, \"xctest_runner_logs\")\n            File(parentName).apply {\n                if (!exists()) mkdir()\n\n                if (!logsDirectory.exists()) logsDirectory.mkdir()\n\n                val existing = logsDirectory.listFiles() ?: emptyArray()\n                val toDelete = existing.sortedByDescending { it.name }\n                val count = toDelete.size\n                if (count > MAX_COUNT_XCTEST_LOGS) toDelete.forEach { it.deleteRecursively() }\n            }\n            logsDirectory\n        }\n    }\n\n    private val dateFormatter by lazy { DateTimeFormatter.ofPattern(LOG_DIR_DATE_FORMAT) }\n\n    fun clearLogs() {\n        logDirectory.listFiles()?.forEach { it.deleteRecursively() }\n    }\n\n    fun listApps(deviceId: String): Set<String> {\n        val process = Runtime.getRuntime().exec(arrayOf(\"bash\", \"-c\", \"xcrun simctl listapps $deviceId | plutil -convert json - -o -\"))\n\n        val json = String(process.inputStream.readBytes())\n\n        if (json.isEmpty()) return emptySet()\n\n        val mapper = jacksonObjectMapper()\n        val appsMap = mapper.readValue(json, Map::class.java) as Map<String, Any>\n\n        return appsMap.keys\n    }\n\n    fun setProxy(host: String, port: Int) {\n        ProcessBuilder(\"networksetup\", \"-setwebproxy\", \"Wi-Fi\", host, port.toString())\n            .redirectErrorStream(true)\n            .start()\n            .waitFor()\n        ProcessBuilder(\"networksetup\", \"-setsecurewebproxy\", \"Wi-Fi\", host, port.toString())\n            .redirectErrorStream(true)\n            .start()\n            .waitFor()\n    }\n\n    fun resetProxy() {\n        ProcessBuilder(\"networksetup\", \"-setwebproxystate\", \"Wi-Fi\", \"off\")\n            .redirectErrorStream(true)\n            .start()\n            .waitFor()\n        ProcessBuilder(\"networksetup\", \"-setsecurewebproxystate\", \"Wi-Fi\", \"off\")\n            .redirectErrorStream(true)\n            .start()\n            .waitFor()\n    }\n\n    fun uninstall(bundleId: String, deviceId: String) {\n        CommandLineUtils.runCommand(\n            listOf(\n                \"xcrun\",\n                \"simctl\",\n                \"uninstall\",\n                deviceId,\n                bundleId\n            )\n        )\n    }\n\n    private fun runningApps(deviceId: String): Map<String, Int?> {\n        val process = ProcessBuilder(\n            \"xcrun\",\n            \"simctl\",\n            \"spawn\",\n            deviceId,\n            \"launchctl\",\n            \"list\"\n        ).start()\n\n        val processOutput = process.inputStream.bufferedReader().readLines()\n\n        if (!process.waitFor(3000, TimeUnit.MILLISECONDS)) {\n            return emptyMap()\n        }\n        return processOutput\n            .asSequence()\n            .drop(1)\n            .toList()\n            .map { line -> line.split(\"\\\\s+\".toRegex()) }\n            .filter { parts -> parts.count() <= 3 }\n            .associate { parts -> parts[2] to parts[0].toIntOrNull() }\n            .mapKeys { (key, _) ->\n                // Fixes issue with iOS 14.0 where process names are sometimes prefixed with \"UIKitApplication:\"\n                // and ending with [stuff]\n                key\n                    .substringBefore(\"[\")\n                    .replace(\"UIKitApplication:\", \"\")\n            }\n    }\n\n    fun pidForApp(bundleId: String, deviceId: String): Int? {\n        return runningApps(deviceId)[bundleId]\n    }\n\n    fun runXcTestWithoutBuild(deviceId: String, xcTestRunFilePath: String, port: Int, snapshotKeyHonorModalViews: Boolean?): Process {\n        val date = dateFormatter.format(LocalDateTime.now())\n        val outputFile = File(logDirectory, \"xctest_runner_$date.log\")\n        val logOutputDir = tempFileHandler.createTempDirectory(\"maestro_xctestrunner_xcodebuild_output\").toPath()\n        val params = mutableMapOf(\"TEST_RUNNER_PORT\" to port.toString())\n        if (snapshotKeyHonorModalViews != null) {\n            params[\"TEST_RUNNER_snapshotKeyHonorModalViews\"] = snapshotKeyHonorModalViews.toString()\n        }\n        return CommandLineUtils.runCommand(\n            listOf(\n                \"xcodebuild\",\n                \"test-without-building\",\n                \"-xctestrun\",\n                xcTestRunFilePath,\n                \"-destination\",\n                \"id=$deviceId\",\n                \"-derivedDataPath\",\n                logOutputDir.absolutePathString()\n            ),\n            waitForCompletion = false,\n            outputFile = outputFile,\n            params = params,\n        )\n    }\n}\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/XCTestClient.kt",
    "content": "package xcuitest\n\nimport okhttp3.HttpUrl\n\nclass XCTestClient(\n    val host: String,\n    val port: Int,\n) {\n\n    fun xctestAPIBuilder(pathSegment: String): HttpUrl.Builder {\n        return HttpUrl.Builder()\n            .scheme(\"http\")\n            .host(host)\n            .addPathSegment(pathSegment)\n            .port(port)\n    }\n}\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/XCTestDriverClient.kt",
    "content": "package xcuitest\n\nimport com.fasterxml.jackson.core.JsonProcessingException\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport hierarchy.ViewHierarchy\nimport maestro.utils.HttpClient\nimport maestro.utils.network.XCUITestServerError\nimport okhttp3.*\nimport okhttp3.MediaType.Companion.toMediaType\nimport okhttp3.RequestBody.Companion.toRequestBody\nimport org.slf4j.LoggerFactory\nimport xcuitest.api.*\nimport xcuitest.installer.XCTestInstaller\nimport kotlin.time.Duration.Companion.seconds\n\nclass XCTestDriverClient(\n    private val installer: XCTestInstaller,\n    private val okHttpClient: OkHttpClient = HttpClient.build(\n        name = \"XCTestDriverClient\",\n        readTimeout = 200.seconds,\n        connectTimeout = 1.seconds,\n        callTimeout = 200.seconds\n    ),\n    private val reinstallDriver: Boolean = true,\n) {\n    private val logger = LoggerFactory.getLogger(XCTestDriverClient::class.java)\n\n    private lateinit var client: XCTestClient\n\n    constructor(installer: XCTestInstaller, client: XCTestClient, reinstallDriver: Boolean = true): this(installer, reinstallDriver = reinstallDriver) {\n        this.client = client\n    }\n\n    fun restartXCTestRunner() {\n        if(reinstallDriver) {\n            logger.trace(\"Restarting XCTest Runner (uninstalling, installing and starting)\")\n            installer.uninstall()\n            logger.trace(\"XCTest Runner uninstalled, will install and start it\")\n        }\n\n        client = installer.start()\n    }\n\n    private val mapper = jacksonObjectMapper()\n\n    fun viewHierarchy(installedApps: Set<String>, excludeKeyboardElements: Boolean): ViewHierarchy {\n        val responseString = executeJsonRequest(\n            \"viewHierarchy\",\n            ViewHierarchyRequest(installedApps, excludeKeyboardElements)\n        )\n        return mapper.readValue(responseString, ViewHierarchy::class.java)\n    }\n\n    fun screenshot(compressed: Boolean): ByteArray {\n        val url = client.xctestAPIBuilder(\"screenshot\")\n            .addQueryParameter(\"compressed\", compressed.toString())\n            .build()\n\n        return executeJsonRequest(url)\n    }\n\n    fun terminateApp(appId: String) {\n        executeJsonRequest(\"terminateApp\", TerminateAppRequest(appId))\n    }\n\n    fun launchApp(appId: String) {\n        executeJsonRequest(\"launchApp\", LaunchAppRequest(appId))\n    }\n\n    fun keyboardInfo(installedApps: Set<String>): KeyboardInfoResponse {\n        val response = executeJsonRequest(\n            \"keyboard\",\n            KeyboardInfoRequest(installedApps)\n        )\n        return mapper.readValue(response, KeyboardInfoResponse::class.java)\n    }\n\n    fun isScreenStatic(): IsScreenStaticResponse {\n        val responseString = executeJsonRequest(\"isScreenStatic\")\n        return mapper.readValue(responseString, IsScreenStaticResponse::class.java)\n    }\n\n    fun runningAppId(appIds: Set<String>): GetRunningAppIdResponse {\n        val response = executeJsonRequest(\n            \"runningApp\",\n            GetRunningAppRequest(appIds)\n        )\n        return mapper.readValue(response, GetRunningAppIdResponse::class.java)\n    }\n\n    @Deprecated(\"swipeV2 is the latest one getting used everywhere because it requires one http call\")\n    fun swipe(\n        appId: String,\n        startX: Double,\n        startY: Double,\n        endX: Double,\n        endY: Double,\n        duration: Double,\n    ) {\n        executeJsonRequest(\"swipe\",\n            SwipeRequest(\n                appId = appId,\n                startX = startX,\n                startY = startY,\n                endX = endX,\n                endY = endY,\n                duration = duration\n            )\n        )\n    }\n\n    fun swipeV2(\n        installedApps: Set<String>,\n        startX: Double,\n        startY: Double,\n        endX: Double,\n        endY: Double,\n        duration: Double,\n    ) {\n        executeJsonRequest(\"swipeV2\",\n            SwipeRequest(\n                startX = startX,\n                startY = startY,\n                endX = endX,\n                endY = endY,\n                duration = duration,\n                appIds = installedApps\n            )\n        )\n    }\n\n    fun inputText(\n        text: String,\n        appIds: Set<String>,\n    ) {\n        executeJsonRequest(\"inputText\", InputTextRequest(text, appIds))\n    }\n\n    fun tap(\n        x: Float,\n        y: Float,\n        duration: Double? = null,\n    ) {\n        executeJsonRequest(\"touch\", TouchRequest(\n            x = x,\n            y = y,\n            duration = duration\n        ))\n    }\n\n    fun setOrientation(orientation: String) {\n        executeJsonRequest(\"setOrientation\", SetOrientationRequest(orientation))\n    }\n\n    fun pressKey(name: String) {\n        executeJsonRequest(\"pressKey\", PressKeyRequest(name))\n    }\n\n    fun pressButton(name: String) {\n        executeJsonRequest(\"pressButton\", PressButtonRequest(name))\n    }\n\n    fun eraseText(charactersToErase: Int, appIds: Set<String>) {\n        executeJsonRequest(\"eraseText\", EraseTextRequest(charactersToErase, appIds))\n    }\n\n    fun deviceInfo(httpUrl: HttpUrl = client.xctestAPIBuilder(\"deviceInfo\").build()): DeviceInfo {\n        val response = executeJsonRequest(httpUrl, Unit)\n        return mapper.readValue(response, DeviceInfo::class.java)\n    }\n\n    fun isChannelAlive(): Boolean {\n        return installer.isChannelAlive()\n    }\n\n    fun close() {\n        installer.close()\n    }\n\n    fun setPermissions(permissions: Map<String, String>) {\n        executeJsonRequest(\"setPermissions\", SetPermissionsRequest(permissions))\n    }\n\n    private fun executeJsonRequest(httpUrl: HttpUrl, body: Any): String {\n        val mediaType = \"application/json; charset=utf-8\".toMediaType()\n        val bodyData = mapper.writeValueAsString(body).toRequestBody(mediaType)\n\n        val requestBuilder = Request.Builder()\n            .addHeader(\"Content-Type\", \"application/json\")\n            .url(httpUrl)\n            .post(bodyData)\n\n        return okHttpClient\n            .newCall(requestBuilder.build())\n            .execute().use { processResponse(it, httpUrl.toString()) }\n    }\n\n    private fun executeJsonRequest(httpUrl: HttpUrl): ByteArray {\n        val request = Request.Builder()\n            .get()\n            .url(httpUrl)\n            .build()\n\n        return okHttpClient\n            .newCall(request)\n            .execute().use {\n                val bytes = it.body?.bytes() ?: ByteArray(0)\n                if (!it.isSuccessful) {\n                    //handle exception\n                    val responseBodyAsString = String(bytes)\n                    handleExceptions(it.code, request.url.pathSegments.first(), responseBodyAsString)\n                }\n                bytes\n            }\n    }\n\n    private fun executeJsonRequest(pathSegment: String, body: Any): String {\n        val mediaType = \"application/json; charset=utf-8\".toMediaType()\n        val bodyData = mapper.writeValueAsString(body).toRequestBody(mediaType)\n\n        val requestBuilder = Request.Builder()\n            .addHeader(\"Content-Type\", \"application/json\")\n            .url(client.xctestAPIBuilder(pathSegment).build())\n            .post(bodyData)\n\n        return okHttpClient\n            .newCall(requestBuilder.build())\n            .execute().use { processResponse(it, pathSegment) }\n    }\n\n    private fun executeJsonRequest(pathSegment: String): String {\n        val requestBuilder = Request.Builder()\n            .url(client.xctestAPIBuilder(pathSegment).build())\n            .get()\n\n        return okHttpClient\n            .newCall(requestBuilder.build())\n            .execute().use { processResponse(it, pathSegment) }\n    }\n\n    private fun processResponse(response: Response, url: String): String {\n        val responseBodyAsString = response.body?.bytes()?.let { bytes -> String(bytes) } ?: \"\"\n\n        return if (!response.isSuccessful) {\n            val code = response.code\n            handleExceptions(code, url, responseBodyAsString)\n        } else {\n            responseBodyAsString\n        }\n    }\n\n    private fun handleExceptions(\n        code: Int,\n        pathString: String,\n        responseBodyAsString: String,\n    ): String {\n        logger.warn(\"XCTestDriver request failed. Status code: $code, path: $pathString, body: $responseBodyAsString\");\n        val error = try {\n            mapper.readValue(responseBodyAsString, Error::class.java)\n        } catch (_: JsonProcessingException) {\n            Error(\"Unable to parse error\", \"unknown\")\n        }\n        when {\n            code == 408 -> {\n                logger.error(\"Request for $pathString timeout, body: $responseBodyAsString\")\n                throw XCUITestServerError.OperationTimeout(error.errorMessage, pathString)\n            }\n            code in 400..499 -> {\n                logger.error(\"Request for $pathString failed with bad request ${code}, body: $responseBodyAsString\")\n                throw XCUITestServerError.BadRequest(\n                    \"Request for $pathString failed with bad request ${code}, body: $responseBodyAsString\",\n                    responseBodyAsString\n                )\n            }\n            error.errorMessage.contains(\"Lost connection to the application.*\".toRegex()) -> {\n                logger.error(\"Request for $pathString failed, because of app crash, body: $responseBodyAsString\")\n                throw XCUITestServerError.AppCrash(\n                    \"Request for $pathString failed, due to app crash with message ${error.errorMessage}\"\n                )\n            }\n            error.errorMessage.contains(\"Application [a-zA-Z0-9.]+ is not running\".toRegex()) -> {\n                logger.error(\"Request for $pathString failed, because of app crash, body: $responseBodyAsString\")\n                throw XCUITestServerError.AppCrash(\n                    \"Request for $pathString failed, due to app crash with message ${error.errorMessage}\"\n                )\n            }\n            error.errorMessage.contains(\"Error getting main window kAXErrorCannotComplete\") -> {\n                logger.error(\"Request for $pathString failed, because of app crash, body: $responseBodyAsString\")\n                throw XCUITestServerError.AppCrash(\n                    \"Request for $pathString failed, due to app crash with message ${error.errorMessage}\"\n                )\n            }\n            error.errorMessage.contains(\"Error getting main window.*\".toRegex()) -> {\n                logger.error(\"Request for $pathString failed, because of app crash, body: $responseBodyAsString\")\n                throw XCUITestServerError.AppCrash(\n                    \"Request for $pathString failed, due to app crash with message ${error.errorMessage}\"\n                )\n            }\n            else -> {\n                logger.error(\"Request for $pathString failed, because of unknown reason, body: $responseBodyAsString\")\n                throw XCUITestServerError.UnknownFailure(\n                    \"Request for $pathString failed, code: ${code}, body: $responseBodyAsString\"\n                )\n            }\n        }\n    }\n\n}\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/api/DeviceInfo.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage xcuitest.api\n\ndata class DeviceInfo(\n    val widthPixels: Int,\n    val heightPixels: Int,\n    val widthPoints: Int,\n    val heightPoints: Int,\n)\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/api/EraseTextRequest.kt",
    "content": "package xcuitest.api\n\ndata class EraseTextRequest(\n    val charactersToErase: Int,\n    val appIds: Set<String>,\n)\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/api/Error.kt",
    "content": "package xcuitest.api\n\nimport com.fasterxml.jackson.annotation.JsonProperty\n\ndata class Error(\n    @JsonProperty(\"errorMessage\") val errorMessage: String,\n    @JsonProperty(\"code\") val errorCode: String,\n)"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/api/GetRunningAppIdResponse.kt",
    "content": "package xcuitest.api\n\ndata class GetRunningAppIdResponse(val runningAppBundleId: String)"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/api/GetRunningAppRequest.kt",
    "content": "package xcuitest.api\n\ndata class GetRunningAppRequest(val appIds: Set<String>)"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/api/InputTextRequest.kt",
    "content": "package xcuitest.api\n\ndata class InputTextRequest(\n    val text: String,\n    val appIds: Set<String>\n)\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/api/IsScreenStaticResponse.kt",
    "content": "package xcuitest.api\n\ndata class IsScreenStaticResponse(val isScreenStatic: Boolean)"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/api/KeyboardInfoRequest.kt",
    "content": "package xcuitest.api\n\ndata class KeyboardInfoRequest(val appIds: Set<String>)"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/api/KeyboardInfoResponse.kt",
    "content": "package xcuitest.api\n\ndata class KeyboardInfoResponse(val isKeyboardVisible: Boolean)"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/api/LaunchAppRequest.kt",
    "content": "package xcuitest.api\n\ndata class LaunchAppRequest(val bundleId: String)"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/api/NetworkExceptions.kt",
    "content": "package xcuitest.api\n\nimport java.io.IOException\n\nsealed class NetworkException(message: String) : IOException(message) {\n    class TimeoutException(message: String) : NetworkException(message)\n    class ConnectionException(message: String) : NetworkException(message)\n    class UnknownHostException(message: String) : NetworkException(message)\n    class UnknownNetworkException(message: String): NetworkException(message)\n\n    companion object {\n        private fun NetworkException.displayErrorMessage(): String {\n            return when (this) {\n                is TimeoutException -> \"A timeout occurred while waiting for a response from the XCUITest server.\"\n                is ConnectionException -> \"Unable to establish a connection to the XCUITest server.\"\n                is UnknownHostException -> \"The host for the XCUITest server is unknown.\"\n                is UnknownNetworkException -> \"An unknown network error occurred while communicating with the XCUITest server.\"\n            } + \" If the issue persists, consider raising a GitHub issue with the error message and any available logs for further assistance.\"\n        }\n\n        fun NetworkException.toUserNetworkException(): NetworkErrorModel {\n            return NetworkErrorModel(\n                displayErrorMessage(),\n                stackTraceToString()\n            )\n        }\n    }\n\n    data class NetworkErrorModel(val userFriendlyMessage: String, val stackTrace: String)\n\n}\n\n\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/api/OkHttpClientInstance.kt",
    "content": "package xcuitest.api\n\nimport okhttp3.ConnectionPool\nimport okhttp3.OkHttpClient\nimport java.util.concurrent.TimeUnit\n\nobject OkHttpClientInstance {\n\n    fun get(): OkHttpClient {\n        return OkHttpClient.Builder()\n            .connectionPool(ConnectionPool(225, 10, TimeUnit.MINUTES))\n            .connectTimeout(1, TimeUnit.SECONDS)\n            .readTimeout(200, TimeUnit.SECONDS)\n            .build()\n    }\n}"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/api/PressButtonRequest.kt",
    "content": "package xcuitest.api\n\ndata class PressButtonRequest(val button: String)\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/api/PressKeyRequest.kt",
    "content": "package xcuitest.api\n\ndata class PressKeyRequest(val key: String)\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/api/SetOrientationRequest.kt",
    "content": "package xcuitest.api\n\ndata class SetOrientationRequest(val orientation: String)\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/api/SetPermissionsRequest.kt",
    "content": "package xcuitest.api\n\ndata class SetPermissionsRequest(val permissions: Map<String, String>)"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/api/SwipeRequest.kt",
    "content": "package xcuitest.api\n\ndata class SwipeRequest(\n    val appId: String? = null,\n    val startX: Double,\n    val startY: Double,\n    val endX: Double,\n    val endY: Double,\n    val duration: Double,\n    val appIds: Set<String>? = null\n)\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/api/TerminateAppRequest.kt",
    "content": "package xcuitest.api\n\ndata class TerminateAppRequest(val appId: String)"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/api/TouchRequest.kt",
    "content": "package xcuitest.api\n\ndata class TouchRequest(\n    val x: Float,\n    val y: Float,\n    val duration: Double?\n)\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/api/ViewHierarchyRequest.kt",
    "content": "package xcuitest.api\n\ndata class ViewHierarchyRequest(\n    val appIds: Set<String>,\n    val excludeKeyboardElements: Boolean\n)\n"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/installer/IOSBuildProductsExtractor.kt",
    "content": "package xcuitest.installer\n\nimport maestro.utils.TempFileHandler\nimport org.rauschig.jarchivelib.ArchiverFactory\nimport org.slf4j.LoggerFactory\nimport util.IOSDeviceType\nimport java.io.File\nimport java.io.FileNotFoundException\nimport java.net.URI\nimport java.nio.file.FileSystem\nimport java.nio.file.FileSystemAlreadyExistsException\nimport java.nio.file.FileSystemNotFoundException\nimport java.nio.file.FileSystems\nimport java.nio.file.Files\nimport java.nio.file.Path\nimport java.nio.file.Paths\nimport java.nio.file.StandardCopyOption\nimport kotlin.io.path.isRegularFile\n\ndata class BuildProducts(val xctestRunPath: File, val uiRunnerPath: File)\n\nenum class Context {\n    CLI,\n    CLOUD\n}\n\nclass IOSBuildProductsExtractor(\n    private val target: Path,\n    private val context: Context,\n    private val deviceType: IOSDeviceType,\n) {\n\n    companion object {\n        private val LOGGER = LoggerFactory.getLogger(IOSBuildProductsExtractor::class.java)\n    }\n\n    fun extract(sourceDirectory: String): BuildProducts {\n        LOGGER.info(\"[Start] Writing build products\")\n        writeBuildProducts(sourceDirectory)\n        LOGGER.info(\"[Done] Writing build products\")\n\n        LOGGER.info(\"[Start] Writing maestro-driver-iosUITests-Runner app\")\n        extractZipToApp(\"maestro-driver-iosUITests-Runner.zip\")\n        LOGGER.info(\"[Done] Writing maestro-driver-iosUITests-Runner app\")\n\n        LOGGER.info(\"[Start] Writing maestro-driver-ios app\")\n        extractZipToApp(\"maestro-driver-ios.zip\")\n        LOGGER.info(\"[Done] Writing maestro-driver-ios app\")\n\n        val targetFile = target.toFile()\n        val xctestRun = targetFile.walkTopDown().firstOrNull { it.extension == \"xctestrun\" }\n            ?: throw FileNotFoundException(\"xctestrun config does not exist\")\n        val uiRunner = targetFile.walkTopDown().firstOrNull { it.name == \"maestro-driver-iosUITests-Runner.app\" }\n            ?: throw FileNotFoundException(\"ui test runner does not exist\")\n\n        return BuildProducts(\n            xctestRunPath = xctestRun,\n            uiRunnerPath = uiRunner\n        )\n    }\n\n    private fun extractZipToApp(appFileName: String) {\n        val appZip = target.toFile().walk().firstOrNull { it.name == appFileName && it.extension == \"zip\" }\n            ?: run {\n                LOGGER.info(\"zip extension not present in the target directory, skipping unzipping operation.\")\n                return\n            }\n\n        try {\n            ArchiverFactory.createArchiver(appZip).apply {\n                extract(appZip, appZip.parentFile)\n            }\n        } finally {\n            Files.delete(appZip.toPath())\n        }\n    }\n\n    private fun writeBuildProducts(sourceDirectory: String) {\n        val uri = when  {\n            deviceType == IOSDeviceType.SIMULATOR -> {\n                LocalXCTestInstaller::class.java.classLoader.getResource(sourceDirectory)?.toURI()\n                    ?: throw IllegalArgumentException(\"Resource not found: $sourceDirectory\")\n            }\n            context == Context.CLI && deviceType == IOSDeviceType.REAL -> {\n                Paths.get(sourceDirectory).toUri()\n            }\n            else ->  {\n                LocalXCTestInstaller::class.java.classLoader.getResource(sourceDirectory)?.toURI()\n                    ?: throw IllegalArgumentException(\"Resource not found: $sourceDirectory\")\n            }\n        }\n\n        val sourcePath = if (uri.scheme == \"jar\") {\n            when (deviceType) {\n                IOSDeviceType.REAL -> {\n                    Paths.get(uri)\n                }\n                IOSDeviceType.SIMULATOR -> {\n                    val fs = try {\n                        FileSystems.getFileSystem(uri)\n                    } catch (e: FileSystemNotFoundException) {\n                        uri.getOrCreateFileSystem()\n                    }\n                    fs.getPath(sourceDirectory)\n                }\n            }\n        } else {\n            Paths.get(uri)\n        }\n\n        Files.walk(sourcePath).use { paths ->\n            paths.filter { it.isRegularFile() }.forEach { file ->\n                val relative = sourcePath.relativize(file)\n                val targetPath = target.resolve(relative.toString())\n\n                Files.createDirectories(targetPath.parent)\n                Files.copy(file, targetPath, StandardCopyOption.REPLACE_EXISTING)\n            }\n        }\n    }\n\n    private fun URI.getOrCreateFileSystem(): FileSystem {\n        return try {\n            FileSystems.newFileSystem(this, emptyMap<String, Any>())\n        } catch (e: FileSystemAlreadyExistsException) {\n            FileSystems.getFileSystem(this)\n        }\n    }\n}"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/installer/LocalXCTestInstaller.kt",
    "content": "package xcuitest.installer\n\nimport device.IOSDevice\nimport maestro.utils.HttpClient\nimport maestro.utils.MaestroTimer\nimport maestro.utils.Metrics\nimport maestro.utils.MetricsProvider\nimport maestro.utils.TempFileHandler\nimport okhttp3.HttpUrl\nimport okhttp3.OkHttpClient\nimport okhttp3.Request\nimport org.slf4j.LoggerFactory\nimport util.IOSDeviceType\nimport util.LocalIOSDeviceController\nimport util.LocalSimulatorUtils\nimport util.XCRunnerCLIUtils\nimport xcuitest.XCTestClient\nimport java.io.File\nimport java.io.IOException\nimport java.nio.file.Files\nimport kotlin.io.path.ExperimentalPathApi\nimport kotlin.io.path.deleteRecursively\nimport kotlin.time.Duration.Companion.seconds\n\nclass LocalXCTestInstaller(\n    private val deviceId: String,\n    private val host: String = \"127.0.0.1\",\n    private val deviceType: IOSDeviceType,\n    private val defaultPort: Int,\n    private val metricsProvider: Metrics = MetricsProvider.getInstance(),\n    private val httpClient: OkHttpClient = HttpClient.build(\n        name = \"XCUITestDriverStatusCheck\",\n        connectTimeout = 1.seconds,\n        readTimeout = 100.seconds,\n    ),\n    val reinstallDriver: Boolean = true,\n    private val iOSDriverConfig: IOSDriverConfig,\n    private val deviceController: IOSDevice,\n    private val tempFileHandler: TempFileHandler = TempFileHandler()\n) : XCTestInstaller {\n\n    private val logger = LoggerFactory.getLogger(LocalXCTestInstaller::class.java)\n    private val metrics = metricsProvider.withPrefix(\"xcuitest.installer\").withTags(mapOf(\"kind\" to \"local\", \"deviceId\" to deviceId, \"host\" to host))\n\n    /**\n     * If true, allow for using a xctest runner started from Xcode.\n     *\n     * When this flag is set, maestro will not install, run, stop or remove the xctest runner.\n     * Make sure to launch the xctest runner from Xcode whenever maestro needs it.\n     */\n    private val useXcodeTestRunner = !System.getenv(\"USE_XCODE_TEST_RUNNER\").isNullOrEmpty()\n    private val tempDir = tempFileHandler.createTempDirectory(deviceId)\n    private val localSimulatorUtils = LocalSimulatorUtils(tempFileHandler)\n    private val iosBuildProductsExtractor = IOSBuildProductsExtractor(\n        target = tempDir.toPath(),\n        context = iOSDriverConfig.context,\n        deviceType = deviceType,\n    )\n    private val xcRunnerCLIUtils = XCRunnerCLIUtils(tempFileHandler)\n\n    private var xcTestProcess: Process? = null\n\n    override fun uninstall(): Boolean {\n        return metrics.measured(\"operation\", mapOf(\"command\" to \"uninstall\")) {\n            // FIXME(bartekpacia): This method probably doesn't have to care about killing the XCTest Runner process.\n            //  Just uninstalling should suffice. It automatically kills the process.\n\n            if (useXcodeTestRunner || !reinstallDriver) {\n                logger.trace(\"Skipping uninstalling XCTest Runner as USE_XCODE_TEST_RUNNER is set\")\n                return@measured false\n            }\n\n            if (!isChannelAlive()) return@measured false\n\n            fun killXCTestRunnerProcess() {\n                logger.trace(\"Will attempt to stop all alive XCTest Runner processes before uninstalling\")\n\n                if (xcTestProcess?.isAlive == true) {\n                    logger.trace(\"XCTest Runner process started by us is alive, killing it\")\n                    xcTestProcess?.destroy()\n                }\n                xcTestProcess = null\n\n                val pid = xcRunnerCLIUtils.pidForApp(UI_TEST_RUNNER_APP_BUNDLE_ID, deviceId)\n                if (pid != null) {\n                    logger.trace(\"Killing XCTest Runner process with the `kill` command\")\n                    ProcessBuilder(listOf(\"kill\", pid.toString()))\n                        .start()\n                        .waitFor()\n                }\n\n                logger.trace(\"All XCTest Runner processes were stopped\")\n            }\n\n            killXCTestRunnerProcess()\n\n            logger.trace(\"Uninstalling XCTest Runner from device $deviceId\")\n            true\n        }\n    }\n\n    override fun start(): XCTestClient {\n        return metrics.measured(\"operation\", mapOf(\"command\" to \"start\")) {\n            logger.info(\"start()\")\n\n            if (useXcodeTestRunner) {\n                logger.info(\"USE_XCODE_TEST_RUNNER is set. Will wait for XCTest runner to be started manually\")\n\n                repeat(20) {\n                    if (ensureOpen()) {\n                        return@measured XCTestClient(host, defaultPort)\n                    }\n                    logger.info(\"==> Start XCTest runner to continue flow\")\n                    Thread.sleep(500)\n                }\n                throw IllegalStateException(\"XCTest was not started manually\")\n            }\n\n\n            logger.info(\"[Start] Install XCUITest runner on $deviceId\")\n            startXCTestRunner(deviceId, iOSDriverConfig.prebuiltRunner)\n            logger.info(\"[Done] Install XCUITest runner on $deviceId\")\n\n            val startTime = System.currentTimeMillis()\n\n            while (System.currentTimeMillis() - startTime < getStartupTimeout()) {\n                runCatching {\n                    if (isChannelAlive()) return@measured XCTestClient(host, defaultPort)\n                }\n                Thread.sleep(500)\n            }\n\n            throw IOSDriverTimeoutException(\"iOS driver not ready in time, consider increasing timeout by configuring MAESTRO_DRIVER_STARTUP_TIMEOUT env variable\")\n        }\n    }\n\n    class IOSDriverTimeoutException(message: String): RuntimeException(message)\n\n    private fun getStartupTimeout(): Long = runCatching {\n        System.getenv(MAESTRO_DRIVER_STARTUP_TIMEOUT).toLong()\n    }.getOrDefault(SERVER_LAUNCH_TIMEOUT_MS)\n\n    override fun isChannelAlive(): Boolean {\n        return metrics.measured(\"operation\", mapOf(\"command\" to \"isChannelAlive\")) {\n        return@measured xcTestDriverStatusCheck()\n        }\n    }\n\n    private fun ensureOpen(): Boolean {\n        val timeout = 120_000L\n        logger.info(\"ensureOpen(): Will spend $timeout ms waiting for the channel to become alive\")\n        val result = MaestroTimer.retryUntilTrue(timeout, 200, onException = {\n            logger.error(\"ensureOpen() failed with exception: $it\")\n        }) { isChannelAlive() }\n        logger.info(\"ensureOpen() finished, is channel alive?: $result\")\n        return result\n    }\n\n    private fun xcTestDriverStatusCheck(): Boolean {\n        logger.info(\"[Start] Perform XCUITest driver status check on $deviceId\")\n        fun xctestAPIBuilder(pathSegment: String): HttpUrl.Builder {\n            return HttpUrl.Builder()\n                .scheme(\"http\")\n                .host(\"127.0.0.1\")\n                .addPathSegment(pathSegment)\n                .port(defaultPort)\n        }\n\n        val url by lazy {\n            xctestAPIBuilder(\"status\")\n                .build()\n        }\n\n        val request by lazy {  Request.Builder()\n            .get()\n            .url(url)\n            .build()\n        }\n\n        val checkSuccessful = try {\n            httpClient.newCall(request).execute().use {\n                logger.info(\"[Done] Perform XCUITest driver status check on $deviceId\")\n                it.isSuccessful\n            }\n        } catch (ignore: IOException) {\n            logger.info(\"[Failed] Perform XCUITest driver status check on $deviceId, exception: $ignore\")\n            false\n        }\n\n        return checkSuccessful\n    }\n\n    private fun startXCTestRunner(deviceId: String, preBuiltRunner: Boolean) {\n        if (isChannelAlive()) {\n            logger.info(\"UI Test runner already running, returning\")\n            return\n        }\n\n        val buildProducts = iosBuildProductsExtractor.extract(iOSDriverConfig.sourceDirectory)\n\n        if (preBuiltRunner) {\n            logger.info(\"Installing pre built driver without xcodebuild\")\n            installPrebuiltRunner(deviceId, buildProducts.uiRunnerPath)\n        } else {\n            logger.info(\"Installing driver with xcodebuild\")\n            logger.info(\"[Start] Running XcUITest with `xcodebuild test-without-building` with $defaultPort and config: $iOSDriverConfig\")\n            xcTestProcess = xcRunnerCLIUtils.runXcTestWithoutBuild(\n                deviceId = this.deviceId,\n                xcTestRunFilePath = buildProducts.xctestRunPath.absolutePath,\n                port = defaultPort,\n                snapshotKeyHonorModalViews = iOSDriverConfig.snapshotKeyHonorModalViews\n            )\n            logger.info(\"[Done] Running XcUITest with `xcodebuild test-without-building`\")\n        }\n    }\n\n    private fun installPrebuiltRunner(deviceId: String, bundlePath: File) {\n        logger.info(\"Installing prebuilt driver for $deviceId and type $deviceType\")\n        when (deviceType) {\n            IOSDeviceType.REAL -> {\n                LocalIOSDeviceController.install(deviceId, bundlePath.toPath())\n                LocalIOSDeviceController.launchRunner(\n                    deviceId = deviceId,\n                    port = defaultPort,\n                    snapshotKeyHonorModalViews = iOSDriverConfig.snapshotKeyHonorModalViews\n                )\n            }\n            IOSDeviceType.SIMULATOR -> {\n                localSimulatorUtils.install(deviceId, bundlePath.toPath())\n                localSimulatorUtils.launchUITestRunner(\n                    deviceId = deviceId,\n                    port = defaultPort,\n                    snapshotKeyHonorModalViews = iOSDriverConfig.snapshotKeyHonorModalViews\n                )\n            }\n        }\n    }\n\n    @OptIn(ExperimentalPathApi::class)\n    override fun close() {\n        if (useXcodeTestRunner) {\n            return\n        }\n\n        logger.info(\"[Start] Cleaning up the ui test runner files\")\n        tempFileHandler.close()\n        if(reinstallDriver) {\n            uninstall()\n            deviceController.close()\n            logger.info(\"[Done] Cleaning up the ui test runner files\")\n        }\n    }\n\n    data class IOSDriverConfig(\n        val prebuiltRunner: Boolean,\n        val sourceDirectory: String,\n        val context: Context,\n        val snapshotKeyHonorModalViews: Boolean?\n    )\n\n    companion object {\n        const val UI_TEST_RUNNER_APP_BUNDLE_ID = \"dev.mobile.maestro-driver-iosUITests.xctrunner\"\n\n        private const val SERVER_LAUNCH_TIMEOUT_MS = 120000L\n        private const val MAESTRO_DRIVER_STARTUP_TIMEOUT = \"MAESTRO_DRIVER_STARTUP_TIMEOUT\"\n    }\n\n}"
  },
  {
    "path": "maestro-ios-driver/src/main/kotlin/xcuitest/installer/XCTestInstaller.kt",
    "content": "package xcuitest.installer\n\nimport xcuitest.XCTestClient\n\ninterface XCTestInstaller: AutoCloseable {\n    fun start(): XCTestClient\n\n    /**\n     * Attempts to uninstall the XCTest Runner.\n     *\n     * @return true if the XCTest Runner was uninstalled, false otherwise.\n     */\n    fun uninstall(): Boolean\n\n    fun isChannelAlive(): Boolean\n}\n"
  },
  {
    "path": "maestro-ios-driver/src/main/resources/driver-iPhoneSimulator/maestro-driver-ios-config.xctestrun",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>__xctestrun_metadata__</key>\n\t<dict>\n\t\t<key>ContainerInfo</key>\n\t\t<dict>\n\t\t\t<key>ContainerName</key>\n\t\t\t<string>maestro-driver-ios</string>\n\t\t\t<key>SchemeName</key>\n\t\t\t<string>maestro-driver-ios</string>\n\t\t</dict>\n\t\t<key>FormatVersion</key>\n\t\t<integer>1</integer>\n\t</dict>\n\t<key>maestro-driver-iosUITests</key>\n\t<dict>\n\t\t<key>BlueprintName</key>\n\t\t<string>maestro-driver-iosUITests</string>\n\t\t<key>BlueprintProviderName</key>\n\t\t<string>maestro-driver-ios</string>\n\t\t<key>BlueprintProviderRelativePath</key>\n\t\t<string>maestro-driver-ios.xcodeproj</string>\n\t\t<key>BundleIdentifiersForCrashReportEmphasis</key>\n\t\t<array>\n\t\t\t<string>dev.mobile.MaestroDriverLib</string>\n\t\t\t<string>dev.mobile.maestro-driver-ios</string>\n\t\t\t<string>dev.mobile.maestro-driver-iosUITests</string>\n\t\t</array>\n\t\t<key>CommandLineArguments</key>\n\t\t<array/>\n\t\t<key>DefaultTestExecutionTimeAllowance</key>\n\t\t<integer>600</integer>\n\t\t<key>DependentProductPaths</key>\n\t\t<array>\n\t\t\t<string>__TESTROOT__/Debug-iphonesimulator/MaestroDriverLib.framework</string>\n\t\t\t<string>__TESTROOT__/Debug-iphonesimulator/maestro-driver-ios.app</string>\n\t\t\t<string>__TESTROOT__/Debug-iphonesimulator/maestro-driver-iosUITests-Runner.app</string>\n\t\t\t<string>__TESTROOT__/Debug-iphonesimulator/maestro-driver-iosUITests-Runner.app/PlugIns/maestro-driver-iosUITests.xctest</string>\n\t\t</array>\n\t\t<key>DiagnosticCollectionPolicy</key>\n\t\t<integer>1</integer>\n\t\t<key>EnvironmentVariables</key>\n\t\t<dict>\n\t\t\t<key>APP_DISTRIBUTOR_ID_OVERRIDE</key>\n\t\t\t<string>com.apple.AppStore</string>\n\t\t\t<key>DYLD_INSERT_LIBRARIES</key>\n\t\t\t<string>/usr/lib/libRPAC.dylib</string>\n\t\t\t<key>OS_ACTIVITY_DT_MODE</key>\n\t\t\t<string>YES</string>\n\t\t\t<key>PERFC_ENABLE_EXTENDED_DIAGNOSTIC_FORMAT</key>\n\t\t\t<string>1</string>\n\t\t\t<key>PERFC_ENABLE_PROFILE_MODE</key>\n\t\t\t<string>1</string>\n\t\t\t<key>PERFC_RESET_INSERT_LIBRARIES</key>\n\t\t\t<string>1</string>\n\t\t\t<key>PERFC_SUPPRESS_SYSTEM_REPORTS</key>\n\t\t\t<string>1</string>\n\t\t\t<key>SQLITE_ENABLE_THREAD_ASSERTIONS</key>\n\t\t\t<string>1</string>\n\t\t\t<key>TERM</key>\n\t\t\t<string>dumb</string>\n\t\t</dict>\n\t\t<key>IsUITestBundle</key>\n\t\t<true/>\n\t\t<key>IsXCTRunnerHostedTestBundle</key>\n\t\t<true/>\n\t\t<key>PreferredScreenCaptureFormat</key>\n\t\t<string>screenRecording</string>\n\t\t<key>ProductModuleName</key>\n\t\t<string>maestro_driver_iosUITests</string>\n\t\t<key>RunOrder</key>\n\t\t<integer>0</integer>\n\t\t<key>SkipTestIdentifiers</key>\n\t\t<array>\n\t\t\t<string>ViewHierarchyHandlerTests</string>\n\t\t\t<string>ViewHierarchyHandlerTests/testViewHierarchyHandlerReturnsNonEmptyHierarchy()</string>\n\t\t</array>\n\t\t<key>SystemAttachmentLifetime</key>\n\t\t<string>deleteOnSuccess</string>\n\t\t<key>TestBundlePath</key>\n\t\t<string>__TESTHOST__/PlugIns/maestro-driver-iosUITests.xctest</string>\n\t\t<key>TestHostBundleIdentifier</key>\n\t\t<string>dev.mobile.maestro-driver-iosUITests.xctrunner</string>\n\t\t<key>TestHostPath</key>\n\t\t<string>__TESTROOT__/Debug-iphonesimulator/maestro-driver-iosUITests-Runner.app</string>\n\t\t<key>TestLanguage</key>\n\t\t<string></string>\n\t\t<key>TestRegion</key>\n\t\t<string></string>\n\t\t<key>TestTimeoutsEnabled</key>\n\t\t<false/>\n\t\t<key>TestingEnvironmentVariables</key>\n\t\t<dict>\n\t\t\t<key>DYLD_FRAMEWORK_PATH</key>\n\t\t\t<string>__TESTROOT__/Debug-iphonesimulator:__TESTROOT__/Debug-iphonesimulator/PackageFrameworks:__PLATFORMS__/iPhoneSimulator.platform/Developer/Library/Frameworks</string>\n\t\t\t<key>DYLD_INSERT_LIBRARIES</key>\n\t\t\t<string>/usr/lib/libRPAC.dylib</string>\n\t\t\t<key>DYLD_LIBRARY_PATH</key>\n\t\t\t<string>__TESTROOT__/Debug-iphonesimulator:__PLATFORMS__/iPhoneSimulator.platform/Developer/usr/lib</string>\n\t\t\t<key>PERFC_SUPPRESS_SYSTEM_REPORTS</key>\n\t\t\t<string>1</string>\n\t\t\t<key>XCODE_SCHEME_NAME</key>\n\t\t\t<string>maestro-driver-ios</string>\n\t\t\t<key>__XCODE_BUILT_PRODUCTS_DIR_PATHS</key>\n\t\t\t<string>__TESTROOT__/Debug-iphonesimulator</string>\n\t\t\t<key>__XPC_DYLD_FRAMEWORK_PATH</key>\n\t\t\t<string>__TESTROOT__/Debug-iphonesimulator</string>\n\t\t\t<key>__XPC_DYLD_LIBRARY_PATH</key>\n\t\t\t<string>__TESTROOT__/Debug-iphonesimulator</string>\n\t\t</dict>\n\t\t<key>ToolchainsSettingValue</key>\n\t\t<array/>\n\t\t<key>UITargetAppCommandLineArguments</key>\n\t\t<array/>\n\t\t<key>UITargetAppEnvironmentVariables</key>\n\t\t<dict>\n\t\t\t<key>APP_DISTRIBUTOR_ID_OVERRIDE</key>\n\t\t\t<string>com.apple.AppStore</string>\n\t\t\t<key>DYLD_FRAMEWORK_PATH</key>\n\t\t\t<string>__TESTROOT__/Debug-iphonesimulator:__TESTROOT__/Debug-iphonesimulator/PackageFrameworks</string>\n\t\t\t<key>DYLD_LIBRARY_PATH</key>\n\t\t\t<string>__TESTROOT__/Debug-iphonesimulator</string>\n\t\t\t<key>XCODE_SCHEME_NAME</key>\n\t\t\t<string>maestro-driver-ios</string>\n\t\t\t<key>__XCODE_BUILT_PRODUCTS_DIR_PATHS</key>\n\t\t\t<string>__TESTROOT__/Debug-iphonesimulator</string>\n\t\t\t<key>__XPC_DYLD_FRAMEWORK_PATH</key>\n\t\t\t<string>__TESTROOT__/Debug-iphonesimulator</string>\n\t\t\t<key>__XPC_DYLD_LIBRARY_PATH</key>\n\t\t\t<string>__TESTROOT__/Debug-iphonesimulator</string>\n\t\t</dict>\n\t\t<key>UITargetAppPath</key>\n\t\t<string>__TESTROOT__/Debug-iphonesimulator/maestro-driver-ios.app</string>\n\t\t<key>UITargetAppPerformanceAntipatternCheckerEnabled</key>\n\t\t<true/>\n\t\t<key>UserAttachmentLifetime</key>\n\t\t<string>deleteOnSuccess</string>\n\t</dict>\n</dict>\n</plist>\n"
  },
  {
    "path": "maestro-ios-driver/src/main/resources/driver-iphoneos/maestro-driver-ios-config.xctestrun",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>__xctestrun_metadata__</key>\n\t<dict>\n\t\t<key>ContainerInfo</key>\n\t\t<dict>\n\t\t\t<key>ContainerName</key>\n\t\t\t<string>maestro-driver-ios</string>\n\t\t\t<key>SchemeName</key>\n\t\t\t<string>maestro-driver-ios</string>\n\t\t</dict>\n\t\t<key>FormatVersion</key>\n\t\t<integer>1</integer>\n\t</dict>\n\t<key>maestro-driver-iosUITests</key>\n\t<dict>\n\t\t<key>BlueprintName</key>\n\t\t<string>maestro-driver-iosUITests</string>\n\t\t<key>BlueprintProviderName</key>\n\t\t<string>maestro-driver-ios</string>\n\t\t<key>BlueprintProviderRelativePath</key>\n\t\t<string>maestro-driver-ios.xcodeproj</string>\n\t\t<key>BundleIdentifiersForCrashReportEmphasis</key>\n\t\t<array>\n\t\t\t<string>dev.mobile.maestro-driver-ios</string>\n\t\t\t<string>dev.mobile.maestro-driver-iosUITests</string>\n\t\t</array>\n\t\t<key>CommandLineArguments</key>\n\t\t<array/>\n\t\t<key>DefaultTestExecutionTimeAllowance</key>\n\t\t<integer>600</integer>\n\t\t<key>DependentProductPaths</key>\n\t\t<array>\n\t\t\t<string>__TESTROOT__/Debug-iphoneos/maestro-driver-ios.app</string>\n\t\t\t<string>__TESTROOT__/Debug-iphoneos/maestro-driver-iosUITests-Runner.app</string>\n\t\t\t<string>__TESTROOT__/Debug-iphoneos/maestro-driver-iosUITests-Runner.app/PlugIns/maestro-driver-iosUITests.xctest</string>\n\t\t</array>\n\t\t<key>DiagnosticCollectionPolicy</key>\n\t\t<integer>1</integer>\n\t\t<key>EnvironmentVariables</key>\n\t\t<dict>\n\t\t\t<key>APP_DISTRIBUTOR_ID_OVERRIDE</key>\n\t\t\t<string>com.apple.AppStore</string>\n\t\t\t<key>DYLD_INSERT_LIBRARIES</key>\n\t\t\t<string>/usr/lib/libRPAC.dylib</string>\n\t\t\t<key>OS_ACTIVITY_DT_MODE</key>\n\t\t\t<string>YES</string>\n\t\t\t<key>PERFC_ENABLE_EXTENDED_DIAGNOSTIC_FORMAT</key>\n\t\t\t<string>1</string>\n\t\t\t<key>PERFC_ENABLE_PROFILE_MODE</key>\n\t\t\t<string>1</string>\n\t\t\t<key>PERFC_RESET_INSERT_LIBRARIES</key>\n\t\t\t<string>1</string>\n\t\t\t<key>PERFC_SUPPRESS_SYSTEM_REPORTS</key>\n\t\t\t<string>1</string>\n\t\t\t<key>SQLITE_ENABLE_THREAD_ASSERTIONS</key>\n\t\t\t<string>1</string>\n\t\t\t<key>TERM</key>\n\t\t\t<string>dumb</string>\n\t\t</dict>\n\t\t<key>IsUITestBundle</key>\n\t\t<true/>\n\t\t<key>IsXCTRunnerHostedTestBundle</key>\n\t\t<true/>\n\t\t<key>PreferredScreenCaptureFormat</key>\n\t\t<string>screenRecording</string>\n\t\t<key>ProductModuleName</key>\n\t\t<string>maestro_driver_iosUITests</string>\n\t\t<key>RunOrder</key>\n\t\t<integer>0</integer>\n\t\t<key>SkipTestIdentifiers</key>\n\t\t<array>\n\t\t\t<string>ViewHierarchyHandlerTests</string>\n\t\t\t<string>ViewHierarchyHandlerTests/testViewHierarchyHandlerReturnsNonEmptyHierarchy()</string>\n\t\t</array>\n\t\t<key>SystemAttachmentLifetime</key>\n\t\t<string>deleteOnSuccess</string>\n\t\t<key>TestBundlePath</key>\n\t\t<string>__TESTHOST__/PlugIns/maestro-driver-iosUITests.xctest</string>\n\t\t<key>TestHostBundleIdentifier</key>\n\t\t<string>dev.mobile.maestro-driver-iosUITests.xctrunner</string>\n\t\t<key>TestHostPath</key>\n\t\t<string>__TESTROOT__/Debug-iphoneos/maestro-driver-iosUITests-Runner.app</string>\n\t\t<key>TestLanguage</key>\n\t\t<string></string>\n\t\t<key>TestRegion</key>\n\t\t<string></string>\n\t\t<key>TestTimeoutsEnabled</key>\n\t\t<false/>\n\t\t<key>TestingEnvironmentVariables</key>\n\t\t<dict>\n\t\t\t<key>DYLD_INSERT_LIBRARIES</key>\n\t\t\t<string>/usr/lib/libRPAC.dylib</string>\n\t\t\t<key>PERFC_SUPPRESS_SYSTEM_REPORTS</key>\n\t\t\t<string>1</string>\n\t\t\t<key>XCODE_SCHEME_NAME</key>\n\t\t\t<string>maestro-driver-ios</string>\n\t\t</dict>\n\t\t<key>ToolchainsSettingValue</key>\n\t\t<array/>\n\t\t<key>UITargetAppCommandLineArguments</key>\n\t\t<array/>\n\t\t<key>UITargetAppEnvironmentVariables</key>\n\t\t<dict>\n\t\t\t<key>APP_DISTRIBUTOR_ID_OVERRIDE</key>\n\t\t\t<string>com.apple.AppStore</string>\n\t\t\t<key>XCODE_SCHEME_NAME</key>\n\t\t\t<string>maestro-driver-ios</string>\n\t\t</dict>\n\t\t<key>UITargetAppPath</key>\n\t\t<string>__TESTROOT__/Debug-iphoneos/maestro-driver-ios.app</string>\n\t\t<key>UITargetAppPerformanceAntipatternCheckerEnabled</key>\n\t\t<true/>\n\t\t<key>UserAttachmentLifetime</key>\n\t\t<string>deleteOnSuccess</string>\n\t</dict>\n</dict>\n</plist>\n"
  },
  {
    "path": "maestro-ios-driver/src/main/resources/screenrecord.sh",
    "content": "# The `simctl recordVideo` command requires a SIGINT to be sent to stop the recording.\n# Before the SIGINT is sent, the video file is not playable.\n# Kotlin / JVM has no API to sent signals to subprocesses.\n# To work around that one could try to use `kill -SIGINT $pid`.\n# Kotlin / JVM on language level < 9 has no API to get the PID of a subprocess.\n# There just isn't a good way to make Kotlin record a video using xctest simctl.\n# That's where this script comes in. It send the SIGINT to simctl as soon as its\n# STDIN pipe is closed.\n\n# Also not that the backend currently does not support hvec. That is why the\n# codec is set to h264.\n\nxcrun simctl io \"$DEVICE_ID\" recordVideo --force --codec h264 \"$RECORDING_PATH\" >\"${RECORDING_PATH}.out\" 2>\"${RECORDING_PATH}.err\" &\nsimctlpid=$!\n\n# Wait briefly for simctl to either fail fast or create the file\nsleep 2\n\nif ! kill -0 \"$simctlpid\" 2>/dev/null; then\n    wait $simctlpid\n    exit_code=$?\n    out_msg=$(cat \"${RECORDING_PATH}.out\" 2>/dev/null)\n    err_msg=$(cat \"${RECORDING_PATH}.err\" 2>/dev/null)\n    rm -f \"${RECORDING_PATH}.out\" \"${RECORDING_PATH}.err\"\n    echo \"RECORDING_FAILED exit_code=$exit_code stdout=[$out_msg] stderr=[$err_msg]\"\n    exit 1\nfi\n\nrm -f \"${RECORDING_PATH}.out\" \"${RECORDING_PATH}.err\"\necho \"RECORDING_STARTED\"\n\n# Wait for STDIN to close\ncat\n\nkill -SIGINT \"$simctlpid\"\nwait $simctlpid\n"
  },
  {
    "path": "maestro-ios-driver/src/test/kotlin/DeviceCtlResponseTest.kt",
    "content": "import com.google.common.truth.Truth.assertThat\nimport io.mockk.every\nimport io.mockk.mockk\nimport org.junit.jupiter.api.Test\nimport util.DeviceCtlProcess\nimport util.LocalIOSDevice\nimport java.nio.file.Files\nimport kotlin.io.path.writeText\n\nclass DeviceCtlResponseTest {\n\n\n    @Test\n    fun `test if deserializing device list works`() {\n        // given\n        val deviceCtlOutput = getDeviceCtlOutput()\n        val deviceOutput = Files.createTempFile(\"output\", \".json\").apply {\n            writeText(deviceCtlOutput)\n        }\n        val deviceCtlProcess = mockk<DeviceCtlProcess>()\n        every { deviceCtlProcess.devicectlDevicesOutput() } returns deviceOutput.toFile()\n\n        // when\n        val connectedDevices = LocalIOSDevice(deviceCtlProcess).listDeviceViaDeviceCtl()\n\n        // then\n        assertThat(connectedDevices).isNotEmpty()\n    }\n\n    private fun getDeviceCtlOutput(): String {\n       return \"\"\"\n           {\n             \"info\" : {\n               \"arguments\" : [\n                 \"devicectl\",\n                 \"--json-output\",\n                 \"./output.json\",\n                 \"list\",\n                 \"devices\"\n               ],\n               \"commandType\" : \"devicectl.list.devices\",\n               \"environment\" : {\n                 \"TERM\" : \"xterm-256color\"\n               },\n               \"jsonVersion\" : 2,\n               \"outcome\" : \"success\",\n               \"version\" : \"397.28\"\n             },\n             \"result\" : {\n               \"devices\" : [\n                 {\n                   \"capabilities\" : [\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.unpairdevice\",\n                       \"name\" : \"Unpair Device\"\n                     }\n                   ],\n                   \"connectionProperties\" : {\n                     \"authenticationType\" : \"manualPairing\",\n                     \"isMobileDeviceOnly\" : false,\n                     \"pairingState\" : \"paired\",\n                     \"potentialHostnames\" : [\n                       \"00008110-001C108C0132401E.coredevice.local\",\n                       \"0097E500-40E1-4842-93ED-89C7D7E25655.coredevice.local\"\n                     ],\n                     \"tunnelState\" : \"unavailable\"\n                   },\n                   \"deviceProperties\" : {\n                     \"bootedFromSnapshot\" : true,\n                     \"bootedSnapshotName\" : \"com.apple.os.update-5B7A8C0795233D12F5E87CF783542EF012DAA964DB278B7F6E53F49E361EB5BF\",\n                     \"ddiServicesAvailable\" : false,\n                     \"developerModeStatus\" : \"enabled\",\n                     \"hasInternalOSBuild\" : false,\n                     \"name\" : \"xx's iPhone \",\n                     \"rootFileSystemIsWritable\" : false\n                   },\n                   \"hardwareProperties\" : {\n                     \"cpuType\" : {\n                       \"name\" : \"arm64e\",\n                       \"subType\" : 2,\n                       \"type\" : 16777228\n                     },\n                     \"deviceType\" : \"iPhone\",\n                     \"ecid\" : 7899492849434654,\n                     \"hardwareModel\" : \"D64AP\",\n                     \"internalStorageCapacity\" : 512000000000,\n                     \"isProductionFused\" : true,\n                     \"marketingName\" : \"iPhone 13 Pro Max\",\n                     \"platform\" : \"iOS\",\n                     \"productType\" : \"iPhone14,3\",\n                     \"reality\" : \"physical\",\n                     \"serialNumber\" : \"C7LTWMC263\",\n                     \"supportedCPUTypes\" : [\n                       {\n                         \"name\" : \"arm64e\",\n                         \"subType\" : 2,\n                         \"type\" : 16777228\n                       },\n                       {\n                         \"name\" : \"arm64\",\n                         \"subType\" : 0,\n                         \"type\" : 16777228\n                       },\n                       {\n                         \"name\" : \"arm64\",\n                         \"subType\" : 1,\n                         \"type\" : 16777228\n                       },\n                       {\n                         \"name\" : \"arm64_32\",\n                         \"subType\" : 1,\n                         \"type\" : 33554444\n                       }\n                     ],\n                     \"supportedDeviceFamilies\" : [\n                       1\n                     ],\n                     \"thinningProductType\" : \"iPhone14,3\",\n                     \"udid\" : \"00008110-001C108C0132401E\"\n                   },\n                   \"identifier\" : \"0097E500-40E1-4842-93ED-89C7D7E25655\",\n                   \"tags\" : [\n\n                   ],\n                   \"visibilityClass\" : \"default\"\n                 },\n                 {\n                   \"capabilities\" : [\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.unpairdevice\",\n                       \"name\" : \"Unpair Device\"\n                     }\n                   ],\n                   \"connectionProperties\" : {\n                     \"authenticationType\" : \"manualPairing\",\n                     \"isMobileDeviceOnly\" : false,\n                     \"lastConnectionDate\" : \"2025-04-03T11:26:51.971Z\",\n                     \"pairingState\" : \"paired\",\n                     \"potentialHostnames\" : [\n                       \"00008301-F0918DA12298C02E.coredevice.local\",\n                       \"35C5DE54-1D3E-4144-8E9A-FA6C38B0B2F1.coredevice.local\"\n                     ],\n                     \"tunnelState\" : \"unavailable\"\n                   },\n                   \"deviceProperties\" : {\n                     \"ddiServicesAvailable\" : false,\n                     \"developerModeStatus\" : \"disabled\",\n                     \"hasInternalOSBuild\" : false,\n                     \"osBuildUpdate\" : \"22S560\",\n                     \"osVersionNumber\" : \"11.3.1\"\n                   },\n                   \"hardwareProperties\" : {\n                     \"cpuType\" : {\n                       \"name\" : \"arm64_32\",\n                       \"subType\" : 1,\n                       \"type\" : 33554444\n                     },\n                     \"deviceType\" : \"appleWatch\",\n                     \"ecid\" : 17334792163935436846,\n                     \"hardwareModel\" : \"N187bAP\",\n                     \"isProductionFused\" : true,\n                     \"marketingName\" : \"Apple Watch Series 7\",\n                     \"platform\" : \"watchOS\",\n                     \"productType\" : \"Watch6,7\",\n                     \"reality\" : \"physical\",\n                     \"serialNumber\" : \"KFKQC1YWK1\",\n                     \"thinningProductType\" : \"Watch6,7\",\n                     \"udid\" : \"00008301-F0918DA12298C02E\"\n                   },\n                   \"identifier\" : \"35C5DE54-1D3E-4144-8E9A-FA6C38B0B2F1\",\n                   \"tags\" : [\n\n                   ],\n                   \"visibilityClass\" : \"default\"\n                 },\n                 {\n                   \"capabilities\" : [\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.getlockstate\",\n                       \"name\" : \"Get Lock State\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"Cryptex1,UseProductClass\",\n                       \"name\" : \"com.apple.security.cryptexd.remote\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.fetchappicons\",\n                       \"name\" : \"Fetch Application Icons\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.listroots\",\n                       \"name\" : \"List Roots\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.spawnexecutable\",\n                       \"name\" : \"Spawn Executable\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.listprocesses\",\n                       \"name\" : \"List Processes\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.sendsignaltoprocess\",\n                       \"name\" : \"Send Signal to Process\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.fetchddimetadata\",\n                       \"name\" : \"Fetch Developer Disk Image Services Metadata\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.installroot\",\n                       \"name\" : \"Install Root\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.getdisplayinfo\",\n                       \"name\" : \"Get Display Information\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.installapp\",\n                       \"name\" : \"Install Application\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.dt.remoteFetchSymbols.dyldSharedCacheFiles\",\n                       \"name\" : \"com.apple.dt.remoteFetchSymbols\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.launchapplication\",\n                       \"name\" : \"Launch Application\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.getdeviceinfo\",\n                       \"name\" : \"Fetch Extended Device Info\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"CryptexInstall\",\n                       \"name\" : \"com.apple.security.cryptexd.remote\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.uninstallapp\",\n                       \"name\" : \"Uninstall Application\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.disableddiservices\",\n                       \"name\" : \"Disable Developer Disk Image Services\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.disconnectdevice\",\n                       \"name\" : \"Disconnect from Device\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.viewdevicescreen\",\n                       \"name\" : \"View Device Screen\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"ReadIdentifiers\",\n                       \"name\" : \"com.apple.security.cryptexd.remote\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.sendmemorywarningtoprocess\",\n                       \"name\" : \"Send Memory Warning to Process\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.dt.profile\",\n                       \"name\" : \"Service Hub Profile\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.uninstallroot\",\n                       \"name\" : \"Uninstall Root\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.unpairdevice\",\n                       \"name\" : \"Unpair Device\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.capturesysdiagnose\",\n                       \"name\" : \"Capture Sysdiagnose\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.listapps\",\n                       \"name\" : \"List Applications\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.querymobilegestalt\",\n                       \"name\" : \"Query MobileGestalt\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.acquireusageassertion\",\n                       \"name\" : \"Acquire Usage Assertion\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.debugserverproxy\",\n                       \"name\" : \"com.apple.internal.dt.remote.debugproxy\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"Cryptex1\",\n                       \"name\" : \"com.apple.security.cryptexd.remote\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.transferFiles\",\n                       \"name\" : \"Transfer Files\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.monitorprocesstermination\",\n                       \"name\" : \"Monitor Process for Termination\"\n                     },\n                     {\n                       \"featureIdentifier\" : \"com.apple.coredevice.feature.rebootdevice\",\n                       \"name\" : \"Reboot Device\"\n                     }\n                   ],\n                   \"connectionProperties\" : {\n                     \"authenticationType\" : \"manualPairing\",\n                     \"isMobileDeviceOnly\" : false,\n                     \"lastConnectionDate\" : \"2025-04-28T11:40:23.973Z\",\n                     \"localHostnames\" : [\n                       \"iPhone.coredevice.local\",\n                       \"00008120-0014485601E3C01E.coredevice.local\",\n                       \"6986451F-A2FF-48DE-A70E-45E06E1F1446.coredevice.local\"\n                     ],\n                     \"pairingState\" : \"paired\",\n                     \"potentialHostnames\" : [\n                       \"00008120-0014485601E3C01E.coredevice.local\",\n                       \"6986451F-A2FF-48DE-A70E-45E06E1F1446.coredevice.local\"\n                     ],\n                     \"transportType\" : \"wired\",\n                     \"tunnelIPAddress\" : \"fdcd:3155:df16::1\",\n                     \"tunnelState\" : \"connected\",\n                     \"tunnelTransportProtocol\" : \"tcp\"\n                   },\n                   \"deviceProperties\" : {\n                     \"bootState\" : \"booted\",\n                     \"bootedFromSnapshot\" : true,\n                     \"bootedSnapshotName\" : \"com.apple.os.update-E6D6B5414ECE9974CF280499DC586E22529542ABCF3C12D2196133074DF91551\",\n                     \"ddiServicesAvailable\" : true,\n                     \"developerModeStatus\" : \"enabled\",\n                     \"hasInternalOSBuild\" : false,\n                     \"name\" : \"iPhone\",\n                     \"osBuildUpdate\" : \"22E252\",\n                     \"osVersionNumber\" : \"18.4.1\",\n                     \"rootFileSystemIsWritable\" : false,\n                     \"screenViewingURL\" : \"devices://device/open?id=6986451F-A2FF-48DE-A70E-45E06E1F1446\"\n                   },\n                   \"hardwareProperties\" : {\n                     \"cpuType\" : {\n                       \"name\" : \"arm64e\",\n                       \"subType\" : 2,\n                       \"type\" : 16777228\n                     },\n                     \"deviceType\" : \"iPhone\",\n                     \"ecid\" : 5709033770303518,\n                     \"hardwareModel\" : \"D73AP\",\n                     \"internalStorageCapacity\" : 128000000000,\n                     \"isProductionFused\" : true,\n                     \"marketingName\" : \"iPhone 14 Pro\",\n                     \"platform\" : \"iOS\",\n                     \"productType\" : \"iPhone15,2\",\n                     \"reality\" : \"physical\",\n                     \"serialNumber\" : \"L7YT7HC7V5\",\n                     \"supportedCPUTypes\" : [\n                       {\n                         \"name\" : \"arm64e\",\n                         \"subType\" : 2,\n                         \"type\" : 16777228\n                       },\n                       {\n                         \"name\" : \"arm64\",\n                         \"subType\" : 0,\n                         \"type\" : 16777228\n                       },\n                       {\n                         \"name\" : \"arm64\",\n                         \"subType\" : 1,\n                         \"type\" : 16777228\n                       },\n                       {\n                         \"name\" : \"arm64_32\",\n                         \"subType\" : 1,\n                         \"type\" : 33554444\n                       }\n                     ],\n                     \"supportedDeviceFamilies\" : [\n                       1\n                     ],\n                     \"thinningProductType\" : \"iPhone15,2\",\n                     \"udid\" : \"00008120-0014485601E3C01E\"\n                   },\n                   \"identifier\" : \"6986451F-A2FF-48DE-A70E-45E06E1F1446\",\n                   \"tags\" : [\n\n                   ],\n                   \"visibilityClass\" : \"default\"\n                 }\n               ]\n             }\n           }\n       \"\"\".trimIndent()\n    }\n}"
  },
  {
    "path": "maestro-ios-driver/src/test/kotlin/IOSBuildProductsExtractorTest.kt",
    "content": "import org.junit.After\nimport org.junit.Before\nimport org.junit.Test\nimport org.junit.jupiter.api.Assertions.assertTrue\nimport util.IOSDeviceType\nimport xcuitest.installer.Context\nimport xcuitest.installer.IOSBuildProductsExtractor\nimport java.nio.file.Files\nimport java.nio.file.Path\n\nclass IOSBuildProductsExtractorTest {\n\n    private lateinit var target: Path\n\n    @Before\n    fun setUp() {\n        target = Files.createTempDirectory(\"test-path\")\n    }\n\n    @Test\n    fun `test drivers are extracted in expected structure for simulator`() {\n        // when\n        IOSBuildProductsExtractor(\n            target,\n            context = Context.CLI,\n            deviceType = IOSDeviceType.SIMULATOR,\n        ).extract(\"driver-iphoneSimulator\")\n\n        // then\n        val configFile = target.resolve(\"maestro-driver-ios-config.xctestrun\")\n        val appDir = target.resolve(\"Debug-iPhoneSimulator/maestro-driver-ios.app\")\n        val runnerDir = target.resolve(\"Debug-iPhoneSimulator/maestro-driver-iosUITests-Runner.app\")\n\n        assertTrue(Files.exists(configFile), \"$configFile does not exist\")\n        assertTrue(Files.isDirectory(appDir), \"$appDir is not a directory\")\n        assertTrue(Files.isDirectory(runnerDir), \"$runnerDir is not a directory\")\n    }\n\n    @After\n    fun tearDown() {\n        target.toFile().deleteRecursively()\n    }\n}"
  },
  {
    "path": "maestro-ios-driver/src/test/kotlin/IOSLaunchArgumentsTest.kt",
    "content": "import com.google.common.truth.Truth.assertThat\nimport org.junit.jupiter.api.Test\nimport util.IOSLaunchArguments.toIOSLaunchArguments\n\nclass IOSLaunchArgumentsTest {\n\n    @Test\n    fun `boolean params with one key are not touched`() {\n        // given\n        val launchArguments = mapOf(\"isCartScreen\" to true)\n\n        // when\n        val iOSLaunchArguments = launchArguments.toIOSLaunchArguments()\n\n        // then\n        assertThat(iOSLaunchArguments).isEqualTo(listOf(\"isCartScreen\", \"true\"))\n    }\n\n    @Test\n    fun `key-value pair without prefixed '-' sign are transformed`() {\n        // given\n        val launchArguments = mapOf<String, Any>(\n            \"isCartScreen\" to false,\n            \"cartValue\" to 3\n        )\n\n        // when\n        val iOSLaunchArguments = launchArguments.toIOSLaunchArguments()\n\n        // then\n        assertThat(iOSLaunchArguments).isEqualTo(listOf(\"isCartScreen\", \"false\", \"-cartValue\", \"3\"))\n    }\n\n    @Test\n    fun `key-value pair with prefixed '-' sign are not changed`() {\n        // given\n        val launchArguments = mapOf<String, Any>(\n            \"isCartScreen\" to false,\n            \"cartValue\" to 3,\n            \"-cartColor\" to \"Orange\"\n        )\n\n        // when\n        val iOSLaunchArguments = launchArguments.toIOSLaunchArguments()\n\n        // then\n        assertThat(iOSLaunchArguments).isEqualTo(\n            listOf(\"isCartScreen\", \"false\", \"-cartValue\", \"3\", \"-cartColor\", \"Orange\")\n        )\n    }\n\n    @Test\n    fun `url arguments are passed correctly`() {\n        // given\n        val launchArguments = mapOf<String, Any>(\n            \"-url\" to \"http://example.com\"\n        )\n\n        // when\n        val iOSLaunchArguments = launchArguments.toIOSLaunchArguments()\n\n        // then\n        assertThat(iOSLaunchArguments).isEqualTo(\n            listOf(\"-url\", \"http://example.com\")\n        )\n    }\n}"
  },
  {
    "path": "maestro-ios-xctest-runner/.gitignore",
    "content": "xcuserdata/\n.build\n.swiftpm"
  },
  {
    "path": "maestro-ios-xctest-runner/MaestroDriverLib/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>CFBundleDevelopmentRegion</key>\n\t<string>$(DEVELOPMENT_LANGUAGE)</string>\n\t<key>CFBundleExecutable</key>\n\t<string>$(EXECUTABLE_NAME)</string>\n\t<key>CFBundleIdentifier</key>\n\t<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>\n\t<key>CFBundleInfoDictionaryVersion</key>\n\t<string>6.0</string>\n\t<key>CFBundleName</key>\n\t<string>$(PRODUCT_NAME)</string>\n\t<key>CFBundlePackageType</key>\n\t<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>\n\t<key>CFBundleShortVersionString</key>\n\t<string>1.0</string>\n\t<key>CFBundleVersion</key>\n\t<string>$(CURRENT_PROJECT_VERSION)</string>\n</dict>\n</plist>"
  },
  {
    "path": "maestro-ios-xctest-runner/MaestroDriverLib/Package.swift",
    "content": "// swift-tools-version: 5.9\n\nimport PackageDescription\n\nlet package = Package(\n    name: \"MaestroDriverLib\",\n    platforms: [\n        .iOS(.v14)\n    ],\n    products: [\n        .library(\n            name: \"MaestroDriverLib\",\n            targets: [\"MaestroDriverLib\"]\n        ),\n    ],\n    targets: [\n        .target(\n            name: \"MaestroDriverLib\",\n            path: \"Sources/MaestroDriverLib\"\n        ),\n        .testTarget(\n            name: \"MaestroDriverLibTests\",\n            dependencies: [\"MaestroDriverLib\"],\n            path: \"Tests/MaestroDriverLibTests\"\n        ),\n    ]\n)"
  },
  {
    "path": "maestro-ios-xctest-runner/MaestroDriverLib/Sources/MaestroDriverLib/Helpers/PermissionButtonFinder.swift",
    "content": "import Foundation\n\n/// Result of finding a button to tap for a permission action\npublic enum PermissionButtonResult: Equatable {\n    /// A button was found at the specified frame\n    case found(frame: AXFrame)\n    /// No buttons found in the hierarchy\n    case noButtonsFound\n    /// Permission value doesn't require action (unset/unknown/not a permission dialog)\n    case noActionRequired\n}\n\n/// Pure logic for finding permission dialog buttons in the view hierarchy.\n/// This class has no XCUITest dependencies and can be unit tested.\npublic final class PermissionButtonFinder {\n\n    static let notificationsPermissionLabel = \"Would Like to Send You Notifications\"\n\n    public init() {}\n\n    /// Recursively finds all button elements in the hierarchy\n    /// - Parameter element: The root element to search from\n    /// - Returns: An array of all button elements found\n    public func findButtons(in element: AXElement) -> [AXElement] {\n        var buttons: [AXElement] = []\n\n        if element.elementType == ElementType.button {\n            buttons.append(element)\n        }\n\n        if let children = element.children {\n            for child in children {\n                buttons.append(contentsOf: findButtons(in: child))\n            }\n        }\n\n        return buttons\n    }\n\n    /// Checks whether the hierarchy contains a notification permission dialog\n    /// by searching for the \"Would Like to Send You Notifications\" label.\n    /// - Parameter element: The root element to search from\n    /// - Returns: `true` if any element's label contains the notification permission text\n    public func isPermissionDialog(_ element: AXElement) -> Bool {\n        let label = element.label.lowercased()\n        let permissionLabel = Self.notificationsPermissionLabel.lowercased()\n        if label.contains(permissionLabel) {\n            return true\n        }\n        if let children = element.children {\n            for child in children {\n                if isPermissionDialog(child) {\n                    return true\n                }\n            }\n        }\n        return false\n    }\n\n    /// Determines which button should be tapped based on the permission value\n    /// - Parameters:\n    ///   - permission: The desired permission action (allow/deny)\n    ///   - hierarchy: The view hierarchy to search for buttons\n    /// - Returns: The result indicating which button frame to tap, or why no action is needed\n    public func findButtonToTap(for permission: PermissionValue, in hierarchy: AXElement) -> PermissionButtonResult {\n        switch permission {\n        case .unset, .unknown:\n            return .noActionRequired\n        case .allow, .deny:\n            break\n        }\n\n        guard isPermissionDialog(hierarchy) else {\n            return .noActionRequired\n        }\n\n        let buttons = findButtons(in: hierarchy)\n\n        guard !buttons.isEmpty else {\n            return .noButtonsFound\n        }\n\n        switch permission {\n        case .allow:\n            if let allowButton = findAllowButton(in: buttons) {\n                return .found(frame: allowButton.frame)\n            }\n            // Fallback: Allow is typically the second button (index 1)\n            if buttons.count > 1 {\n                return .found(frame: buttons[1].frame)\n            }\n            return .found(frame: buttons[0].frame)\n\n        case .deny:\n            if let denyButton = findDenyButton(in: buttons) {\n                return .found(frame: denyButton.frame)\n            }\n            // Fallback: Don't Allow is typically the first button (index 0)\n            return .found(frame: buttons[0].frame)\n\n        case .unset, .unknown:\n            return .noActionRequired\n        }\n    }\n\n    /// Finds the \"Allow\" button by label matching\n    private func findAllowButton(in buttons: [AXElement]) -> AXElement? {\n        buttons.first { button in\n            let label = button.label.lowercased()\n            return label == \"allow\" || label == \"continue\"\n        }\n    }\n\n    /// Finds the \"Don't Allow\" / \"Deny\" button by label matching\n    private func findDenyButton(in buttons: [AXElement]) -> AXElement? {\n        buttons.first { button in\n            let label = button.label.lowercased()\n            return label.contains(\"don't allow\") || label == \"cancel\"\n        }\n    }\n}"
  },
  {
    "path": "maestro-ios-xctest-runner/MaestroDriverLib/Sources/MaestroDriverLib/MaestroDriverLib.swift",
    "content": "// MaestroDriverLib\n// A framework for UI automation logic that doesn't depend on XCUITest\n\n// Re-export all public types\n@_exported import Foundation"
  },
  {
    "path": "maestro-ios-xctest-runner/MaestroDriverLib/Sources/MaestroDriverLib/Models/AXElement.swift",
    "content": "import Foundation\nimport CoreGraphics\n\n/// Represents an accessibility element in the view hierarchy.\n/// This is a pure domain model without XCUITest dependencies.\npublic struct AXElement: Codable, Equatable {\n    public let identifier: String\n    public let frame: AXFrame\n    public let value: String?\n    public let title: String?\n    public let label: String\n    public let elementType: Int\n    public let enabled: Bool\n    public let horizontalSizeClass: Int\n    public let verticalSizeClass: Int\n    public let placeholderValue: String?\n    public let selected: Bool\n    public let hasFocus: Bool\n    public var children: [AXElement]?\n    public let windowContextID: Double\n    public let displayID: Int\n\n    public init(children: [AXElement]) {\n        self.children = children\n        self.label = \"\"\n        self.elementType = 0\n        self.identifier = \"\"\n        self.horizontalSizeClass = 0\n        self.windowContextID = 0\n        self.verticalSizeClass = 0\n        self.selected = false\n        self.displayID = 0\n        self.hasFocus = false\n        self.placeholderValue = nil\n        self.value = nil\n        self.frame = .zero\n        self.enabled = false\n        self.title = nil\n    }\n\n    public init(\n        identifier: String = \"\",\n        frame: AXFrame = .zero,\n        value: String? = nil,\n        title: String? = nil,\n        label: String = \"\",\n        elementType: Int = 0,\n        enabled: Bool = false,\n        horizontalSizeClass: Int = 0,\n        verticalSizeClass: Int = 0,\n        placeholderValue: String? = nil,\n        selected: Bool = false,\n        hasFocus: Bool = false,\n        displayID: Int = 0,\n        windowContextID: Double = 0,\n        children: [AXElement]? = nil\n    ) {\n        self.identifier = identifier\n        self.frame = frame\n        self.value = value\n        self.title = title\n        self.label = label\n        self.elementType = elementType\n        self.enabled = enabled\n        self.horizontalSizeClass = horizontalSizeClass\n        self.verticalSizeClass = verticalSizeClass\n        self.placeholderValue = placeholderValue\n        self.selected = selected\n        self.hasFocus = hasFocus\n        self.displayID = displayID\n        self.windowContextID = windowContextID\n        self.children = children\n    }\n\n    public func encode(to encoder: Encoder) throws {\n        var container = encoder.container(keyedBy: CodingKeys.self)\n        try container.encode(self.identifier, forKey: .identifier)\n        try container.encode(self.frame, forKey: .frame)\n        try container.encodeIfPresent(self.value, forKey: .value)\n        try container.encodeIfPresent(self.title, forKey: .title)\n        try container.encode(self.label, forKey: .label)\n        try container.encode(self.elementType, forKey: .elementType)\n        try container.encode(self.enabled, forKey: .enabled)\n        try container.encode(self.horizontalSizeClass, forKey: .horizontalSizeClass)\n        try container.encode(self.verticalSizeClass, forKey: .verticalSizeClass)\n        try container.encodeIfPresent(self.placeholderValue, forKey: .placeholderValue)\n        try container.encode(self.selected, forKey: .selected)\n        try container.encode(self.hasFocus, forKey: .hasFocus)\n        try container.encodeIfPresent(self.children, forKey: .children)\n        try container.encode(self.windowContextID, forKey: .windowContextID)\n        try container.encode(self.displayID, forKey: .displayID)\n    }\n\n    public func depth() -> Int {\n        guard let children = children else { return 1 }\n        let max = children.map { $0.depth() + 1 }.max()\n        return max ?? 1\n    }\n\n    public func filterAllChildrenNotInKeyboardBounds(_ keyboardFrame: CGRect) -> [AXElement] {\n        var filteredChildren = [AXElement]()\n\n        func filterChildrenRecursively(_ element: AXElement, _ ancestorAdded: Bool) {\n            let childFrame = CGRect(\n                x: element.frame[\"X\"] ?? 0,\n                y: element.frame[\"Y\"] ?? 0,\n                width: element.frame[\"Width\"] ?? 0,\n                height: element.frame[\"Height\"] ?? 0\n            )\n\n            var currentAncestorAdded = ancestorAdded\n\n            if !keyboardFrame.intersects(childFrame) && !ancestorAdded {\n                filteredChildren.append(element)\n                currentAncestorAdded = true\n            }\n\n            element.children?.forEach { child in\n                filterChildrenRecursively(child, currentAncestorAdded)\n            }\n        }\n\n        filterChildrenRecursively(self, false)\n        return filteredChildren\n    }\n}"
  },
  {
    "path": "maestro-ios-xctest-runner/MaestroDriverLib/Sources/MaestroDriverLib/Models/AXFrame.swift",
    "content": "import Foundation\n\n/// A dictionary representing frame coordinates with keys: \"X\", \"Y\", \"Width\", \"Height\"\npublic typealias AXFrame = [String: Double]\n\nextension AXFrame {\n    public static var zero: Self {\n        [\"X\": 0, \"Y\": 0, \"Width\": 0, \"Height\": 0]\n    }\n\n    public var x: Double { self[\"X\"] ?? 0 }\n    public var y: Double { self[\"Y\"] ?? 0 }\n    public var width: Double { self[\"Width\"] ?? 0 }\n    public var height: Double { self[\"Height\"] ?? 0 }\n\n    public var centerX: Double { x + width / 2 }\n    public var centerY: Double { y + height / 2 }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/MaestroDriverLib/Sources/MaestroDriverLib/Models/ElementType.swift",
    "content": "import Foundation\n\n/// Element type constants matching XCUIElement.ElementType raw values\npublic enum ElementType {\n    /// XCUIElement.ElementType.button.rawValue\n    public static let button: Int = 9\n}"
  },
  {
    "path": "maestro-ios-xctest-runner/MaestroDriverLib/Sources/MaestroDriverLib/Models/PermissionValue.swift",
    "content": "import Foundation\n\n/// Represents the desired permission state\npublic enum PermissionValue: String, Codable, Equatable {\n    case allow\n    case deny\n    case unset\n    case unknown\n\n    public init(from decoder: Decoder) throws {\n        self = try PermissionValue(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown\n    }\n}"
  },
  {
    "path": "maestro-ios-xctest-runner/MaestroDriverLib/Tests/MaestroDriverLibTests/AXElementTests.swift",
    "content": "import XCTest\n@testable import MaestroDriverLib\n\nfinal class AXElementTests: XCTestCase {\n\n    // MARK: - Initialization Tests\n\n    func testInit_withChildren_setsDefaultValues() {\n        let child = AXElement(label: \"Child\", elementType: 1, children: nil)\n        let element = AXElement(children: [child])\n\n        XCTAssertEqual(element.label, \"\")\n        XCTAssertEqual(element.elementType, 0)\n        XCTAssertEqual(element.identifier, \"\")\n        XCTAssertEqual(element.children?.count, 1)\n        XCTAssertEqual(element.frame, .zero)\n    }\n\n    func testInit_withAllParameters_setsAllValues() {\n        let frame: AXFrame = [\"X\": 10, \"Y\": 20, \"Width\": 100, \"Height\": 50]\n        let element = AXElement(\n            identifier: \"test-id\",\n            frame: frame,\n            value: \"test-value\",\n            title: \"Test Title\",\n            label: \"Test Label\",\n            elementType: 9,\n            enabled: true,\n            horizontalSizeClass: 1,\n            verticalSizeClass: 2,\n            placeholderValue: \"placeholder\",\n            selected: true,\n            hasFocus: true,\n            displayID: 1,\n            windowContextID: 123.0,\n            children: nil\n        )\n\n        XCTAssertEqual(element.identifier, \"test-id\")\n        XCTAssertEqual(element.frame[\"X\"], 10)\n        XCTAssertEqual(element.value, \"test-value\")\n        XCTAssertEqual(element.title, \"Test Title\")\n        XCTAssertEqual(element.label, \"Test Label\")\n        XCTAssertEqual(element.elementType, 9)\n        XCTAssertTrue(element.enabled)\n        XCTAssertEqual(element.horizontalSizeClass, 1)\n        XCTAssertEqual(element.verticalSizeClass, 2)\n        XCTAssertEqual(element.placeholderValue, \"placeholder\")\n        XCTAssertTrue(element.selected)\n        XCTAssertTrue(element.hasFocus)\n        XCTAssertEqual(element.displayID, 1)\n        XCTAssertEqual(element.windowContextID, 123.0)\n        XCTAssertNil(element.children)\n    }\n\n    // MARK: - Depth Tests\n\n    func testDepth_withNoChildren_returnsOne() {\n        let element = AXElement(\n            elementType: 0,\n            children: nil\n        )\n\n        XCTAssertEqual(element.depth(), 1)\n    }\n\n    func testDepth_withEmptyChildren_returnsOne() {\n        let element = AXElement(children: [])\n\n        XCTAssertEqual(element.depth(), 1)\n    }\n\n    func testDepth_withOneLevel_returnsTwo() {\n        let child = AXElement(elementType: 0, children: nil)\n        let element = AXElement(children: [child])\n\n        XCTAssertEqual(element.depth(), 2)\n    }\n\n    func testDepth_withMultipleLevels_returnsCorrectDepth() {\n        let grandchild = AXElement(elementType: 0, children: nil)\n        let child = AXElement(elementType: 0, children: [grandchild])\n        let element = AXElement(children: [child])\n\n        XCTAssertEqual(element.depth(), 3)\n    }\n\n    func testDepth_withUnevenTree_returnsMaxDepth() {\n        // Create a tree where one branch is deeper than the other\n        let deepGrandchild = AXElement(elementType: 0, children: nil)\n        let deepChild = AXElement(elementType: 0, children: [deepGrandchild])\n        let shallowChild = AXElement(elementType: 0, children: nil)\n        let element = AXElement(children: [deepChild, shallowChild])\n\n        XCTAssertEqual(element.depth(), 3)\n    }\n\n    // MARK: - Codable Tests\n\n    func testCodable_encodesAndDecodesCorrectly() throws {\n        let original = AXElement(\n            identifier: \"test\",\n            frame: [\"X\": 10, \"Y\": 20, \"Width\": 100, \"Height\": 50],\n            value: \"value\",\n            title: \"title\",\n            label: \"label\",\n            elementType: 9,\n            enabled: true,\n            horizontalSizeClass: 1,\n            verticalSizeClass: 2,\n            placeholderValue: nil,\n            selected: false,\n            hasFocus: true,\n            displayID: 1,\n            windowContextID: 100,\n            children: nil\n        )\n\n        let encoder = JSONEncoder()\n        let data = try encoder.encode(original)\n\n        let decoder = JSONDecoder()\n        let decoded = try decoder.decode(AXElement.self, from: data)\n\n        XCTAssertEqual(decoded.identifier, original.identifier)\n        XCTAssertEqual(decoded.label, original.label)\n        XCTAssertEqual(decoded.elementType, original.elementType)\n        XCTAssertEqual(decoded.frame[\"X\"], original.frame[\"X\"])\n    }\n\n    // MARK: - Equatable Tests\n\n    func testEquatable_equalElements_areEqual() {\n        let element1 = AXElement(\n            identifier: \"test\",\n            label: \"Test\",\n            elementType: 9,\n            children: nil\n        )\n        let element2 = AXElement(\n            identifier: \"test\",\n            label: \"Test\",\n            elementType: 9,\n            children: nil\n        )\n\n        XCTAssertEqual(element1, element2)\n    }\n\n    func testEquatable_differentElements_areNotEqual() {\n        let element1 = AXElement(\n            identifier: \"test1\",\n            label: \"Test 1\",\n            elementType: 9,\n            children: nil\n        )\n        let element2 = AXElement(\n            identifier: \"test2\",\n            label: \"Test 2\",\n            elementType: 9,\n            children: nil\n        )\n\n        XCTAssertNotEqual(element1, element2)\n    }\n}"
  },
  {
    "path": "maestro-ios-xctest-runner/MaestroDriverLib/Tests/MaestroDriverLibTests/AXFrameTests.swift",
    "content": "import XCTest\n@testable import MaestroDriverLib\n\nfinal class AXFrameTests: XCTestCase {\n\n    func testZeroFrame_hasAllZeroValues() {\n        let frame: AXFrame = .zero\n\n        XCTAssertEqual(frame.x, 0)\n        XCTAssertEqual(frame.y, 0)\n        XCTAssertEqual(frame.width, 0)\n        XCTAssertEqual(frame.height, 0)\n    }\n\n    func testFrameAccessors_returnCorrectValues() {\n        let frame: AXFrame = [\n            \"X\": 10,\n            \"Y\": 20,\n            \"Width\": 100,\n            \"Height\": 50\n        ]\n\n        XCTAssertEqual(frame.x, 10)\n        XCTAssertEqual(frame.y, 20)\n        XCTAssertEqual(frame.width, 100)\n        XCTAssertEqual(frame.height, 50)\n    }\n\n    func testCenterX_calculatesCorrectly() {\n        let frame: AXFrame = [\n            \"X\": 100,\n            \"Y\": 200,\n            \"Width\": 80,\n            \"Height\": 40\n        ]\n\n        XCTAssertEqual(frame.centerX, 140) // 100 + 80/2\n    }\n\n    func testCenterY_calculatesCorrectly() {\n        let frame: AXFrame = [\n            \"X\": 100,\n            \"Y\": 200,\n            \"Width\": 80,\n            \"Height\": 40\n        ]\n\n        XCTAssertEqual(frame.centerY, 220) // 200 + 40/2\n    }\n\n    func testFrameWithMissingKeys_usesZeroDefaults() {\n        let frame: AXFrame = [\"X\": 10]\n\n        XCTAssertEqual(frame.x, 10)\n        XCTAssertEqual(frame.y, 0)\n        XCTAssertEqual(frame.width, 0)\n        XCTAssertEqual(frame.height, 0)\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/MaestroDriverLib/Tests/MaestroDriverLibTests/PermissionButtonFinderTests.swift",
    "content": "import XCTest\n@testable import MaestroDriverLib\n\nfinal class PermissionButtonFinderTests: XCTestCase {\n\n    var sut: PermissionButtonFinder!\n\n    override func setUp() {\n        super.setUp()\n        sut = PermissionButtonFinder()\n    }\n\n    override func tearDown() {\n        sut = nil\n        super.tearDown()\n    }\n\n    // MARK: - Helper\n\n    /// Creates a hierarchy that looks like a real notification permission dialog:\n    /// a root element containing a label with the notification text, plus buttons.\n    private func makeNotificationPermissionDialog(buttons: [AXElement]) -> AXElement {\n        let notificationLabel = AXElement(\n            label: \"\\u{201C}MyApp\\u{201D} Would Like to Send You Notifications\",\n            elementType: 0, // Static text\n            children: nil\n        )\n        return AXElement(\n            elementType: 0,\n            children: [notificationLabel] + buttons\n        )\n    }\n\n    // MARK: - findButtons Tests\n\n    func testFindButtons_withNoButtons_returnsEmptyArray() {\n        let hierarchy = AXElement(\n            elementType: 0, // Not a button\n            children: []\n        )\n\n        let buttons = sut.findButtons(in: hierarchy)\n\n        XCTAssertTrue(buttons.isEmpty)\n    }\n\n    func testFindButtons_withSingleButton_returnsButton() {\n        let button = AXElement(\n            label: \"Allow\",\n            elementType: ElementType.button,\n            children: nil\n        )\n\n        let buttons = sut.findButtons(in: button)\n\n        XCTAssertEqual(buttons.count, 1)\n        XCTAssertEqual(buttons.first?.label, \"Allow\")\n    }\n\n    func testFindButtons_withNestedButtons_returnsAllButtons() {\n        let allowButton = AXElement(\n            label: \"Allow\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let denyButton = AXElement(\n            label: \"Don't Allow\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let container = AXElement(\n            elementType: 0, // Container, not a button\n            children: [allowButton, denyButton]\n        )\n        let hierarchy = AXElement(\n            elementType: 0,\n            children: [container]\n        )\n\n        let buttons = sut.findButtons(in: hierarchy)\n\n        XCTAssertEqual(buttons.count, 2)\n        XCTAssertEqual(buttons[0].label, \"Allow\")\n        XCTAssertEqual(buttons[1].label, \"Don't Allow\")\n    }\n\n    func testFindButtons_withDeeplyNestedButtons_findsAllButtons() {\n        let deepButton = AXElement(\n            label: \"Deep Button\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let level2 = AXElement(\n            elementType: 0,\n            children: [deepButton]\n        )\n        let level1 = AXElement(\n            elementType: 0,\n            children: [level2]\n        )\n        let hierarchy = AXElement(\n            elementType: 0,\n            children: [level1]\n        )\n\n        let buttons = sut.findButtons(in: hierarchy)\n\n        XCTAssertEqual(buttons.count, 1)\n        XCTAssertEqual(buttons.first?.label, \"Deep Button\")\n    }\n\n    // MARK: - isPermissionDialog Tests\n\n    func testIsPermissionDialog_withNotificationLabel_returnsTrue() {\n        let label = AXElement(\n            label: \"\\u{201C}MyApp\\u{201D} Would Like to Send You Notifications\",\n            elementType: 0,\n            children: nil\n        )\n        let hierarchy = AXElement(children: [label])\n\n        XCTAssertTrue(sut.isPermissionDialog(hierarchy))\n    }\n\n    func testIsPermissionDialog_withNestedNotificationLabel_returnsTrue() {\n        let label = AXElement(\n            label: \"App Would Like to Send You Notifications\",\n            elementType: 0,\n            children: nil\n        )\n        let nested = AXElement(elementType: 0, children: [label])\n        let hierarchy = AXElement(children: [nested])\n\n        XCTAssertTrue(sut.isPermissionDialog(hierarchy))\n    }\n\n    func testIsPermissionDialog_isCaseInsensitive() {\n        let label = AXElement(\n            label: \"app would like to send you notifications\",\n            elementType: 0,\n            children: nil\n        )\n        let hierarchy = AXElement(children: [label])\n\n        XCTAssertTrue(sut.isPermissionDialog(hierarchy))\n    }\n\n    func testIsPermissionDialog_withOpenInDialog_returnsFalse() {\n        let label = AXElement(\n            label: \"Open in \\u{201C}My App Staging\\u{201D}\",\n            elementType: 0,\n            children: nil\n        )\n        let hierarchy = AXElement(children: [label])\n\n        XCTAssertFalse(sut.isPermissionDialog(hierarchy))\n    }\n\n    func testIsPermissionDialog_withUnrelatedDialog_returnsFalse() {\n        let label = AXElement(\n            label: \"Are you sure you want to delete this?\",\n            elementType: 0,\n            children: nil\n        )\n        let hierarchy = AXElement(children: [label])\n\n        XCTAssertFalse(sut.isPermissionDialog(hierarchy))\n    }\n\n    func testIsPermissionDialog_withEmptyHierarchy_returnsFalse() {\n        let hierarchy = AXElement(children: [])\n\n        XCTAssertFalse(sut.isPermissionDialog(hierarchy))\n    }\n\n    // MARK: - findButtonToTap Tests (Allow Permission)\n\n    func testFindButtonToTap_allowPermission_findsAllowButtonByLabel() {\n        let allowButton = AXElement(\n            frame: [\"X\": 100, \"Y\": 200, \"Width\": 80, \"Height\": 40],\n            label: \"Allow\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let denyButton = AXElement(\n            frame: [\"X\": 10, \"Y\": 200, \"Width\": 80, \"Height\": 40],\n            label: \"Don't Allow\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let hierarchy = makeNotificationPermissionDialog(buttons: [denyButton, allowButton])\n\n        let result = sut.findButtonToTap(for: .allow, in: hierarchy)\n\n        if case .found(let frame) = result {\n            XCTAssertEqual(frame[\"X\"], 100)\n            XCTAssertEqual(frame[\"Y\"], 200)\n        } else {\n            XCTFail(\"Expected .found result\")\n        }\n    }\n\n    func testFindButtonToTap_allowPermission_findsContinueButton() {\n        let continueButton = AXElement(\n            frame: [\"X\": 100, \"Y\": 200, \"Width\": 80, \"Height\": 40],\n            label: \"Continue\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let hierarchy = makeNotificationPermissionDialog(buttons: [continueButton])\n\n        let result = sut.findButtonToTap(for: .allow, in: hierarchy)\n\n        if case .found(let frame) = result {\n            XCTAssertEqual(frame[\"X\"], 100)\n        } else {\n            XCTFail(\"Expected .found result\")\n        }\n    }\n\n    func testFindButtonToTap_allowPermission_fallsBackToSecondButton() {\n        let firstButton = AXElement(\n            frame: [\"X\": 10, \"Y\": 200, \"Width\": 80, \"Height\": 40],\n            label: \"Nope\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let secondButton = AXElement(\n            frame: [\"X\": 100, \"Y\": 200, \"Width\": 80, \"Height\": 40],\n            label: \"OK\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let hierarchy = makeNotificationPermissionDialog(buttons: [firstButton, secondButton])\n\n        let result = sut.findButtonToTap(for: .allow, in: hierarchy)\n\n        if case .found(let frame) = result {\n            XCTAssertEqual(frame[\"X\"], 100)\n        } else {\n            XCTFail(\"Expected .found result\")\n        }\n    }\n\n    // MARK: - findButtonToTap Tests (Deny Permission)\n\n    func testFindButtonToTap_denyPermission_findsDontAllowButtonByLabel() {\n        let allowButton = AXElement(\n            frame: [\"X\": 100, \"Y\": 200, \"Width\": 80, \"Height\": 40],\n            label: \"Allow\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let denyButton = AXElement(\n            frame: [\"X\": 10, \"Y\": 200, \"Width\": 80, \"Height\": 40],\n            label: \"Don't Allow\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let hierarchy = makeNotificationPermissionDialog(buttons: [allowButton, denyButton])\n\n        let result = sut.findButtonToTap(for: .deny, in: hierarchy)\n\n        if case .found(let frame) = result {\n            XCTAssertEqual(frame[\"X\"], 10)\n        } else {\n            XCTFail(\"Expected .found result\")\n        }\n    }\n\n    func testFindButtonToTap_denyPermission_findsCancelButton() {\n        let cancelButton = AXElement(\n            frame: [\"X\": 10, \"Y\": 200, \"Width\": 80, \"Height\": 40],\n            label: \"Cancel\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let hierarchy = makeNotificationPermissionDialog(buttons: [cancelButton])\n\n        let result = sut.findButtonToTap(for: .deny, in: hierarchy)\n\n        if case .found(let frame) = result {\n            XCTAssertEqual(frame[\"X\"], 10)\n        } else {\n            XCTFail(\"Expected .found result\")\n        }\n    }\n\n    func testFindButtonToTap_denyPermission_fallsBackToFirstButton() {\n        let firstButton = AXElement(\n            frame: [\"X\": 10, \"Y\": 200, \"Width\": 80, \"Height\": 40],\n            label: \"Nope\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let secondButton = AXElement(\n            frame: [\"X\": 100, \"Y\": 200, \"Width\": 80, \"Height\": 40],\n            label: \"OK\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let hierarchy = makeNotificationPermissionDialog(buttons: [firstButton, secondButton])\n\n        let result = sut.findButtonToTap(for: .deny, in: hierarchy)\n\n        if case .found(let frame) = result {\n            XCTAssertEqual(frame[\"X\"], 10)\n        } else {\n            XCTFail(\"Expected .found result\")\n        }\n    }\n\n    // MARK: - findButtonToTap Tests (No Action Required)\n\n    func testFindButtonToTap_unsetPermission_returnsNoActionRequired() {\n        let hierarchy = AXElement(children: [])\n\n        let result = sut.findButtonToTap(for: .unset, in: hierarchy)\n\n        XCTAssertEqual(result, .noActionRequired)\n    }\n\n    func testFindButtonToTap_unknownPermission_returnsNoActionRequired() {\n        let hierarchy = AXElement(children: [])\n\n        let result = sut.findButtonToTap(for: .unknown, in: hierarchy)\n\n        XCTAssertEqual(result, .noActionRequired)\n    }\n\n    // MARK: - findButtonToTap Tests (No Buttons Found)\n\n    func testFindButtonToTap_noButtonsInHierarchy_returnsNoButtonsFound() {\n        let notificationLabel = AXElement(\n            label: \"App Would Like to Send You Notifications\",\n            elementType: 0,\n            children: nil\n        )\n        let textElement = AXElement(\n            elementType: 0,\n            children: nil\n        )\n        let hierarchy = AXElement(children: [notificationLabel, textElement])\n\n        let result = sut.findButtonToTap(for: .allow, in: hierarchy)\n\n        XCTAssertEqual(result, .noButtonsFound)\n    }\n\n    // MARK: - findButtonToTap Tests (Not a Permission Dialog)\n\n    func testFindButtonToTap_openInDialog_returnsNotPermissionDialog() {\n        let label = AXElement(\n            label: \"Open in \\u{201C}My App Staging\\u{201D}\",\n            elementType: 0,\n            children: nil\n        )\n        let openButton = AXElement(\n            frame: [\"X\": 100, \"Y\": 200, \"Width\": 80, \"Height\": 40],\n            label: \"Allow\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let hierarchy = AXElement(children: [label, openButton])\n\n        let result = sut.findButtonToTap(for: .allow, in: hierarchy)\n\n        XCTAssertEqual(result, .noActionRequired)\n    }\n\n    func testFindButtonToTap_openInDialog_doesNotTapDenyButton() {\n        let label = AXElement(\n            label: \"Open in \\u{201C}My App Staging\\u{201D}\",\n            elementType: 0,\n            children: nil\n        )\n        let cancelButton = AXElement(\n            frame: [\"X\": 10, \"Y\": 200, \"Width\": 80, \"Height\": 40],\n            label: \"Cancel\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let allowButton = AXElement(\n            frame: [\"X\": 100, \"Y\": 200, \"Width\": 80, \"Height\": 40],\n            label: \"Allow\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let hierarchy = AXElement(children: [label, cancelButton, allowButton])\n\n        let result = sut.findButtonToTap(for: .deny, in: hierarchy)\n\n        XCTAssertEqual(result, .noActionRequired)\n    }\n\n    func testFindButtonToTap_genericDeleteDialog_returnsNotPermissionDialog() {\n        let label = AXElement(\n            label: \"Are you sure you want to delete this?\",\n            elementType: 0,\n            children: nil\n        )\n        let deleteButton = AXElement(\n            frame: [\"X\": 100, \"Y\": 200, \"Width\": 80, \"Height\": 40],\n            label: \"Delete\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let cancelButton = AXElement(\n            frame: [\"X\": 10, \"Y\": 200, \"Width\": 80, \"Height\": 40],\n            label: \"Cancel\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let hierarchy = AXElement(children: [label, deleteButton, cancelButton])\n\n        let result = sut.findButtonToTap(for: .allow, in: hierarchy)\n\n        XCTAssertEqual(result, .noActionRequired)\n    }\n\n    func testFindButtonToTap_dialogWithNoLabels_returnsNotPermissionDialog() {\n        let button = AXElement(\n            frame: [\"X\": 100, \"Y\": 200, \"Width\": 80, \"Height\": 40],\n            label: \"Allow\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let hierarchy = AXElement(children: [button])\n\n        let result = sut.findButtonToTap(for: .allow, in: hierarchy)\n\n        XCTAssertEqual(result, .noActionRequired)\n    }\n\n    // MARK: - findButtonToTap with notification dialog selects correct button\n\n    func testFindButtonToTap_notificationDialog_allowSelectsCorrectButton() {\n        let allowButton = AXElement(\n            frame: [\"X\": 200, \"Y\": 300, \"Width\": 80, \"Height\": 40],\n            label: \"Allow\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let denyButton = AXElement(\n            frame: [\"X\": 50, \"Y\": 300, \"Width\": 80, \"Height\": 40],\n            label: \"Don't Allow\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let hierarchy = makeNotificationPermissionDialog(buttons: [denyButton, allowButton])\n\n        let result = sut.findButtonToTap(for: .allow, in: hierarchy)\n\n        if case .found(let frame) = result {\n            XCTAssertEqual(frame[\"X\"], 200, \"Should select the Allow button, not Don't Allow\")\n        } else {\n            XCTFail(\"Expected .found result for notification permission dialog\")\n        }\n    }\n\n    func testFindButtonToTap_notificationDialog_denySelectsCorrectButton() {\n        let allowButton = AXElement(\n            frame: [\"X\": 200, \"Y\": 300, \"Width\": 80, \"Height\": 40],\n            label: \"Allow\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let denyButton = AXElement(\n            frame: [\"X\": 50, \"Y\": 300, \"Width\": 80, \"Height\": 40],\n            label: \"Don't Allow\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let hierarchy = makeNotificationPermissionDialog(buttons: [denyButton, allowButton])\n\n        let result = sut.findButtonToTap(for: .deny, in: hierarchy)\n\n        if case .found(let frame) = result {\n            XCTAssertEqual(frame[\"X\"], 50, \"Should select the Don't Allow button\")\n        } else {\n            XCTFail(\"Expected .found result for notification permission dialog\")\n        }\n    }\n\n    // MARK: - Case Insensitivity Tests\n\n    func testFindButtonToTap_allowPermission_isCaseInsensitive() {\n        let allowButton = AXElement(\n            frame: [\"X\": 100, \"Y\": 200, \"Width\": 80, \"Height\": 40],\n            label: \"ALLOW\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let hierarchy = makeNotificationPermissionDialog(buttons: [allowButton])\n\n        let result = sut.findButtonToTap(for: .allow, in: hierarchy)\n\n        if case .found(let frame) = result {\n            XCTAssertEqual(frame[\"X\"], 100)\n        } else {\n            XCTFail(\"Expected .found result\")\n        }\n    }\n\n    func testFindButtonToTap_denyPermission_isCaseInsensitive() {\n        let denyButton = AXElement(\n            frame: [\"X\": 10, \"Y\": 200, \"Width\": 80, \"Height\": 40],\n            label: \"DON'T ALLOW\",\n            elementType: ElementType.button,\n            children: nil\n        )\n        let hierarchy = makeNotificationPermissionDialog(buttons: [denyButton])\n\n        let result = sut.findButtonToTap(for: .deny, in: hierarchy)\n\n        if case .found(let frame) = result {\n            XCTAssertEqual(frame[\"X\"], 10)\n        } else {\n            XCTFail(\"Expected .found result\")\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/build-maestro-ios-runner-all.sh",
    "content": "#!/usr/bin/env bash\n\nDESTINATION=\"generic/platform=iOS Simulator\" DERIVED_DATA_DIR=\"driver-iPhoneSimulator\" $PWD/maestro-ios-xctest-runner/build-maestro-ios-runner.sh\n\nif [ -z \"${DEVELOPMENT_TEAM:-}\" ]; then\n  echo \"DEVELOPMENT_TEAM is not set, only building for iOS Simulator\"\nelse\n  DESTINATION=\"generic/platform=iphoneos\" DERIVED_DATA_DIR=\"driver-iphoneos\" $PWD/maestro-ios-xctest-runner/build-maestro-ios-runner.sh\nfi\n"
  },
  {
    "path": "maestro-ios-xctest-runner/build-maestro-ios-runner.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nif [ \"$(basename \"$PWD\")\" != \"Maestro\" ]; then\n\techo \"This script must be run from the maestro root directory\"\n\texit 1\nfi\n\nDERIVED_DATA_PATH=\"${DERIVED_DATA_DIR:-driver-iPhoneSimulator}\"\nDESTINATION=\"${DESTINATION:-generic/platform=iOS Simulator}\"\n\n# Determine build output directory\nif [[ \"$DESTINATION\" == *\"iOS Simulator\"* ]]; then\n\tBUILD_OUTPUT_DIR=\"Debug-iphonesimulator\"\nelse\n\tBUILD_OUTPUT_DIR=\"Debug-iphoneos\"\nfi\n\nif [[ \"$DESTINATION\" == *\"iOS Simulator\"* ]]; then\n  DEVELOPMENT_TEAM_OPT=\"\"\nelse\n  echo \"Building iphoneos drivers for team: ${DEVELOPMENT_TEAM}...\"\n\tDEVELOPMENT_TEAM_OPT=\"DEVELOPMENT_TEAM=${DEVELOPMENT_TEAM}\"\nfi\n\nif [[ -z \"${ARCHS:-}\" ]]; then\n  if [[ \"$DESTINATION\" == *\"iOS Simulator\"* ]]; then\n    ARCHS=\"x86_64 arm64\" # Build for all standard simulator architectures\n  else\n    ARCHS=\"arm64\" # Build only for arm64 on device builds\n  fi\nfi\n\necho \"Building iOS driver for arch: $ARCHS for $DESTINATION\"\n\nrm -rf \"$PWD/$DERIVED_DATA_PATH\"\nrm -rf \"./maestro-ios-driver/src/main/resources/$DERIVED_DATA_PATH\"\n\nmkdir -p \"$PWD/$DERIVED_DATA_PATH\"\nmkdir -p \"./maestro-ios-driver/src/main/resources/$DERIVED_DATA_PATH\"\nmkdir -p \"./maestro-ios-driver/src/main/resources/$DERIVED_DATA_PATH/$BUILD_OUTPUT_DIR\"\n\nxcodebuild clean build-for-testing \\\n  -project ./maestro-ios-xctest-runner/maestro-driver-ios.xcodeproj \\\n  -derivedDataPath \"$PWD/$DERIVED_DATA_PATH\" \\\n  -scheme maestro-driver-ios \\\n  -destination \"$DESTINATION\" \\\n  ARCHS=\"$ARCHS\" ${DEVELOPMENT_TEAM_OPT}\n\n## Copy built apps and xctestrun file\ncp -r \\\n\t\"./$DERIVED_DATA_PATH/Build/Products/$BUILD_OUTPUT_DIR/maestro-driver-iosUITests-Runner.app\" \\\n\t\"./maestro-ios-driver/src/main/resources/$DERIVED_DATA_PATH/maestro-driver-iosUITests-Runner.app\"\n\ncp -r \\\n\t\"./$DERIVED_DATA_PATH/Build/Products/$BUILD_OUTPUT_DIR/maestro-driver-ios.app\" \\\n\t\"./maestro-ios-driver/src/main/resources/$DERIVED_DATA_PATH/maestro-driver-ios.app\"\n\n# Find and copy the .xctestrun file\nXCTESTRUN_FILE=$(find \"$PWD/$DERIVED_DATA_PATH/Build/Products\" -name \"*.xctestrun\" | head -n 1)\ncp \"$XCTESTRUN_FILE\" \"./maestro-ios-driver/src/main/resources/$DERIVED_DATA_PATH/maestro-driver-ios-config.xctestrun\"\n\nWORKING_DIR=$PWD\n\nOUTPUT_DIR=./$DERIVED_DATA_PATH/Build/Products/$BUILD_OUTPUT_DIR\ncd $OUTPUT_DIR\nzip -r \"$WORKING_DIR/maestro-ios-driver/src/main/resources/$DERIVED_DATA_PATH/$BUILD_OUTPUT_DIR/maestro-driver-iosUITests-Runner.zip\" \"./maestro-driver-iosUITests-Runner.app\"\nzip -r \"$WORKING_DIR/maestro-ios-driver/src/main/resources/$DERIVED_DATA_PATH/$BUILD_OUTPUT_DIR/maestro-driver-ios.zip\" \"./maestro-driver-ios.app\"\n\n# Clean up\ncd $WORKING_DIR\nrm -rf \"./maestro-ios-driver/src/main/resources/$DERIVED_DATA_PATH/\"*.app\nrm -rf \"$PWD/$DERIVED_DATA_PATH\""
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-ios/AppDelegate.swift",
    "content": "//\n//  AppDelegate.swift\n//  maestro-driver-ios\n//\n//  \n//\n\nimport UIKit\n\n@main\nclass AppDelegate: UIResponder, UIApplicationDelegate {\n\n\n\n    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {\n        // Override point for customization after application launch.\n        return true\n    }\n\n    // MARK: UISceneSession Lifecycle\n\n    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {\n        // Called when a new scene session is being created.\n        // Use this method to select a configuration to create the new scene with.\n        return UISceneConfiguration(name: \"Default Configuration\", sessionRole: connectingSceneSession.role)\n    }\n\n    func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {\n        // Called when the user discards a scene session.\n        // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.\n        // Use this method to release any resources that were specific to the discarded scenes, as they will not return.\n    }\n\n\n}\n\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-ios/Assets.xcassets/AccentColor.colorset/Contents.json",
    "content": "{\n  \"colors\" : [\n    {\n      \"idiom\" : \"universal\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-ios/Assets.xcassets/AppIcon.appiconset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"idiom\" : \"iphone\",\n      \"scale\" : \"2x\",\n      \"size\" : \"20x20\"\n    },\n    {\n      \"idiom\" : \"iphone\",\n      \"scale\" : \"3x\",\n      \"size\" : \"20x20\"\n    },\n    {\n      \"idiom\" : \"iphone\",\n      \"scale\" : \"2x\",\n      \"size\" : \"29x29\"\n    },\n    {\n      \"idiom\" : \"iphone\",\n      \"scale\" : \"3x\",\n      \"size\" : \"29x29\"\n    },\n    {\n      \"idiom\" : \"iphone\",\n      \"scale\" : \"2x\",\n      \"size\" : \"40x40\"\n    },\n    {\n      \"idiom\" : \"iphone\",\n      \"scale\" : \"3x\",\n      \"size\" : \"40x40\"\n    },\n    {\n      \"idiom\" : \"iphone\",\n      \"scale\" : \"2x\",\n      \"size\" : \"60x60\"\n    },\n    {\n      \"idiom\" : \"iphone\",\n      \"scale\" : \"3x\",\n      \"size\" : \"60x60\"\n    },\n    {\n      \"idiom\" : \"ipad\",\n      \"scale\" : \"1x\",\n      \"size\" : \"20x20\"\n    },\n    {\n      \"idiom\" : \"ipad\",\n      \"scale\" : \"2x\",\n      \"size\" : \"20x20\"\n    },\n    {\n      \"idiom\" : \"ipad\",\n      \"scale\" : \"1x\",\n      \"size\" : \"29x29\"\n    },\n    {\n      \"idiom\" : \"ipad\",\n      \"scale\" : \"2x\",\n      \"size\" : \"29x29\"\n    },\n    {\n      \"idiom\" : \"ipad\",\n      \"scale\" : \"1x\",\n      \"size\" : \"40x40\"\n    },\n    {\n      \"idiom\" : \"ipad\",\n      \"scale\" : \"2x\",\n      \"size\" : \"40x40\"\n    },\n    {\n      \"idiom\" : \"ipad\",\n      \"scale\" : \"2x\",\n      \"size\" : \"76x76\"\n    },\n    {\n      \"idiom\" : \"ipad\",\n      \"scale\" : \"2x\",\n      \"size\" : \"83.5x83.5\"\n    },\n    {\n      \"idiom\" : \"ios-marketing\",\n      \"scale\" : \"1x\",\n      \"size\" : \"1024x1024\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-ios/Assets.xcassets/Contents.json",
    "content": "{\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-ios/Base.lproj/LaunchScreen.storyboard",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n<document type=\"com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB\" version=\"3.0\" toolsVersion=\"13122.16\" targetRuntime=\"iOS.CocoaTouch\" propertyAccessControl=\"none\" useAutolayout=\"YES\" launchScreen=\"YES\" useTraitCollections=\"YES\" useSafeAreas=\"YES\" colorMatched=\"YES\" initialViewController=\"01J-lp-oVM\">\n    <dependencies>\n        <plugIn identifier=\"com.apple.InterfaceBuilder.IBCocoaTouchPlugin\" version=\"13104.12\"/>\n        <capability name=\"Safe area layout guides\" minToolsVersion=\"9.0\"/>\n        <capability name=\"documents saved in the Xcode 8 format\" minToolsVersion=\"8.0\"/>\n    </dependencies>\n    <scenes>\n        <!--View Controller-->\n        <scene sceneID=\"EHf-IW-A2E\">\n            <objects>\n                <viewController id=\"01J-lp-oVM\" sceneMemberID=\"viewController\">\n                    <view key=\"view\" contentMode=\"scaleToFill\" id=\"Ze5-6b-2t3\">\n                        <rect key=\"frame\" x=\"0.0\" y=\"0.0\" width=\"375\" height=\"667\"/>\n                        <autoresizingMask key=\"autoresizingMask\" widthSizable=\"YES\" heightSizable=\"YES\"/>\n                        <color key=\"backgroundColor\" xcode11CocoaTouchSystemColor=\"systemBackgroundColor\" cocoaTouchSystemColor=\"whiteColor\"/>\n                        <viewLayoutGuide key=\"safeArea\" id=\"6Tk-OE-BBY\"/>\n                    </view>\n                </viewController>\n                <placeholder placeholderIdentifier=\"IBFirstResponder\" id=\"iYj-Kq-Ea1\" userLabel=\"First Responder\" sceneMemberID=\"firstResponder\"/>\n            </objects>\n            <point key=\"canvasLocation\" x=\"53\" y=\"375\"/>\n        </scene>\n    </scenes>\n</document>\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-ios/Base.lproj/Main.storyboard",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<document type=\"com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB\" version=\"3.0\" toolsVersion=\"13122.16\" targetRuntime=\"iOS.CocoaTouch\" propertyAccessControl=\"none\" useAutolayout=\"YES\" useTraitCollections=\"YES\" useSafeAreas=\"YES\" colorMatched=\"YES\" initialViewController=\"BYZ-38-t0r\">\n    <dependencies>\n        <plugIn identifier=\"com.apple.InterfaceBuilder.IBCocoaTouchPlugin\" version=\"13104.12\"/>\n        <capability name=\"Safe area layout guides\" minToolsVersion=\"9.0\"/>\n        <capability name=\"documents saved in the Xcode 8 format\" minToolsVersion=\"8.0\"/>\n    </dependencies>\n    <scenes>\n        <!--View Controller-->\n        <scene sceneID=\"tne-QT-ifu\">\n            <objects>\n                <viewController id=\"BYZ-38-t0r\" customClass=\"ViewController\" customModuleProvider=\"target\" sceneMemberID=\"viewController\">\n                    <view key=\"view\" contentMode=\"scaleToFill\" id=\"8bC-Xf-vdC\">\n                        <rect key=\"frame\" x=\"0.0\" y=\"0.0\" width=\"375\" height=\"667\"/>\n                        <autoresizingMask key=\"autoresizingMask\" widthSizable=\"YES\" heightSizable=\"YES\"/>\n                        <color key=\"backgroundColor\" xcode11CocoaTouchSystemColor=\"systemBackgroundColor\" cocoaTouchSystemColor=\"whiteColor\"/>\n                        <viewLayoutGuide key=\"safeArea\" id=\"6Tk-OE-BBY\"/>\n                    </view>\n                </viewController>\n                <placeholder placeholderIdentifier=\"IBFirstResponder\" id=\"dkx-z0-nzr\" sceneMemberID=\"firstResponder\"/>\n            </objects>\n        </scene>\n    </scenes>\n</document>\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-ios/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>UIApplicationSceneManifest</key>\n\t<dict>\n\t\t<key>UIApplicationSupportsMultipleScenes</key>\n\t\t<false/>\n\t\t<key>UISceneConfigurations</key>\n\t\t<dict>\n\t\t\t<key>UIWindowSceneSessionRoleApplication</key>\n\t\t\t<array>\n\t\t\t\t<dict>\n\t\t\t\t\t<key>UISceneConfigurationName</key>\n\t\t\t\t\t<string>Default Configuration</string>\n\t\t\t\t\t<key>UISceneDelegateClassName</key>\n\t\t\t\t\t<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>\n\t\t\t\t\t<key>UISceneStoryboardFile</key>\n\t\t\t\t\t<string>Main</string>\n\t\t\t\t</dict>\n\t\t\t</array>\n\t\t</dict>\n\t</dict>\n</dict>\n</plist>\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-ios/SceneDelegate.swift",
    "content": "//\n//  SceneDelegate.swift\n//  maestro-driver-ios\n//\n//  \n//\n\nimport UIKit\n\nclass SceneDelegate: UIResponder, UIWindowSceneDelegate {\n\n    var window: UIWindow?\n\n\n    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {\n        // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.\n        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.\n        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).\n        guard let _ = (scene as? UIWindowScene) else { return }\n    }\n\n    func sceneDidDisconnect(_ scene: UIScene) {\n        // Called as the scene is being released by the system.\n        // This occurs shortly after the scene enters the background, or when its session is discarded.\n        // Release any resources associated with this scene that can be re-created the next time the scene connects.\n        // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).\n    }\n\n    func sceneDidBecomeActive(_ scene: UIScene) {\n        // Called when the scene has moved from an inactive state to an active state.\n        // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.\n    }\n\n    func sceneWillResignActive(_ scene: UIScene) {\n        // Called when the scene will move from an active state to an inactive state.\n        // This may occur due to temporary interruptions (ex. an incoming phone call).\n    }\n\n    func sceneWillEnterForeground(_ scene: UIScene) {\n        // Called as the scene transitions from the background to the foreground.\n        // Use this method to undo the changes made on entering the background.\n    }\n\n    func sceneDidEnterBackground(_ scene: UIScene) {\n        // Called as the scene transitions from the foreground to the background.\n        // Use this method to save data, release shared resources, and store enough scene-specific state information\n        // to restore the scene back to its current state.\n    }\n\n\n}\n\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-ios/ViewController.swift",
    "content": "//\n//  ViewController.swift\n//  maestro-driver-ios\n//\n//\n//\n\nimport UIKit\n\nclass ViewController: UIViewController {\n\n    override func viewDidLoad() {\n        super.viewDidLoad()\n        // Do any additional setup after loading the view.\n    }\n\n\n}\n\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-ios.xcodeproj/project.pbxproj",
    "content": "// !$*UTF8*$!\n{\n\tarchiveVersion = 1;\n\tclasses = {\n\t};\n\tobjectVersion = 55;\n\tobjects = {\n\n/* Begin PBXBuildFile section */\n\t\t320979632970925500340282 /* InputTextRouteHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 320979622970925500340282 /* InputTextRouteHandler.swift */; };\n\t\t32097965297092A800340282 /* InputTextRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32097964297092A800340282 /* InputTextRequest.swift */; };\n\t\t32762AF82965E2A200FB69BD /* SwipeRouteHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32762AF72965E2A200FB69BD /* SwipeRouteHandler.swift */; };\n\t\t32762AFA2966DC8300FB69BD /* SwipeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32762AF92966DC8300FB69BD /* SwipeRequest.swift */; };\n\t\t32A9C73C2D7A62B500545435 /* LaunchAppHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32A9C73B2D7A62AF00545435 /* LaunchAppHandler.swift */; };\n\t\t32A9C73E2D7A631A00545435 /* LaunchAppRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32A9C73D2D7A631400545435 /* LaunchAppRequest.swift */; };\n\t\t32ECCB262980449200A1A0A0 /* TouchRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32ECCB252980449200A1A0A0 /* TouchRequest.swift */; };\n\t\t32ECCB28298044C200A1A0A0 /* TouchRouteHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32ECCB27298044C200A1A0A0 /* TouchRouteHandler.swift */; };\n\t\t39F002647AA68C9B8DC39E61 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF897D5D1CB7C6C46344E543 /* Foundation.framework */; };\n\t\t52047F782A7A638E00BF982D /* StatusHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52047F772A7A638E00BF982D /* StatusHandler.swift */; };\n\t\t52049BD72935039F00807AA3 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52049BD62935039F00807AA3 /* AppDelegate.swift */; };\n\t\t52049BD92935039F00807AA3 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52049BD82935039F00807AA3 /* SceneDelegate.swift */; };\n\t\t52049BDB2935039F00807AA3 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52049BDA2935039F00807AA3 /* ViewController.swift */; };\n\t\t52049BDE2935039F00807AA3 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 52049BDC2935039F00807AA3 /* Main.storyboard */; };\n\t\t52049BE0293503A200807AA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 52049BDF293503A200807AA3 /* Assets.xcassets */; };\n\t\t52049BE3293503A200807AA3 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 52049BE1293503A200807AA3 /* LaunchScreen.storyboard */; };\n\t\t52049BF8293503A200807AA3 /* maestro_driver_iosUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52049BF7293503A200807AA3 /* maestro_driver_iosUITests.swift */; };\n\t\t521C1F032D8014410024EE42 /* AXClientProxy.h in Headers */ = {isa = PBXBuildFile; fileRef = 521C1F022D8014340024EE42 /* AXClientProxy.h */; };\n\t\t521C1F052D8016950024EE42 /* XCAccessibilityElement.h in Headers */ = {isa = PBXBuildFile; fileRef = 521C1F042D8016950024EE42 /* XCAccessibilityElement.h */; };\n\t\t521C1F072D8017480024EE42 /* AXClientProxy.m in Sources */ = {isa = PBXBuildFile; fileRef = 521C1F062D8017460024EE42 /* AXClientProxy.m */; };\n\t\t521C1F092D8018590024EE42 /* XCUIApplication+Helper.h in Headers */ = {isa = PBXBuildFile; fileRef = 521C1F082D80184D0024EE42 /* XCUIApplication+Helper.h */; };\n\t\t521C1F0B2D801A360024EE42 /* XCUIApplication+Helper.m in Sources */ = {isa = PBXBuildFile; fileRef = 521C1F0A2D801A350024EE42 /* XCUIApplication+Helper.m */; };\n\t\t521C1F0C2D8030A30024EE42 /* XCTestManager_ManagerInterface-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 52D8FA622D6DCC600015FAB3 /* XCTestManager_ManagerInterface-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; };\n\t\t521C1F0E2D80518A0024EE42 /* XCTestDaemonsProxy.h in Headers */ = {isa = PBXBuildFile; fileRef = 521C1F0D2D8051890024EE42 /* XCTestDaemonsProxy.h */; };\n\t\t521C1F102D8051FC0024EE42 /* XCTestDaemonsProxy.m in Sources */ = {isa = PBXBuildFile; fileRef = 521C1F0F2D8051F20024EE42 /* XCTestDaemonsProxy.m */; };\n\t\t521C1F122D86CE940024EE42 /* TerminateAppHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 521C1F112D86CE8A0024EE42 /* TerminateAppHandler.swift */; };\n\t\t521C1F142D86D3900024EE42 /* TerminateAppRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 521C1F132D86D3840024EE42 /* TerminateAppRequest.swift */; };\n\t\t522785812A54410D008DBC0A /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 522785802A54410D008DBC0A /* AppError.swift */; };\n\t\t526111782DE5E8DA007C356B /* XCAXClient_iOS+FBSnapshotReqParams.h in Headers */ = {isa = PBXBuildFile; fileRef = 526111772DE5E8D0007C356B /* XCAXClient_iOS+FBSnapshotReqParams.h */; };\n\t\t5261117A2DE5E8E7007C356B /* XCAXClient_iOS+FBSnapshotReqParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 526111792DE5E8E5007C356B /* XCAXClient_iOS+FBSnapshotReqParams.m */; };\n\t\t5279BFD62935ECE20056C609 /* FlyingFox in Frameworks */ = {isa = PBXBuildFile; productRef = 5279BFD52935ECE20056C609 /* FlyingFox */; };\n\t\t52D8FA9D2D6DCFAF0015FAB3 /* XCUIApplicationProcess+FBQuiescence.m in Sources */ = {isa = PBXBuildFile; fileRef = 52D8FA9C2D6DCFAF0015FAB3 /* XCUIApplicationProcess+FBQuiescence.m */; };\n\t\t52D8FA9F2D6DD0B50015FAB3 /* XCUIApplicationProcess+FBQuiescence.h in Headers */ = {isa = PBXBuildFile; fileRef = 52D8FA9B2D6DCFAF0015FAB3 /* XCUIApplicationProcess+FBQuiescence.h */; };\n\t\t52D8FAA02D6DD0E60015FAB3 /* XCAXClient_iOS.h in Headers */ = {isa = PBXBuildFile; fileRef = 52D8FA402D6DCC600015FAB3 /* XCAXClient_iOS.h */; settings = {ATTRIBUTES = (Public, ); }; };\n\t\t52D8FAA12D6DD13B0015FAB3 /* _XCTestImplementation.h in Headers */ = {isa = PBXBuildFile; fileRef = 52D8FA2D2D6DCC600015FAB3 /* _XCTestImplementation.h */; settings = {ATTRIBUTES = (Public, ); }; };\n\t\t52D8FAA22D6DD1580015FAB3 /* maestro-driver-iosUITests-Bridging-Header.h in Headers */ = {isa = PBXBuildFile; fileRef = 52D8FA922D6DCF2C0015FAB3 /* maestro-driver-iosUITests-Bridging-Header.h */; };\n\t\t52D8FAA32D6DD16F0015FAB3 /* CDStructures.h in Headers */ = {isa = PBXBuildFile; fileRef = 52D8FA332D6DCC600015FAB3 /* CDStructures.h */; settings = {ATTRIBUTES = (Public, ); }; };\n\t\t52D8FAA42D6DD1950015FAB3 /* XCUIApplication.h in Headers */ = {isa = PBXBuildFile; fileRef = 52D8FA7D2D6DCC600015FAB3 /* XCUIApplication.h */; settings = {ATTRIBUTES = (Public, ); }; };\n\t\t52D8FAA52D6DD1950015FAB3 /* XCUIApplicationImpl.h in Headers */ = {isa = PBXBuildFile; fileRef = 52D8FA7E2D6DCC600015FAB3 /* XCUIApplicationImpl.h */; settings = {ATTRIBUTES = (Public, ); }; };\n\t\t52D8FAA62D6DD1950015FAB3 /* XCUIApplicationProcess.h in Headers */ = {isa = PBXBuildFile; fileRef = 52D8FA7F2D6DCC600015FAB3 /* XCUIApplicationProcess.h */; settings = {ATTRIBUTES = (Public, ); }; };\n\t\t52D8FAA72D6DD1950015FAB3 /* XCUICoordinate.h in Headers */ = {isa = PBXBuildFile; fileRef = 52D8FA802D6DCC600015FAB3 /* XCUICoordinate.h */; settings = {ATTRIBUTES = (Public, ); }; };\n\t\t52D8FAA82D6DD1950015FAB3 /* XCUIDevice.h in Headers */ = {isa = PBXBuildFile; fileRef = 52D8FA812D6DCC600015FAB3 /* XCUIDevice.h */; settings = {ATTRIBUTES = (Public, ); }; };\n\t\t52D8FAAC2D6DD3A20015FAB3 /* FBLogger.m in Sources */ = {isa = PBXBuildFile; fileRef = 52D8FAAB2D6DD3A20015FAB3 /* FBLogger.m */; };\n\t\t52D8FAAD2D6DD3A20015FAB3 /* FBLogger.h in Headers */ = {isa = PBXBuildFile; fileRef = 52D8FAAA2D6DD3A20015FAB3 /* FBLogger.h */; settings = {ATTRIBUTES = (Public, ); }; };\n\t\t52D8FAB02D6DD4100015FAB3 /* FBConfiguration.m in Sources */ = {isa = PBXBuildFile; fileRef = 52D8FAAF2D6DD4100015FAB3 /* FBConfiguration.m */; };\n\t\t52D8FAB12D6DD4100015FAB3 /* FBConfiguration.h in Headers */ = {isa = PBXBuildFile; fileRef = 52D8FAAE2D6DD4100015FAB3 /* FBConfiguration.h */; settings = {ATTRIBUTES = (Public, ); }; };\n\t\t52D8FAB42D6DD54F0015FAB3 /* XCUIApplication+FBQuiescence.m in Sources */ = {isa = PBXBuildFile; fileRef = 52D8FAB32D6DD54F0015FAB3 /* XCUIApplication+FBQuiescence.m */; };\n\t\t52D8FAB52D6DD54F0015FAB3 /* XCUIApplication+FBQuiescence.h in Headers */ = {isa = PBXBuildFile; fileRef = 52D8FAB22D6DD54F0015FAB3 /* XCUIApplication+FBQuiescence.h */; };\n\t\t52E35D432A654F67001D97A8 /* RunningApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52E35D422A654F67001D97A8 /* RunningApp.swift */; };\n\t\t52F0B1B12B3C25BC00C6471A /* KeyboardRouteHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52F0B1B02B3C25BC00C6471A /* KeyboardRouteHandler.swift */; };\n\t\t52F0B1B32B3C26DF00C6471A /* KeyboardHandlerRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52F0B1B22B3C26DF00C6471A /* KeyboardHandlerRequest.swift */; };\n\t\t52F0B1B52B3C27F700C6471A /* KeyboardHandlerResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52F0B1B42B3C27F700C6471A /* KeyboardHandlerResponse.swift */; };\n\t\t52F33A942AE6823100692902 /* StatusResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52F33A932AE6823100692902 /* StatusResponse.swift */; };\n\t\t5B8E0ABC2CD562D000E9D439 /* SetOrientationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8E0ABB2CD562D000E9D439 /* SetOrientationHandler.swift */; };\n\t\t5B8E0ABE2CD562F200E9D439 /* SetOrientationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8E0ABD2CD562F200E9D439 /* SetOrientationRequest.swift */; };\n\t\t610D58F92A45B5DA00B4BBEB /* ViewHierarchyHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 610D58F82A45B5DA00B4BBEB /* ViewHierarchyHandler.swift */; };\n\t\t610D58FB2A49E3CA00B4BBEB /* AXClientSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 610D58FA2A49E3CA00B4BBEB /* AXClientSwizzler.swift */; };\n\t\t6124329C2A4B368100F5F619 /* ViewHierarchyRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6124329B2A4B368100F5F619 /* ViewHierarchyRequest.swift */; };\n\t\t6124329E2A4DA5BB00F5F619 /* AXElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6124329D2A4DA5BB00F5F619 /* AXElement.swift */; };\n\t\t612432A02A4DA72F00F5F619 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6124329F2A4DA72F00F5F619 /* Logger.swift */; };\n\t\t612DE40229801F71003C2BE0 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 612DE40129801F71003C2BE0 /* XCTest.framework */; };\n\t\t612DE4122984114B003C2BE0 /* RunnerDaemonProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 612DE4112984114B003C2BE0 /* RunnerDaemonProxy.swift */; };\n\t\t612DE414298426EF003C2BE0 /* EventRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 612DE413298426EF003C2BE0 /* EventRecord.swift */; };\n\t\t613E87D7299A64BD00FF8551 /* PointerEventPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 613E87D6299A64BD00FF8551 /* PointerEventPath.swift */; };\n\t\t613E87DF299BE78400FF8551 /* KeyModifierFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 613E87DE299BE78400FF8551 /* KeyModifierFlags.swift */; };\n\t\t61A79B9729DF0B8A00C38882 /* SwipeRouteHandlerV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A79B9629DF0B8A00C38882 /* SwipeRouteHandlerV2.swift */; };\n\t\t61C0AFE529C7AAB3005D1FC5 /* PressKeyRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C0AFE429C7AAB3005D1FC5 /* PressKeyRequest.swift */; };\n\t\t61C0AFE729C7AB0C005D1FC5 /* PressKeyHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C0AFE629C7AB0C005D1FC5 /* PressKeyHandler.swift */; };\n\t\t61C0AFE929C86378005D1FC5 /* PressButtonRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C0AFE829C86378005D1FC5 /* PressButtonRequest.swift */; };\n\t\t61C0AFEB29C863BB005D1FC5 /* PressButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C0AFEA29C863BB005D1FC5 /* PressButtonHandler.swift */; };\n\t\t61C0AFED29C88926005D1FC5 /* EraseTextHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C0AFEC29C88926005D1FC5 /* EraseTextHandler.swift */; };\n\t\t61C0AFEF29C8961F005D1FC5 /* EraseTextRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C0AFEE29C8961F005D1FC5 /* EraseTextRequest.swift */; };\n\t\t61C0AFF129C8C01F005D1FC5 /* DeviceInfoHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C0AFF029C8C01F005D1FC5 /* DeviceInfoHandler.swift */; };\n\t\t61C0AFF329C8C040005D1FC5 /* DeviceInfoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C0AFF229C8C040005D1FC5 /* DeviceInfoResponse.swift */; };\n\t\t61C0AFF729D34FEA005D1FC5 /* EventTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C0AFF629D34FEA005D1FC5 /* EventTarget.swift */; };\n\t\t641B1A7714A4B3E6B7C2A114 /* AXClientProxy.m in Sources */ = {isa = PBXBuildFile; fileRef = 521C1F062D8017460024EE42 /* AXClientProxy.m */; };\n\t\t9407362E2940CA1600A72E99 /* GetRunningAppRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9407362D2940CA1600A72E99 /* GetRunningAppRequest.swift */; };\n\t\t94256BAD298D39DE00CDB55D /* ScreenDiffHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94256BAC298D39DE00CDB55D /* ScreenDiffHandler.swift */; };\n\t\t943A9081293F5C2500C85136 /* RouteHandlerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943A9080293F5C2500C85136 /* RouteHandlerFactory.swift */; };\n\t\t943A9088293F5EAA00C85136 /* RunningAppRouteHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943A9087293F5EAA00C85136 /* RunningAppRouteHandler.swift */; };\n\t\t945DD44B29D6F5D8004D8ECF /* SetPermissionsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945DD44A29D6F5D8004D8ECF /* SetPermissionsRequest.swift */; };\n\t\t945DD44D29D6F73B004D8ECF /* SetPermissionsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945DD44C29D6F73B004D8ECF /* SetPermissionsHandler.swift */; };\n\t\t9468FA7D2AA741F100254AA3 /* TextInputHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9468FA7C2AA741F100254AA3 /* TextInputHelper.swift */; };\n\t\t94826F4429DB082100795E9B /* SystemPermissionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94826F4329DB082100795E9B /* SystemPermissionHelper.swift */; };\n\t\t9494CBD12982F719009C987C /* ScreenshotHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9494CBD02982F719009C987C /* ScreenshotHandler.swift */; };\n\t\t949535FC299FD67E00FD0159 /* XCTestHTTPServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 949535FB299FD67E00FD0159 /* XCTestHTTPServer.swift */; };\n\t\t94A90DDE298AE72A006EB769 /* XCUIElement+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A90DDD298AE72A006EB769 /* XCUIElement+Extensions.swift */; };\n\t\t9811C9092C49751D00DDACA0 /* ScreenSizeHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9811C9082C49751D00DDACA0 /* ScreenSizeHelper.swift */; };\n\t\tBBBB00012DF24A0000000001 /* AXElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00112DF24A0000000011 /* AXElement.swift */; };\n\t\tBBBB00022DF24A0000000002 /* AXFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00122DF24A0000000012 /* AXFrame.swift */; };\n\t\tBBBB00032DF24A0000000003 /* ElementType.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00132DF24A0000000013 /* ElementType.swift */; };\n\t\tBBBB00042DF24A0000000004 /* PermissionValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00142DF24A0000000014 /* PermissionValue.swift */; };\n\t\tBBBB00052DF24A0000000005 /* PermissionButtonFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00152DF24A0000000015 /* PermissionButtonFinder.swift */; };\n\t\tBBBB00062DF24A0000000006 /* MaestroDriverLib.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00162DF24A0000000016 /* MaestroDriverLib.swift */; };\n\t\tBBBB00072DF24A0000000007 /* MaestroDriverLib.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BBBB00202DF24A0000000020 /* MaestroDriverLib.framework */; };\n\t\tBBBB00082DF24A0000000008 /* MaestroDriverLib.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BBBB00202DF24A0000000020 /* MaestroDriverLib.framework */; };\n\t\tBBBB00092DF24A0000000009 /* MaestroDriverLib.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BBBB00202DF24A0000000020 /* MaestroDriverLib.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };\n\t\tBBBB000A2DF24A000000000A /* PermissionButtonFinderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00172DF24A0000000017 /* PermissionButtonFinderTests.swift */; };\n\t\tBBBB000B2DF24A000000000B /* AXElementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00182DF24A0000000018 /* AXElementTests.swift */; };\n\t\tBBBB000C2DF24A000000000C /* AXFrameTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00192DF24A0000000019 /* AXFrameTests.swift */; };\n\t\tC2DB03C0028C821C6F580B2C /* XCAXClient_iOS+FBSnapshotReqParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 526111792DE5E8E5007C356B /* XCAXClient_iOS+FBSnapshotReqParams.m */; };\n\t\tD2244FD4EDCE31D1BAFD0549 /* SnapshotParametersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69198CA2D2E1AAFBD06FEB38 /* SnapshotParametersTests.swift */; };\n\t\tF328D3E62A2A98E7000546D3 /* StringExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F328D3E52A2A98E7000546D3 /* StringExtensions.swift */; };\n/* End PBXBuildFile section */\n\n/* Begin PBXContainerItemProxy section */\n\t\t52049BF4293503A200807AA3 /* PBXContainerItemProxy */ = {\n\t\t\tisa = PBXContainerItemProxy;\n\t\t\tcontainerPortal = 52049BCB2935039F00807AA3 /* Project object */;\n\t\t\tproxyType = 1;\n\t\t\tremoteGlobalIDString = 52049BD22935039F00807AA3;\n\t\t\tremoteInfo = \"maestro-driver-ios\";\n\t\t};\n\t\t87E61CDE27EC91E5197ED513 /* PBXContainerItemProxy */ = {\n\t\t\tisa = PBXContainerItemProxy;\n\t\t\tcontainerPortal = 52049BCB2935039F00807AA3 /* Project object */;\n\t\t\tproxyType = 1;\n\t\t\tremoteGlobalIDString = 52049BD22935039F00807AA3;\n\t\t\tremoteInfo = \"maestro-driver-ios\";\n\t\t};\n\t\tBBBB00812DF24A0000000081 /* PBXContainerItemProxy */ = {\n\t\t\tisa = PBXContainerItemProxy;\n\t\t\tcontainerPortal = 52049BCB2935039F00807AA3 /* Project object */;\n\t\t\tproxyType = 1;\n\t\t\tremoteGlobalIDString = BBBB00A02DF24A00000000A0;\n\t\t\tremoteInfo = MaestroDriverLib;\n\t\t};\n\t\tBBBB00832DF24A0000000083 /* PBXContainerItemProxy */ = {\n\t\t\tisa = PBXContainerItemProxy;\n\t\t\tcontainerPortal = 52049BCB2935039F00807AA3 /* Project object */;\n\t\t\tproxyType = 1;\n\t\t\tremoteGlobalIDString = BBBB00A02DF24A00000000A0;\n\t\t\tremoteInfo = MaestroDriverLib;\n\t\t};\n/* End PBXContainerItemProxy section */\n\n/* Begin PBXCopyFilesBuildPhase section */\n\t\tBBBB00302DF24A0000000030 /* Embed Frameworks */ = {\n\t\t\tisa = PBXCopyFilesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tdstPath = \"\";\n\t\t\tdstSubfolderSpec = 10;\n\t\t\tfiles = (\n\t\t\t\tBBBB00092DF24A0000000009 /* MaestroDriverLib.framework in Embed Frameworks */,\n\t\t\t);\n\t\t\tname = \"Embed Frameworks\";\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXCopyFilesBuildPhase section */\n\n/* Begin PBXFileReference section */\n\t\t320979622970925500340282 /* InputTextRouteHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputTextRouteHandler.swift; sourceTree = \"<group>\"; };\n\t\t32097964297092A800340282 /* InputTextRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputTextRequest.swift; sourceTree = \"<group>\"; };\n\t\t32762AF72965E2A200FB69BD /* SwipeRouteHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeRouteHandler.swift; sourceTree = \"<group>\"; };\n\t\t32762AF92966DC8300FB69BD /* SwipeRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeRequest.swift; sourceTree = \"<group>\"; };\n\t\t32A9C73B2D7A62AF00545435 /* LaunchAppHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAppHandler.swift; sourceTree = \"<group>\"; };\n\t\t32A9C73D2D7A631400545435 /* LaunchAppRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAppRequest.swift; sourceTree = \"<group>\"; };\n\t\t32ECCB252980449200A1A0A0 /* TouchRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchRequest.swift; sourceTree = \"<group>\"; };\n\t\t32ECCB27298044C200A1A0A0 /* TouchRouteHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchRouteHandler.swift; sourceTree = \"<group>\"; };\n\t\t52047F772A7A638E00BF982D /* StatusHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusHandler.swift; sourceTree = \"<group>\"; };\n\t\t52049BD32935039F00807AA3 /* maestro-driver-ios.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = \"maestro-driver-ios.app\"; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t52049BD62935039F00807AA3 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = \"<group>\"; };\n\t\t52049BD82935039F00807AA3 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = \"<group>\"; };\n\t\t52049BDA2935039F00807AA3 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = \"<group>\"; };\n\t\t52049BDD2935039F00807AA3 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = \"<group>\"; };\n\t\t52049BDF293503A200807AA3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = \"<group>\"; };\n\t\t52049BE2293503A200807AA3 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = \"<group>\"; };\n\t\t52049BE4293503A200807AA3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = \"<group>\"; };\n\t\t52049BF3293503A200807AA3 /* maestro-driver-iosUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = \"maestro-driver-iosUITests.xctest\"; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\t52049BF7293503A200807AA3 /* maestro_driver_iosUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = maestro_driver_iosUITests.swift; sourceTree = \"<group>\"; };\n\t\t521C1F022D8014340024EE42 /* AXClientProxy.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AXClientProxy.h; sourceTree = \"<group>\"; };\n\t\t521C1F042D8016950024EE42 /* XCAccessibilityElement.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCAccessibilityElement.h; sourceTree = \"<group>\"; };\n\t\t521C1F062D8017460024EE42 /* AXClientProxy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AXClientProxy.m; sourceTree = \"<group>\"; };\n\t\t521C1F082D80184D0024EE42 /* XCUIApplication+Helper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCUIApplication+Helper.h\"; sourceTree = \"<group>\"; };\n\t\t521C1F0A2D801A350024EE42 /* XCUIApplication+Helper.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = \"XCUIApplication+Helper.m\"; sourceTree = \"<group>\"; };\n\t\t521C1F0D2D8051890024EE42 /* XCTestDaemonsProxy.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTestDaemonsProxy.h; sourceTree = \"<group>\"; };\n\t\t521C1F0F2D8051F20024EE42 /* XCTestDaemonsProxy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = XCTestDaemonsProxy.m; sourceTree = \"<group>\"; };\n\t\t521C1F112D86CE8A0024EE42 /* TerminateAppHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminateAppHandler.swift; sourceTree = \"<group>\"; };\n\t\t521C1F132D86D3840024EE42 /* TerminateAppRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminateAppRequest.swift; sourceTree = \"<group>\"; };\n\t\t522785802A54410D008DBC0A /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = \"<group>\"; };\n\t\t526111772DE5E8D0007C356B /* XCAXClient_iOS+FBSnapshotReqParams.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCAXClient_iOS+FBSnapshotReqParams.h\"; sourceTree = \"<group>\"; };\n\t\t526111792DE5E8E5007C356B /* XCAXClient_iOS+FBSnapshotReqParams.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = \"XCAXClient_iOS+FBSnapshotReqParams.m\"; sourceTree = \"<group>\"; };\n\t\t52D8FA272D6DCC600015FAB3 /* _XCInternalTestRun.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = _XCInternalTestRun.h; sourceTree = \"<group>\"; };\n\t\t52D8FA282D6DCC600015FAB3 /* _XCKVOExpectationImplementation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = _XCKVOExpectationImplementation.h; sourceTree = \"<group>\"; };\n\t\t52D8FA292D6DCC600015FAB3 /* _XCTDarwinNotificationExpectationImplementation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = _XCTDarwinNotificationExpectationImplementation.h; sourceTree = \"<group>\"; };\n\t\t52D8FA2A2D6DCC600015FAB3 /* _XCTestCaseImplementation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = _XCTestCaseImplementation.h; sourceTree = \"<group>\"; };\n\t\t52D8FA2B2D6DCC600015FAB3 /* _XCTestCaseInterruptionException.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = _XCTestCaseInterruptionException.h; sourceTree = \"<group>\"; };\n\t\t52D8FA2C2D6DCC600015FAB3 /* _XCTestExpectationImplementation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = _XCTestExpectationImplementation.h; sourceTree = \"<group>\"; };\n\t\t52D8FA2D2D6DCC600015FAB3 /* _XCTestImplementation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = _XCTestImplementation.h; sourceTree = \"<group>\"; };\n\t\t52D8FA2E2D6DCC600015FAB3 /* _XCTestObservationCenterImplementation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = _XCTestObservationCenterImplementation.h; sourceTree = \"<group>\"; };\n\t\t52D8FA2F2D6DCC600015FAB3 /* _XCTestSuiteImplementation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = _XCTestSuiteImplementation.h; sourceTree = \"<group>\"; };\n\t\t52D8FA302D6DCC600015FAB3 /* _XCTNSNotificationExpectationImplementation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = _XCTNSNotificationExpectationImplementation.h; sourceTree = \"<group>\"; };\n\t\t52D8FA312D6DCC600015FAB3 /* _XCTNSPredicateExpectationImplementation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = _XCTNSPredicateExpectationImplementation.h; sourceTree = \"<group>\"; };\n\t\t52D8FA322D6DCC600015FAB3 /* _XCTWaiterImpl.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = _XCTWaiterImpl.h; sourceTree = \"<group>\"; };\n\t\t52D8FA332D6DCC600015FAB3 /* CDStructures.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CDStructures.h; sourceTree = \"<group>\"; };\n\t\t52D8FA342D6DCC600015FAB3 /* NSString-XCTAdditions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"NSString-XCTAdditions.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA352D6DCC600015FAB3 /* NSValue-XCTestAdditions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"NSValue-XCTestAdditions.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA362D6DCC600015FAB3 /* UIGestureRecognizer-RecordingAdditions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"UIGestureRecognizer-RecordingAdditions.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA372D6DCC600015FAB3 /* UILongPressGestureRecognizer-RecordingAdditions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"UILongPressGestureRecognizer-RecordingAdditions.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA382D6DCC600015FAB3 /* UIPanGestureRecognizer-RecordingAdditions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"UIPanGestureRecognizer-RecordingAdditions.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA392D6DCC600015FAB3 /* UIPinchGestureRecognizer-RecordingAdditions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"UIPinchGestureRecognizer-RecordingAdditions.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA3A2D6DCC600015FAB3 /* UISwipeGestureRecognizer-RecordingAdditions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"UISwipeGestureRecognizer-RecordingAdditions.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA3B2D6DCC600015FAB3 /* UITapGestureRecognizer-RecordingAdditions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"UITapGestureRecognizer-RecordingAdditions.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA3C2D6DCC600015FAB3 /* XCActivityRecord.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCActivityRecord.h; sourceTree = \"<group>\"; };\n\t\t52D8FA3D2D6DCC600015FAB3 /* XCApplicationMonitor_iOS.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCApplicationMonitor_iOS.h; sourceTree = \"<group>\"; };\n\t\t52D8FA3E2D6DCC600015FAB3 /* XCApplicationMonitor.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCApplicationMonitor.h; sourceTree = \"<group>\"; };\n\t\t52D8FA3F2D6DCC600015FAB3 /* XCApplicationQuery.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCApplicationQuery.h; sourceTree = \"<group>\"; };\n\t\t52D8FA402D6DCC600015FAB3 /* XCAXClient_iOS.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCAXClient_iOS.h; sourceTree = \"<group>\"; };\n\t\t52D8FA412D6DCC600015FAB3 /* XCDebugLogDelegate-Protocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCDebugLogDelegate-Protocol.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA422D6DCC600015FAB3 /* XCEventGenerator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCEventGenerator.h; sourceTree = \"<group>\"; };\n\t\t52D8FA432D6DCC600015FAB3 /* XCKeyboardInputSolver.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCKeyboardInputSolver.h; sourceTree = \"<group>\"; };\n\t\t52D8FA442D6DCC600015FAB3 /* XCKeyboardKeyMap.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCKeyboardKeyMap.h; sourceTree = \"<group>\"; };\n\t\t52D8FA452D6DCC600015FAB3 /* XCKeyboardLayout.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCKeyboardLayout.h; sourceTree = \"<group>\"; };\n\t\t52D8FA462D6DCC600015FAB3 /* XCKeyMappingPath.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCKeyMappingPath.h; sourceTree = \"<group>\"; };\n\t\t52D8FA472D6DCC600015FAB3 /* XCPointerEvent.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCPointerEvent.h; sourceTree = \"<group>\"; };\n\t\t52D8FA482D6DCC600015FAB3 /* XCPointerEventPath.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCPointerEventPath.h; sourceTree = \"<group>\"; };\n\t\t52D8FA492D6DCC600015FAB3 /* XCSourceCodeRecording.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCSourceCodeRecording.h; sourceTree = \"<group>\"; };\n\t\t52D8FA4A2D6DCC600015FAB3 /* XCSourceCodeTreeNode.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCSourceCodeTreeNode.h; sourceTree = \"<group>\"; };\n\t\t52D8FA4B2D6DCC600015FAB3 /* XCSourceCodeTreeNodeEnumerator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCSourceCodeTreeNodeEnumerator.h; sourceTree = \"<group>\"; };\n\t\t52D8FA4C2D6DCC600015FAB3 /* XCSymbolicationRecord.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCSymbolicationRecord.h; sourceTree = \"<group>\"; };\n\t\t52D8FA4D2D6DCC600015FAB3 /* XCSymbolicatorHolder.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCSymbolicatorHolder.h; sourceTree = \"<group>\"; };\n\t\t52D8FA4E2D6DCC600015FAB3 /* XCSynthesizedEventRecord.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCSynthesizedEventRecord.h; sourceTree = \"<group>\"; };\n\t\t52D8FA4F2D6DCC600015FAB3 /* XCTAsyncActivity-Protocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCTAsyncActivity-Protocol.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA502D6DCC600015FAB3 /* XCTAsyncActivity.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTAsyncActivity.h; sourceTree = \"<group>\"; };\n\t\t52D8FA512D6DCC600015FAB3 /* XCTAutomationTarget-Protocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCTAutomationTarget-Protocol.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA522D6DCC600015FAB3 /* XCTAXClient-Protocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCTAXClient-Protocol.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA532D6DCC600015FAB3 /* XCTDarwinNotificationExpectation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTDarwinNotificationExpectation.h; sourceTree = \"<group>\"; };\n\t\t52D8FA542D6DCC600015FAB3 /* XCTest.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTest.h; sourceTree = \"<group>\"; };\n\t\t52D8FA552D6DCC600015FAB3 /* XCTestCase.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTestCase.h; sourceTree = \"<group>\"; };\n\t\t52D8FA562D6DCC600015FAB3 /* XCTestCaseRun.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTestCaseRun.h; sourceTree = \"<group>\"; };\n\t\t52D8FA572D6DCC600015FAB3 /* XCTestCaseSuite.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTestCaseSuite.h; sourceTree = \"<group>\"; };\n\t\t52D8FA582D6DCC600015FAB3 /* XCTestConfiguration.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTestConfiguration.h; sourceTree = \"<group>\"; };\n\t\t52D8FA592D6DCC600015FAB3 /* XCTestContext.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTestContext.h; sourceTree = \"<group>\"; };\n\t\t52D8FA5A2D6DCC600015FAB3 /* XCTestContextScope.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTestContextScope.h; sourceTree = \"<group>\"; };\n\t\t52D8FA5B2D6DCC600015FAB3 /* XCTestDriver.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTestDriver.h; sourceTree = \"<group>\"; };\n\t\t52D8FA5C2D6DCC600015FAB3 /* XCTestDriverInterface-Protocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCTestDriverInterface-Protocol.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA5D2D6DCC600015FAB3 /* XCTestExpectation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTestExpectation.h; sourceTree = \"<group>\"; };\n\t\t52D8FA5E2D6DCC600015FAB3 /* XCTestExpectationDelegate-Protocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCTestExpectationDelegate-Protocol.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA5F2D6DCC600015FAB3 /* XCTestExpectationWaiter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTestExpectationWaiter.h; sourceTree = \"<group>\"; };\n\t\t52D8FA602D6DCC600015FAB3 /* XCTestLog.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTestLog.h; sourceTree = \"<group>\"; };\n\t\t52D8FA612D6DCC600015FAB3 /* XCTestManager_IDEInterface-Protocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCTestManager_IDEInterface-Protocol.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA622D6DCC600015FAB3 /* XCTestManager_ManagerInterface-Protocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCTestManager_ManagerInterface-Protocol.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA632D6DCC600015FAB3 /* XCTestManager_TestsInterface-Protocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCTestManager_TestsInterface-Protocol.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA642D6DCC600015FAB3 /* XCTestMisuseObserver.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTestMisuseObserver.h; sourceTree = \"<group>\"; };\n\t\t52D8FA652D6DCC600015FAB3 /* XCTestObservation-Protocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCTestObservation-Protocol.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA662D6DCC600015FAB3 /* XCTestObservationCenter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTestObservationCenter.h; sourceTree = \"<group>\"; };\n\t\t52D8FA672D6DCC600015FAB3 /* XCTestObserver.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTestObserver.h; sourceTree = \"<group>\"; };\n\t\t52D8FA682D6DCC600015FAB3 /* XCTestProbe.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTestProbe.h; sourceTree = \"<group>\"; };\n\t\t52D8FA692D6DCC600015FAB3 /* XCTestRun.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTestRun.h; sourceTree = \"<group>\"; };\n\t\t52D8FA6A2D6DCC600015FAB3 /* XCTestSuite.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTestSuite.h; sourceTree = \"<group>\"; };\n\t\t52D8FA6B2D6DCC600015FAB3 /* XCTestSuiteRun.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTestSuiteRun.h; sourceTree = \"<group>\"; };\n\t\t52D8FA6C2D6DCC600015FAB3 /* XCTestWaiter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTestWaiter.h; sourceTree = \"<group>\"; };\n\t\t52D8FA6D2D6DCC600015FAB3 /* XCTKVOExpectation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTKVOExpectation.h; sourceTree = \"<group>\"; };\n\t\t52D8FA6E2D6DCC600015FAB3 /* XCTMetric.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTMetric.h; sourceTree = \"<group>\"; };\n\t\t52D8FA6F2D6DCC600015FAB3 /* XCTNSNotificationExpectation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTNSNotificationExpectation.h; sourceTree = \"<group>\"; };\n\t\t52D8FA702D6DCC600015FAB3 /* XCTNSPredicateExpectation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTNSPredicateExpectation.h; sourceTree = \"<group>\"; };\n\t\t52D8FA712D6DCC600015FAB3 /* XCTNSPredicateExpectationObject-Protocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCTNSPredicateExpectationObject-Protocol.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA722D6DCC600015FAB3 /* XCTRunnerAutomationSession.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTRunnerAutomationSession.h; sourceTree = \"<group>\"; };\n\t\t52D8FA732D6DCC600015FAB3 /* XCTRunnerDaemonSession.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTRunnerDaemonSession.h; sourceTree = \"<group>\"; };\n\t\t52D8FA742D6DCC600015FAB3 /* XCTRunnerIDESession.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTRunnerIDESession.h; sourceTree = \"<group>\"; };\n\t\t52D8FA752D6DCC600015FAB3 /* XCTTestRunSession.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTTestRunSession.h; sourceTree = \"<group>\"; };\n\t\t52D8FA762D6DCC600015FAB3 /* XCTTestRunSessionDelegate-Protocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCTTestRunSessionDelegate-Protocol.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA772D6DCC600015FAB3 /* XCTUIApplicationMonitor-Protocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCTUIApplicationMonitor-Protocol.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA782D6DCC600015FAB3 /* XCTWaiter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTWaiter.h; sourceTree = \"<group>\"; };\n\t\t52D8FA792D6DCC600015FAB3 /* XCTWaiterDelegate-Protocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCTWaiterDelegate-Protocol.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA7A2D6DCC600015FAB3 /* XCTWaiterDelegatePrivate-Protocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCTWaiterDelegatePrivate-Protocol.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA7B2D6DCC600015FAB3 /* XCTWaiterManagement-Protocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCTWaiterManagement-Protocol.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA7C2D6DCC600015FAB3 /* XCTWaiterManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCTWaiterManager.h; sourceTree = \"<group>\"; };\n\t\t52D8FA7D2D6DCC600015FAB3 /* XCUIApplication.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCUIApplication.h; sourceTree = \"<group>\"; };\n\t\t52D8FA7E2D6DCC600015FAB3 /* XCUIApplicationImpl.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCUIApplicationImpl.h; sourceTree = \"<group>\"; };\n\t\t52D8FA7F2D6DCC600015FAB3 /* XCUIApplicationProcess.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCUIApplicationProcess.h; sourceTree = \"<group>\"; };\n\t\t52D8FA802D6DCC600015FAB3 /* XCUICoordinate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCUICoordinate.h; sourceTree = \"<group>\"; };\n\t\t52D8FA812D6DCC600015FAB3 /* XCUIDevice.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCUIDevice.h; sourceTree = \"<group>\"; };\n\t\t52D8FA822D6DCC600015FAB3 /* XCUIElement.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCUIElement.h; sourceTree = \"<group>\"; };\n\t\t52D8FA832D6DCC600015FAB3 /* XCUIElementAsynchronousHandlerWrapper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCUIElementAsynchronousHandlerWrapper.h; sourceTree = \"<group>\"; };\n\t\t52D8FA842D6DCC600015FAB3 /* XCUIElementHitPointCoordinate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCUIElementHitPointCoordinate.h; sourceTree = \"<group>\"; };\n\t\t52D8FA852D6DCC600015FAB3 /* XCUIElementQuery.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCUIElementQuery.h; sourceTree = \"<group>\"; };\n\t\t52D8FA862D6DCC600015FAB3 /* XCUIHitPointResult.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCUIHitPointResult.h; sourceTree = \"<group>\"; };\n\t\t52D8FA872D6DCC600015FAB3 /* XCUIRecorderNodeFinder.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCUIRecorderNodeFinder.h; sourceTree = \"<group>\"; };\n\t\t52D8FA882D6DCC600015FAB3 /* XCUIRecorderNodeFinderMatch.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCUIRecorderNodeFinderMatch.h; sourceTree = \"<group>\"; };\n\t\t52D8FA892D6DCC600015FAB3 /* XCUIRecorderTimingMessage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCUIRecorderTimingMessage.h; sourceTree = \"<group>\"; };\n\t\t52D8FA8A2D6DCC600015FAB3 /* XCUIRecorderUtilities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCUIRecorderUtilities.h; sourceTree = \"<group>\"; };\n\t\t52D8FA8B2D6DCC600015FAB3 /* XCTElementSetTransformer-Protocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCTElementSetTransformer-Protocol.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA8C2D6DCC600015FAB3 /* XCUIScreen.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCUIScreen.h; sourceTree = \"<group>\"; };\n\t\t52D8FA8D2D6DCC600015FAB3 /* XCUIScreenDataSource-Protocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCUIScreenDataSource-Protocol.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA922D6DCF2C0015FAB3 /* maestro-driver-iosUITests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"maestro-driver-iosUITests-Bridging-Header.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA9B2D6DCFAF0015FAB3 /* XCUIApplicationProcess+FBQuiescence.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCUIApplicationProcess+FBQuiescence.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FA9C2D6DCFAF0015FAB3 /* XCUIApplicationProcess+FBQuiescence.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = \"XCUIApplicationProcess+FBQuiescence.m\"; sourceTree = \"<group>\"; };\n\t\t52D8FAAA2D6DD3A20015FAB3 /* FBLogger.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FBLogger.h; sourceTree = \"<group>\"; };\n\t\t52D8FAAB2D6DD3A20015FAB3 /* FBLogger.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBLogger.m; sourceTree = \"<group>\"; };\n\t\t52D8FAAE2D6DD4100015FAB3 /* FBConfiguration.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FBConfiguration.h; sourceTree = \"<group>\"; };\n\t\t52D8FAAF2D6DD4100015FAB3 /* FBConfiguration.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBConfiguration.m; sourceTree = \"<group>\"; };\n\t\t52D8FAB22D6DD54F0015FAB3 /* XCUIApplication+FBQuiescence.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = \"XCUIApplication+FBQuiescence.h\"; sourceTree = \"<group>\"; };\n\t\t52D8FAB32D6DD54F0015FAB3 /* XCUIApplication+FBQuiescence.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = \"XCUIApplication+FBQuiescence.m\"; sourceTree = \"<group>\"; };\n\t\t52E35D422A654F67001D97A8 /* RunningApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunningApp.swift; sourceTree = \"<group>\"; };\n\t\t52F0B1B02B3C25BC00C6471A /* KeyboardRouteHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardRouteHandler.swift; sourceTree = \"<group>\"; };\n\t\t52F0B1B22B3C26DF00C6471A /* KeyboardHandlerRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardHandlerRequest.swift; sourceTree = \"<group>\"; };\n\t\t52F0B1B42B3C27F700C6471A /* KeyboardHandlerResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardHandlerResponse.swift; sourceTree = \"<group>\"; };\n\t\t52F33A932AE6823100692902 /* StatusResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusResponse.swift; sourceTree = \"<group>\"; };\n\t\t566C6ED5A08D754A03395D84 /* maestro-driver-iosTests-Bridging-Header.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = \"maestro-driver-iosTests-Bridging-Header.h\"; sourceTree = \"<group>\"; };\n\t\t5B8E0ABB2CD562D000E9D439 /* SetOrientationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetOrientationHandler.swift; sourceTree = \"<group>\"; };\n\t\t5B8E0ABD2CD562F200E9D439 /* SetOrientationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetOrientationRequest.swift; sourceTree = \"<group>\"; };\n\t\t5D711D048386CB50800447DD /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = \"<group>\"; };\n\t\t610D58F82A45B5DA00B4BBEB /* ViewHierarchyHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewHierarchyHandler.swift; sourceTree = \"<group>\"; };\n\t\t610D58FA2A49E3CA00B4BBEB /* AXClientSwizzler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AXClientSwizzler.swift; sourceTree = \"<group>\"; };\n\t\t6124329B2A4B368100F5F619 /* ViewHierarchyRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewHierarchyRequest.swift; sourceTree = \"<group>\"; };\n\t\t6124329D2A4DA5BB00F5F619 /* AXElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXElement.swift; sourceTree = \"<group>\"; };\n\t\t6124329F2A4DA72F00F5F619 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = \"<group>\"; };\n\t\t612DE40129801F71003C2BE0 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };\n\t\t612DE4112984114B003C2BE0 /* RunnerDaemonProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerDaemonProxy.swift; sourceTree = \"<group>\"; };\n\t\t612DE413298426EF003C2BE0 /* EventRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventRecord.swift; sourceTree = \"<group>\"; };\n\t\t613E87D6299A64BD00FF8551 /* PointerEventPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointerEventPath.swift; sourceTree = \"<group>\"; };\n\t\t613E87DE299BE78400FF8551 /* KeyModifierFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyModifierFlags.swift; sourceTree = \"<group>\"; };\n\t\t61A79B9629DF0B8A00C38882 /* SwipeRouteHandlerV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeRouteHandlerV2.swift; sourceTree = \"<group>\"; };\n\t\t61C0AFE429C7AAB3005D1FC5 /* PressKeyRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PressKeyRequest.swift; sourceTree = \"<group>\"; };\n\t\t61C0AFE629C7AB0C005D1FC5 /* PressKeyHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PressKeyHandler.swift; sourceTree = \"<group>\"; };\n\t\t61C0AFE829C86378005D1FC5 /* PressButtonRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PressButtonRequest.swift; sourceTree = \"<group>\"; };\n\t\t61C0AFEA29C863BB005D1FC5 /* PressButtonHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PressButtonHandler.swift; sourceTree = \"<group>\"; };\n\t\t61C0AFEC29C88926005D1FC5 /* EraseTextHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EraseTextHandler.swift; sourceTree = \"<group>\"; };\n\t\t61C0AFEE29C8961F005D1FC5 /* EraseTextRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EraseTextRequest.swift; sourceTree = \"<group>\"; };\n\t\t61C0AFF029C8C01F005D1FC5 /* DeviceInfoHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoHandler.swift; sourceTree = \"<group>\"; };\n\t\t61C0AFF229C8C040005D1FC5 /* DeviceInfoResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoResponse.swift; sourceTree = \"<group>\"; };\n\t\t61C0AFF629D34FEA005D1FC5 /* EventTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventTarget.swift; sourceTree = \"<group>\"; };\n\t\t69198CA2D2E1AAFBD06FEB38 /* SnapshotParametersTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnapshotParametersTests.swift; sourceTree = \"<group>\"; };\n\t\t9407362D2940CA1600A72E99 /* GetRunningAppRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetRunningAppRequest.swift; sourceTree = \"<group>\"; };\n\t\t94256BAC298D39DE00CDB55D /* ScreenDiffHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenDiffHandler.swift; sourceTree = \"<group>\"; };\n\t\t943A9080293F5C2500C85136 /* RouteHandlerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteHandlerFactory.swift; sourceTree = \"<group>\"; };\n\t\t943A9087293F5EAA00C85136 /* RunningAppRouteHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunningAppRouteHandler.swift; sourceTree = \"<group>\"; };\n\t\t945DD44A29D6F5D8004D8ECF /* SetPermissionsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetPermissionsRequest.swift; sourceTree = \"<group>\"; };\n\t\t945DD44C29D6F73B004D8ECF /* SetPermissionsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetPermissionsHandler.swift; sourceTree = \"<group>\"; };\n\t\t9468FA7C2AA741F100254AA3 /* TextInputHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextInputHelper.swift; sourceTree = \"<group>\"; };\n\t\t94826F4329DB082100795E9B /* SystemPermissionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemPermissionHelper.swift; sourceTree = \"<group>\"; };\n\t\t9494CBD02982F719009C987C /* ScreenshotHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotHandler.swift; sourceTree = \"<group>\"; };\n\t\t949535FB299FD67E00FD0159 /* XCTestHTTPServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCTestHTTPServer.swift; sourceTree = \"<group>\"; };\n\t\t94A90DDD298AE72A006EB769 /* XCUIElement+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = \"XCUIElement+Extensions.swift\"; sourceTree = \"<group>\"; };\n\t\t9811C9082C49751D00DDACA0 /* ScreenSizeHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenSizeHelper.swift; sourceTree = \"<group>\"; };\n\t\tAF897D5D1CB7C6C46344E543 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };\n\t\tBBBB00112DF24A0000000011 /* AXElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXElement.swift; sourceTree = \"<group>\"; };\n\t\tBBBB00122DF24A0000000012 /* AXFrame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXFrame.swift; sourceTree = \"<group>\"; };\n\t\tBBBB00132DF24A0000000013 /* ElementType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementType.swift; sourceTree = \"<group>\"; };\n\t\tBBBB00142DF24A0000000014 /* PermissionValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionValue.swift; sourceTree = \"<group>\"; };\n\t\tBBBB00152DF24A0000000015 /* PermissionButtonFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionButtonFinder.swift; sourceTree = \"<group>\"; };\n\t\tBBBB00162DF24A0000000016 /* MaestroDriverLib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaestroDriverLib.swift; sourceTree = \"<group>\"; };\n\t\tBBBB00172DF24A0000000017 /* PermissionButtonFinderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionButtonFinderTests.swift; sourceTree = \"<group>\"; };\n\t\tBBBB00182DF24A0000000018 /* AXElementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXElementTests.swift; sourceTree = \"<group>\"; };\n\t\tBBBB00192DF24A0000000019 /* AXFrameTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXFrameTests.swift; sourceTree = \"<group>\"; };\n\t\tBBBB001A2DF24A000000001A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = \"<group>\"; };\n\t\tBBBB00202DF24A0000000020 /* MaestroDriverLib.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = MaestroDriverLib.framework; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\tBBBB00212DF24A0000000021 /* MaestroDriverLibTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MaestroDriverLibTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\tEF8504E3A6C0E75E273B4538 /* maestro-driver-iosTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = \"maestro-driver-iosTests.xctest\"; sourceTree = BUILT_PRODUCTS_DIR; };\n\t\tF328D3E52A2A98E7000546D3 /* StringExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtensions.swift; sourceTree = \"<group>\"; };\n/* End PBXFileReference section */\n\n/* Begin PBXFrameworksBuildPhase section */\n\t\t52049BD02935039F00807AA3 /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\t52049BF0293503A200807AA3 /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t612DE40229801F71003C2BE0 /* XCTest.framework in Frameworks */,\n\t\t\t\t5279BFD62935ECE20056C609 /* FlyingFox in Frameworks */,\n\t\t\t\tBBBB00082DF24A0000000008 /* MaestroDriverLib.framework in Frameworks */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\tBBBB00602DF24A0000000060 /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\tBBBB00612DF24A0000000061 /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\tBBBB00072DF24A0000000007 /* MaestroDriverLib.framework in Frameworks */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\tD2FF88B2E3410D9913F22F96 /* Frameworks */ = {\n\t\t\tisa = PBXFrameworksBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t39F002647AA68C9B8DC39E61 /* Foundation.framework in Frameworks */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXFrameworksBuildPhase section */\n\n/* Begin PBXGroup section */\n\t\t05F84546B87917C0BE5D6F36 /* iOS */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tAF897D5D1CB7C6C46344E543 /* Foundation.framework */,\n\t\t\t);\n\t\t\tname = iOS;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t1EFA22C0E42B636C243D752D /* maestro-driver-iosTests */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t69198CA2D2E1AAFBD06FEB38 /* SnapshotParametersTests.swift */,\n\t\t\t\t566C6ED5A08D754A03395D84 /* maestro-driver-iosTests-Bridging-Header.h */,\n\t\t\t\t5D711D048386CB50800447DD /* Info.plist */,\n\t\t\t);\n\t\t\tname = \"maestro-driver-iosTests\";\n\t\t\tpath = \"maestro-driver-iosTests\";\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t52049BCA2935039F00807AA3 = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t52049BD52935039F00807AA3 /* maestro-driver-ios */,\n\t\t\t\t52049BF6293503A200807AA3 /* maestro-driver-iosUITests */,\n\t\t\t\tBBBB00402DF24A0000000040 /* MaestroDriverLib */,\n\t\t\t\tBBBB00502DF24A0000000050 /* MaestroDriverLibTests */,\n\t\t\t\t52049BD42935039F00807AA3 /* Products */,\n\t\t\t\t5279BFD42935ECE20056C609 /* Frameworks */,\n\t\t\t\t1EFA22C0E42B636C243D752D /* maestro-driver-iosTests */,\n\t\t\t);\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t52049BD42935039F00807AA3 /* Products */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t52049BD32935039F00807AA3 /* maestro-driver-ios.app */,\n\t\t\t\t52049BF3293503A200807AA3 /* maestro-driver-iosUITests.xctest */,\n\t\t\t\tBBBB00202DF24A0000000020 /* MaestroDriverLib.framework */,\n\t\t\t\tBBBB00212DF24A0000000021 /* MaestroDriverLibTests.xctest */,\n\t\t\t\tEF8504E3A6C0E75E273B4538 /* maestro-driver-iosTests.xctest */,\n\t\t\t);\n\t\t\tname = Products;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t52049BD52935039F00807AA3 /* maestro-driver-ios */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t52049BD62935039F00807AA3 /* AppDelegate.swift */,\n\t\t\t\t52049BD82935039F00807AA3 /* SceneDelegate.swift */,\n\t\t\t\t52049BDA2935039F00807AA3 /* ViewController.swift */,\n\t\t\t\t52049BDC2935039F00807AA3 /* Main.storyboard */,\n\t\t\t\t52049BDF293503A200807AA3 /* Assets.xcassets */,\n\t\t\t\t52049BE1293503A200807AA3 /* LaunchScreen.storyboard */,\n\t\t\t\t52049BE4293503A200807AA3 /* Info.plist */,\n\t\t\t);\n\t\t\tpath = \"maestro-driver-ios\";\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t52049BF6293503A200807AA3 /* maestro-driver-iosUITests */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t52D8FAA92D6DD3850015FAB3 /* Utilities */,\n\t\t\t\t52D8FA8E2D6DCF010015FAB3 /* Categories */,\n\t\t\t\t52D8F8F12D6DCC150015FAB3 /* PrivateHeaders */,\n\t\t\t\t943A907A293F5AF400C85136 /* Routes */,\n\t\t\t\t52049BF7293503A200807AA3 /* maestro_driver_iosUITests.swift */,\n\t\t\t);\n\t\t\tpath = \"maestro-driver-iosUITests\";\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t5279BFD42935ECE20056C609 /* Frameworks */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t612DE40129801F71003C2BE0 /* XCTest.framework */,\n\t\t\t\t05F84546B87917C0BE5D6F36 /* iOS */,\n\t\t\t);\n\t\t\tname = Frameworks;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t52D8F8F12D6DCC150015FAB3 /* PrivateHeaders */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t52D8FA262D6DCC580015FAB3 /* XCTest */,\n\t\t\t);\n\t\t\tpath = PrivateHeaders;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t52D8FA262D6DCC580015FAB3 /* XCTest */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t52D8FA272D6DCC600015FAB3 /* _XCInternalTestRun.h */,\n\t\t\t\t52D8FA282D6DCC600015FAB3 /* _XCKVOExpectationImplementation.h */,\n\t\t\t\t52D8FA292D6DCC600015FAB3 /* _XCTDarwinNotificationExpectationImplementation.h */,\n\t\t\t\t52D8FA2A2D6DCC600015FAB3 /* _XCTestCaseImplementation.h */,\n\t\t\t\t52D8FA2B2D6DCC600015FAB3 /* _XCTestCaseInterruptionException.h */,\n\t\t\t\t52D8FA2C2D6DCC600015FAB3 /* _XCTestExpectationImplementation.h */,\n\t\t\t\t52D8FA2D2D6DCC600015FAB3 /* _XCTestImplementation.h */,\n\t\t\t\t52D8FA2E2D6DCC600015FAB3 /* _XCTestObservationCenterImplementation.h */,\n\t\t\t\t52D8FA2F2D6DCC600015FAB3 /* _XCTestSuiteImplementation.h */,\n\t\t\t\t52D8FA302D6DCC600015FAB3 /* _XCTNSNotificationExpectationImplementation.h */,\n\t\t\t\t52D8FA312D6DCC600015FAB3 /* _XCTNSPredicateExpectationImplementation.h */,\n\t\t\t\t52D8FA322D6DCC600015FAB3 /* _XCTWaiterImpl.h */,\n\t\t\t\t52D8FA332D6DCC600015FAB3 /* CDStructures.h */,\n\t\t\t\t52D8FA342D6DCC600015FAB3 /* NSString-XCTAdditions.h */,\n\t\t\t\t52D8FA352D6DCC600015FAB3 /* NSValue-XCTestAdditions.h */,\n\t\t\t\t52D8FA362D6DCC600015FAB3 /* UIGestureRecognizer-RecordingAdditions.h */,\n\t\t\t\t52D8FA372D6DCC600015FAB3 /* UILongPressGestureRecognizer-RecordingAdditions.h */,\n\t\t\t\t52D8FA382D6DCC600015FAB3 /* UIPanGestureRecognizer-RecordingAdditions.h */,\n\t\t\t\t52D8FA392D6DCC600015FAB3 /* UIPinchGestureRecognizer-RecordingAdditions.h */,\n\t\t\t\t52D8FA3A2D6DCC600015FAB3 /* UISwipeGestureRecognizer-RecordingAdditions.h */,\n\t\t\t\t52D8FA3B2D6DCC600015FAB3 /* UITapGestureRecognizer-RecordingAdditions.h */,\n\t\t\t\t52D8FA3C2D6DCC600015FAB3 /* XCActivityRecord.h */,\n\t\t\t\t52D8FA3D2D6DCC600015FAB3 /* XCApplicationMonitor_iOS.h */,\n\t\t\t\t52D8FA3E2D6DCC600015FAB3 /* XCApplicationMonitor.h */,\n\t\t\t\t52D8FA3F2D6DCC600015FAB3 /* XCApplicationQuery.h */,\n\t\t\t\t52D8FA402D6DCC600015FAB3 /* XCAXClient_iOS.h */,\n\t\t\t\t52D8FA412D6DCC600015FAB3 /* XCDebugLogDelegate-Protocol.h */,\n\t\t\t\t52D8FA422D6DCC600015FAB3 /* XCEventGenerator.h */,\n\t\t\t\t52D8FA432D6DCC600015FAB3 /* XCKeyboardInputSolver.h */,\n\t\t\t\t52D8FA442D6DCC600015FAB3 /* XCKeyboardKeyMap.h */,\n\t\t\t\t52D8FA452D6DCC600015FAB3 /* XCKeyboardLayout.h */,\n\t\t\t\t52D8FA462D6DCC600015FAB3 /* XCKeyMappingPath.h */,\n\t\t\t\t52D8FA472D6DCC600015FAB3 /* XCPointerEvent.h */,\n\t\t\t\t52D8FA482D6DCC600015FAB3 /* XCPointerEventPath.h */,\n\t\t\t\t52D8FA492D6DCC600015FAB3 /* XCSourceCodeRecording.h */,\n\t\t\t\t52D8FA4A2D6DCC600015FAB3 /* XCSourceCodeTreeNode.h */,\n\t\t\t\t52D8FA4B2D6DCC600015FAB3 /* XCSourceCodeTreeNodeEnumerator.h */,\n\t\t\t\t52D8FA4C2D6DCC600015FAB3 /* XCSymbolicationRecord.h */,\n\t\t\t\t52D8FA4D2D6DCC600015FAB3 /* XCSymbolicatorHolder.h */,\n\t\t\t\t52D8FA4E2D6DCC600015FAB3 /* XCSynthesizedEventRecord.h */,\n\t\t\t\t52D8FA4F2D6DCC600015FAB3 /* XCTAsyncActivity-Protocol.h */,\n\t\t\t\t52D8FA502D6DCC600015FAB3 /* XCTAsyncActivity.h */,\n\t\t\t\t52D8FA512D6DCC600015FAB3 /* XCTAutomationTarget-Protocol.h */,\n\t\t\t\t52D8FA522D6DCC600015FAB3 /* XCTAXClient-Protocol.h */,\n\t\t\t\t52D8FA532D6DCC600015FAB3 /* XCTDarwinNotificationExpectation.h */,\n\t\t\t\t52D8FA542D6DCC600015FAB3 /* XCTest.h */,\n\t\t\t\t52D8FA552D6DCC600015FAB3 /* XCTestCase.h */,\n\t\t\t\t52D8FA562D6DCC600015FAB3 /* XCTestCaseRun.h */,\n\t\t\t\t52D8FA572D6DCC600015FAB3 /* XCTestCaseSuite.h */,\n\t\t\t\t52D8FA582D6DCC600015FAB3 /* XCTestConfiguration.h */,\n\t\t\t\t52D8FA592D6DCC600015FAB3 /* XCTestContext.h */,\n\t\t\t\t52D8FA5A2D6DCC600015FAB3 /* XCTestContextScope.h */,\n\t\t\t\t52D8FA5B2D6DCC600015FAB3 /* XCTestDriver.h */,\n\t\t\t\t52D8FA5C2D6DCC600015FAB3 /* XCTestDriverInterface-Protocol.h */,\n\t\t\t\t52D8FA5D2D6DCC600015FAB3 /* XCTestExpectation.h */,\n\t\t\t\t52D8FA5E2D6DCC600015FAB3 /* XCTestExpectationDelegate-Protocol.h */,\n\t\t\t\t52D8FA5F2D6DCC600015FAB3 /* XCTestExpectationWaiter.h */,\n\t\t\t\t52D8FA602D6DCC600015FAB3 /* XCTestLog.h */,\n\t\t\t\t52D8FA612D6DCC600015FAB3 /* XCTestManager_IDEInterface-Protocol.h */,\n\t\t\t\t52D8FA622D6DCC600015FAB3 /* XCTestManager_ManagerInterface-Protocol.h */,\n\t\t\t\t52D8FA632D6DCC600015FAB3 /* XCTestManager_TestsInterface-Protocol.h */,\n\t\t\t\t52D8FA642D6DCC600015FAB3 /* XCTestMisuseObserver.h */,\n\t\t\t\t52D8FA652D6DCC600015FAB3 /* XCTestObservation-Protocol.h */,\n\t\t\t\t52D8FA662D6DCC600015FAB3 /* XCTestObservationCenter.h */,\n\t\t\t\t52D8FA672D6DCC600015FAB3 /* XCTestObserver.h */,\n\t\t\t\t52D8FA682D6DCC600015FAB3 /* XCTestProbe.h */,\n\t\t\t\t52D8FA692D6DCC600015FAB3 /* XCTestRun.h */,\n\t\t\t\t52D8FA6A2D6DCC600015FAB3 /* XCTestSuite.h */,\n\t\t\t\t52D8FA6B2D6DCC600015FAB3 /* XCTestSuiteRun.h */,\n\t\t\t\t52D8FA6C2D6DCC600015FAB3 /* XCTestWaiter.h */,\n\t\t\t\t52D8FA6D2D6DCC600015FAB3 /* XCTKVOExpectation.h */,\n\t\t\t\t52D8FA6E2D6DCC600015FAB3 /* XCTMetric.h */,\n\t\t\t\t52D8FA6F2D6DCC600015FAB3 /* XCTNSNotificationExpectation.h */,\n\t\t\t\t52D8FA702D6DCC600015FAB3 /* XCTNSPredicateExpectation.h */,\n\t\t\t\t52D8FA712D6DCC600015FAB3 /* XCTNSPredicateExpectationObject-Protocol.h */,\n\t\t\t\t52D8FA722D6DCC600015FAB3 /* XCTRunnerAutomationSession.h */,\n\t\t\t\t52D8FA732D6DCC600015FAB3 /* XCTRunnerDaemonSession.h */,\n\t\t\t\t52D8FA742D6DCC600015FAB3 /* XCTRunnerIDESession.h */,\n\t\t\t\t52D8FA752D6DCC600015FAB3 /* XCTTestRunSession.h */,\n\t\t\t\t52D8FA762D6DCC600015FAB3 /* XCTTestRunSessionDelegate-Protocol.h */,\n\t\t\t\t52D8FA772D6DCC600015FAB3 /* XCTUIApplicationMonitor-Protocol.h */,\n\t\t\t\t52D8FA782D6DCC600015FAB3 /* XCTWaiter.h */,\n\t\t\t\t52D8FA792D6DCC600015FAB3 /* XCTWaiterDelegate-Protocol.h */,\n\t\t\t\t52D8FA7A2D6DCC600015FAB3 /* XCTWaiterDelegatePrivate-Protocol.h */,\n\t\t\t\t52D8FA7B2D6DCC600015FAB3 /* XCTWaiterManagement-Protocol.h */,\n\t\t\t\t52D8FA7C2D6DCC600015FAB3 /* XCTWaiterManager.h */,\n\t\t\t\t52D8FA7D2D6DCC600015FAB3 /* XCUIApplication.h */,\n\t\t\t\t52D8FA7E2D6DCC600015FAB3 /* XCUIApplicationImpl.h */,\n\t\t\t\t52D8FA7F2D6DCC600015FAB3 /* XCUIApplicationProcess.h */,\n\t\t\t\t52D8FA802D6DCC600015FAB3 /* XCUICoordinate.h */,\n\t\t\t\t52D8FA812D6DCC600015FAB3 /* XCUIDevice.h */,\n\t\t\t\t52D8FA822D6DCC600015FAB3 /* XCUIElement.h */,\n\t\t\t\t52D8FA832D6DCC600015FAB3 /* XCUIElementAsynchronousHandlerWrapper.h */,\n\t\t\t\t52D8FA842D6DCC600015FAB3 /* XCUIElementHitPointCoordinate.h */,\n\t\t\t\t52D8FA852D6DCC600015FAB3 /* XCUIElementQuery.h */,\n\t\t\t\t52D8FA862D6DCC600015FAB3 /* XCUIHitPointResult.h */,\n\t\t\t\t52D8FA872D6DCC600015FAB3 /* XCUIRecorderNodeFinder.h */,\n\t\t\t\t52D8FA882D6DCC600015FAB3 /* XCUIRecorderNodeFinderMatch.h */,\n\t\t\t\t52D8FA892D6DCC600015FAB3 /* XCUIRecorderTimingMessage.h */,\n\t\t\t\t52D8FA8A2D6DCC600015FAB3 /* XCUIRecorderUtilities.h */,\n\t\t\t\t52D8FA8B2D6DCC600015FAB3 /* XCTElementSetTransformer-Protocol.h */,\n\t\t\t\t52D8FA8C2D6DCC600015FAB3 /* XCUIScreen.h */,\n\t\t\t\t52D8FA8D2D6DCC600015FAB3 /* XCUIScreenDataSource-Protocol.h */,\n\t\t\t);\n\t\t\tpath = XCTest;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t52D8FA8E2D6DCF010015FAB3 /* Categories */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t526111792DE5E8E5007C356B /* XCAXClient_iOS+FBSnapshotReqParams.m */,\n\t\t\t\t526111772DE5E8D0007C356B /* XCAXClient_iOS+FBSnapshotReqParams.h */,\n\t\t\t\t521C1F0A2D801A350024EE42 /* XCUIApplication+Helper.m */,\n\t\t\t\t521C1F082D80184D0024EE42 /* XCUIApplication+Helper.h */,\n\t\t\t\t52D8FAB22D6DD54F0015FAB3 /* XCUIApplication+FBQuiescence.h */,\n\t\t\t\t52D8FAB32D6DD54F0015FAB3 /* XCUIApplication+FBQuiescence.m */,\n\t\t\t\t52D8FA9B2D6DCFAF0015FAB3 /* XCUIApplicationProcess+FBQuiescence.h */,\n\t\t\t\t52D8FA9C2D6DCFAF0015FAB3 /* XCUIApplicationProcess+FBQuiescence.m */,\n\t\t\t\t52D8FA922D6DCF2C0015FAB3 /* maestro-driver-iosUITests-Bridging-Header.h */,\n\t\t\t);\n\t\t\tpath = Categories;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t52D8FAA92D6DD3850015FAB3 /* Utilities */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t521C1F0F2D8051F20024EE42 /* XCTestDaemonsProxy.m */,\n\t\t\t\t521C1F0D2D8051890024EE42 /* XCTestDaemonsProxy.h */,\n\t\t\t\t521C1F062D8017460024EE42 /* AXClientProxy.m */,\n\t\t\t\t521C1F022D8014340024EE42 /* AXClientProxy.h */,\n\t\t\t\t52D8FAAE2D6DD4100015FAB3 /* FBConfiguration.h */,\n\t\t\t\t52D8FAAF2D6DD4100015FAB3 /* FBConfiguration.m */,\n\t\t\t\t52D8FAAA2D6DD3A20015FAB3 /* FBLogger.h */,\n\t\t\t\t52D8FAAB2D6DD3A20015FAB3 /* FBLogger.m */,\n\t\t\t\t521C1F042D8016950024EE42 /* XCAccessibilityElement.h */,\n\t\t\t);\n\t\t\tpath = Utilities;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t612DE410298410F9003C2BE0 /* XCTest */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t610D58FA2A49E3CA00B4BBEB /* AXClientSwizzler.swift */,\n\t\t\t\t612DE4112984114B003C2BE0 /* RunnerDaemonProxy.swift */,\n\t\t\t\t612DE413298426EF003C2BE0 /* EventRecord.swift */,\n\t\t\t\t613E87D6299A64BD00FF8551 /* PointerEventPath.swift */,\n\t\t\t\t613E87DE299BE78400FF8551 /* KeyModifierFlags.swift */,\n\t\t\t\t61C0AFF629D34FEA005D1FC5 /* EventTarget.swift */,\n\t\t\t\t52E35D422A654F67001D97A8 /* RunningApp.swift */,\n\t\t\t);\n\t\t\tpath = XCTest;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t9407362C2940C9F900A72E99 /* Models */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t521C1F132D86D3840024EE42 /* TerminateAppRequest.swift */,\n\t\t\t\t32A9C73D2D7A631400545435 /* LaunchAppRequest.swift */,\n\t\t\t\t9407362D2940CA1600A72E99 /* GetRunningAppRequest.swift */,\n\t\t\t\t32762AF92966DC8300FB69BD /* SwipeRequest.swift */,\n\t\t\t\t32097964297092A800340282 /* InputTextRequest.swift */,\n\t\t\t\t32ECCB252980449200A1A0A0 /* TouchRequest.swift */,\n\t\t\t\t61C0AFE429C7AAB3005D1FC5 /* PressKeyRequest.swift */,\n\t\t\t\t5B8E0ABD2CD562F200E9D439 /* SetOrientationRequest.swift */,\n\t\t\t\t61C0AFE829C86378005D1FC5 /* PressButtonRequest.swift */,\n\t\t\t\t61C0AFEE29C8961F005D1FC5 /* EraseTextRequest.swift */,\n\t\t\t\t61C0AFF229C8C040005D1FC5 /* DeviceInfoResponse.swift */,\n\t\t\t\t945DD44A29D6F5D8004D8ECF /* SetPermissionsRequest.swift */,\n\t\t\t\t6124329B2A4B368100F5F619 /* ViewHierarchyRequest.swift */,\n\t\t\t\t6124329D2A4DA5BB00F5F619 /* AXElement.swift */,\n\t\t\t\t52F33A932AE6823100692902 /* StatusResponse.swift */,\n\t\t\t\t52F0B1B22B3C26DF00C6471A /* KeyboardHandlerRequest.swift */,\n\t\t\t\t52F0B1B42B3C27F700C6471A /* KeyboardHandlerResponse.swift */,\n\t\t\t);\n\t\t\tpath = Models;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t943A907A293F5AF400C85136 /* Routes */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t94826F4229DB080E00795E9B /* Helpers */,\n\t\t\t\t94A90DDC298AE70D006EB769 /* Extensions */,\n\t\t\t\t612DE410298410F9003C2BE0 /* XCTest */,\n\t\t\t\t9407362C2940C9F900A72E99 /* Models */,\n\t\t\t\t943A9082293F5CA700C85136 /* Handlers */,\n\t\t\t\t943A9080293F5C2500C85136 /* RouteHandlerFactory.swift */,\n\t\t\t\t949535FB299FD67E00FD0159 /* XCTestHTTPServer.swift */,\n\t\t\t);\n\t\t\tpath = Routes;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t943A9082293F5CA700C85136 /* Handlers */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t521C1F112D86CE8A0024EE42 /* TerminateAppHandler.swift */,\n\t\t\t\t32A9C73B2D7A62AF00545435 /* LaunchAppHandler.swift */,\n\t\t\t\t610D58F82A45B5DA00B4BBEB /* ViewHierarchyHandler.swift */,\n\t\t\t\t943A9087293F5EAA00C85136 /* RunningAppRouteHandler.swift */,\n\t\t\t\t32762AF72965E2A200FB69BD /* SwipeRouteHandler.swift */,\n\t\t\t\t61A79B9629DF0B8A00C38882 /* SwipeRouteHandlerV2.swift */,\n\t\t\t\t320979622970925500340282 /* InputTextRouteHandler.swift */,\n\t\t\t\t32ECCB27298044C200A1A0A0 /* TouchRouteHandler.swift */,\n\t\t\t\t9494CBD02982F719009C987C /* ScreenshotHandler.swift */,\n\t\t\t\t94256BAC298D39DE00CDB55D /* ScreenDiffHandler.swift */,\n\t\t\t\t61C0AFE629C7AB0C005D1FC5 /* PressKeyHandler.swift */,\n\t\t\t\t61C0AFEA29C863BB005D1FC5 /* PressButtonHandler.swift */,\n\t\t\t\t61C0AFEC29C88926005D1FC5 /* EraseTextHandler.swift */,\n\t\t\t\t61C0AFF029C8C01F005D1FC5 /* DeviceInfoHandler.swift */,\n\t\t\t\t5B8E0ABB2CD562D000E9D439 /* SetOrientationHandler.swift */,\n\t\t\t\t945DD44C29D6F73B004D8ECF /* SetPermissionsHandler.swift */,\n\t\t\t\t52047F772A7A638E00BF982D /* StatusHandler.swift */,\n\t\t\t\t52F0B1B02B3C25BC00C6471A /* KeyboardRouteHandler.swift */,\n\t\t\t);\n\t\t\tpath = Handlers;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t94826F4229DB080E00795E9B /* Helpers */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t94826F4329DB082100795E9B /* SystemPermissionHelper.swift */,\n\t\t\t\t522785802A54410D008DBC0A /* AppError.swift */,\n\t\t\t\t9468FA7C2AA741F100254AA3 /* TextInputHelper.swift */,\n\t\t\t\t9811C9082C49751D00DDACA0 /* ScreenSizeHelper.swift */,\n\t\t\t);\n\t\t\tpath = Helpers;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t94A90DDC298AE70D006EB769 /* Extensions */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\t94A90DDD298AE72A006EB769 /* XCUIElement+Extensions.swift */,\n\t\t\t\tF328D3E52A2A98E7000546D3 /* StringExtensions.swift */,\n\t\t\t\t6124329F2A4DA72F00F5F619 /* Logger.swift */,\n\t\t\t);\n\t\t\tpath = Extensions;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tBBBB00402DF24A0000000040 /* MaestroDriverLib */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tBBBB00412DF24A0000000041 /* MaestroDriverLib */,\n\t\t\t\tBBBB001A2DF24A000000001A /* Info.plist */,\n\t\t\t);\n\t\t\tpath = MaestroDriverLib;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tBBBB00412DF24A0000000041 /* MaestroDriverLib */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tBBBB00422DF24A0000000042 /* Models */,\n\t\t\t\tBBBB00432DF24A0000000043 /* Helpers */,\n\t\t\t\tBBBB00162DF24A0000000016 /* MaestroDriverLib.swift */,\n\t\t\t);\n\t\t\tpath = Sources/MaestroDriverLib;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tBBBB00422DF24A0000000042 /* Models */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tBBBB00112DF24A0000000011 /* AXElement.swift */,\n\t\t\t\tBBBB00122DF24A0000000012 /* AXFrame.swift */,\n\t\t\t\tBBBB00132DF24A0000000013 /* ElementType.swift */,\n\t\t\t\tBBBB00142DF24A0000000014 /* PermissionValue.swift */,\n\t\t\t);\n\t\t\tpath = Models;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tBBBB00432DF24A0000000043 /* Helpers */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tBBBB00152DF24A0000000015 /* PermissionButtonFinder.swift */,\n\t\t\t);\n\t\t\tpath = Helpers;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\tBBBB00502DF24A0000000050 /* MaestroDriverLibTests */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tBBBB00172DF24A0000000017 /* PermissionButtonFinderTests.swift */,\n\t\t\t\tBBBB00182DF24A0000000018 /* AXElementTests.swift */,\n\t\t\t\tBBBB00192DF24A0000000019 /* AXFrameTests.swift */,\n\t\t\t);\n\t\t\tpath = MaestroDriverLib/Tests/MaestroDriverLibTests;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXGroup section */\n\n/* Begin PBXHeadersBuildPhase section */\n\t\t52D8FA9E2D6DD0970015FAB3 /* Headers */ = {\n\t\t\tisa = PBXHeadersBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t52D8FAA72D6DD1950015FAB3 /* XCUICoordinate.h in Headers */,\n\t\t\t\t52D8FAA52D6DD1950015FAB3 /* XCUIApplicationImpl.h in Headers */,\n\t\t\t\t52D8FAB52D6DD54F0015FAB3 /* XCUIApplication+FBQuiescence.h in Headers */,\n\t\t\t\t52D8FAA62D6DD1950015FAB3 /* XCUIApplicationProcess.h in Headers */,\n\t\t\t\t52D8FAA82D6DD1950015FAB3 /* XCUIDevice.h in Headers */,\n\t\t\t\t52D8FAA42D6DD1950015FAB3 /* XCUIApplication.h in Headers */,\n\t\t\t\t526111782DE5E8DA007C356B /* XCAXClient_iOS+FBSnapshotReqParams.h in Headers */,\n\t\t\t\t521C1F092D8018590024EE42 /* XCUIApplication+Helper.h in Headers */,\n\t\t\t\t52D8FAA12D6DD13B0015FAB3 /* _XCTestImplementation.h in Headers */,\n\t\t\t\t52D8FAAD2D6DD3A20015FAB3 /* FBLogger.h in Headers */,\n\t\t\t\t521C1F0C2D8030A30024EE42 /* XCTestManager_ManagerInterface-Protocol.h in Headers */,\n\t\t\t\t52D8FAB12D6DD4100015FAB3 /* FBConfiguration.h in Headers */,\n\t\t\t\t52D8FAA02D6DD0E60015FAB3 /* XCAXClient_iOS.h in Headers */,\n\t\t\t\t521C1F0E2D80518A0024EE42 /* XCTestDaemonsProxy.h in Headers */,\n\t\t\t\t521C1F052D8016950024EE42 /* XCAccessibilityElement.h in Headers */,\n\t\t\t\t52D8FAA32D6DD16F0015FAB3 /* CDStructures.h in Headers */,\n\t\t\t\t52D8FAA22D6DD1580015FAB3 /* maestro-driver-iosUITests-Bridging-Header.h in Headers */,\n\t\t\t\t52D8FA9F2D6DD0B50015FAB3 /* XCUIApplicationProcess+FBQuiescence.h in Headers */,\n\t\t\t\t521C1F032D8014410024EE42 /* AXClientProxy.h in Headers */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXHeadersBuildPhase section */\n\n/* Begin PBXNativeTarget section */\n\t\t52049BD22935039F00807AA3 /* maestro-driver-ios */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = 52049BFD293503A200807AA3 /* Build configuration list for PBXNativeTarget \"maestro-driver-ios\" */;\n\t\t\tbuildPhases = (\n\t\t\t\t52049BCF2935039F00807AA3 /* Sources */,\n\t\t\t\t52049BD02935039F00807AA3 /* Frameworks */,\n\t\t\t\t52049BD12935039F00807AA3 /* Resources */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t);\n\t\t\tname = \"maestro-driver-ios\";\n\t\t\tproductName = \"maestro-driver-ios\";\n\t\t\tproductReference = 52049BD32935039F00807AA3 /* maestro-driver-ios.app */;\n\t\t\tproductType = \"com.apple.product-type.application\";\n\t\t};\n\t\t52049BF2293503A200807AA3 /* maestro-driver-iosUITests */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = 52049C03293503A200807AA3 /* Build configuration list for PBXNativeTarget \"maestro-driver-iosUITests\" */;\n\t\t\tbuildPhases = (\n\t\t\t\t52D8FA9E2D6DD0970015FAB3 /* Headers */,\n\t\t\t\t52049BEF293503A200807AA3 /* Sources */,\n\t\t\t\t52049BF0293503A200807AA3 /* Frameworks */,\n\t\t\t\t52049BF1293503A200807AA3 /* Resources */,\n\t\t\t\tBBBB00302DF24A0000000030 /* Embed Frameworks */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t\t52049BF5293503A200807AA3 /* PBXTargetDependency */,\n\t\t\t\tBBBB00802DF24A0000000080 /* PBXTargetDependency */,\n\t\t\t);\n\t\t\tname = \"maestro-driver-iosUITests\";\n\t\t\tpackageProductDependencies = (\n\t\t\t\t5279BFD52935ECE20056C609 /* FlyingFox */,\n\t\t\t);\n\t\t\tproductName = \"maestro-driver-iosUITests\";\n\t\t\tproductReference = 52049BF3293503A200807AA3 /* maestro-driver-iosUITests.xctest */;\n\t\t\tproductType = \"com.apple.product-type.bundle.ui-testing\";\n\t\t};\n\t\t85861EAEA94A3C855AED94AD /* maestro-driver-iosTests */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = 86375B66D218F69066FF14CA /* Build configuration list for PBXNativeTarget \"maestro-driver-iosTests\" */;\n\t\t\tbuildPhases = (\n\t\t\t\tF1A2303EDBE4483B98F2B146 /* Sources */,\n\t\t\t\tD2FF88B2E3410D9913F22F96 /* Frameworks */,\n\t\t\t\tA4C710470E989DA1B8CF25A3 /* Resources */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t\tA94E226D52BD4874F051FD7C /* PBXTargetDependency */,\n\t\t\t);\n\t\t\tname = \"maestro-driver-iosTests\";\n\t\t\tproductName = \"maestro-driver-iosTests\";\n\t\t\tproductReference = EF8504E3A6C0E75E273B4538 /* maestro-driver-iosTests.xctest */;\n\t\t\tproductType = \"com.apple.product-type.bundle.unit-test\";\n\t\t};\n\t\tBBBB00A02DF24A00000000A0 /* MaestroDriverLib */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = BBBB00B02DF24A00000000B0 /* Build configuration list for PBXNativeTarget \"MaestroDriverLib\" */;\n\t\t\tbuildPhases = (\n\t\t\t\tBBBB00702DF24A0000000070 /* Sources */,\n\t\t\t\tBBBB00602DF24A0000000060 /* Frameworks */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t);\n\t\t\tname = MaestroDriverLib;\n\t\t\tproductName = MaestroDriverLib;\n\t\t\tproductReference = BBBB00202DF24A0000000020 /* MaestroDriverLib.framework */;\n\t\t\tproductType = \"com.apple.product-type.framework\";\n\t\t};\n\t\tBBBB00A12DF24A00000000A1 /* MaestroDriverLibTests */ = {\n\t\t\tisa = PBXNativeTarget;\n\t\t\tbuildConfigurationList = BBBB00B12DF24A00000000B1 /* Build configuration list for PBXNativeTarget \"MaestroDriverLibTests\" */;\n\t\t\tbuildPhases = (\n\t\t\t\tBBBB00712DF24A0000000071 /* Sources */,\n\t\t\t\tBBBB00612DF24A0000000061 /* Frameworks */,\n\t\t\t);\n\t\t\tbuildRules = (\n\t\t\t);\n\t\t\tdependencies = (\n\t\t\t\tBBBB00822DF24A0000000082 /* PBXTargetDependency */,\n\t\t\t);\n\t\t\tname = MaestroDriverLibTests;\n\t\t\tproductName = MaestroDriverLibTests;\n\t\t\tproductReference = BBBB00212DF24A0000000021 /* MaestroDriverLibTests.xctest */;\n\t\t\tproductType = \"com.apple.product-type.bundle.unit-test\";\n\t\t};\n/* End PBXNativeTarget section */\n\n/* Begin PBXProject section */\n\t\t52049BCB2935039F00807AA3 /* Project object */ = {\n\t\t\tisa = PBXProject;\n\t\t\tattributes = {\n\t\t\t\tBuildIndependentTargetsInParallel = 1;\n\t\t\t\tLastSwiftUpdateCheck = 1340;\n\t\t\t\tLastUpgradeCheck = 1340;\n\t\t\t\tTargetAttributes = {\n\t\t\t\t\t52049BD22935039F00807AA3 = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 13.4.1;\n\t\t\t\t\t};\n\t\t\t\t\t52049BF2293503A200807AA3 = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 13.4.1;\n\t\t\t\t\t\tLastSwiftMigration = 1620;\n\t\t\t\t\t\tTestTargetID = 52049BD22935039F00807AA3;\n\t\t\t\t\t};\n\t\t\t\t\tBBBB00A02DF24A00000000A0 = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 16.2;\n\t\t\t\t\t};\n\t\t\t\t\tBBBB00A12DF24A00000000A1 = {\n\t\t\t\t\t\tCreatedOnToolsVersion = 16.2;\n\t\t\t\t\t\tTestTargetID = BBBB00A02DF24A00000000A0;\n\t\t\t\t\t};\n\t\t\t\t};\n\t\t\t};\n\t\t\tbuildConfigurationList = 52049BCE2935039F00807AA3 /* Build configuration list for PBXProject \"maestro-driver-ios\" */;\n\t\t\tcompatibilityVersion = \"Xcode 13.0\";\n\t\t\tdevelopmentRegion = en;\n\t\t\thasScannedForEncodings = 0;\n\t\t\tknownRegions = (\n\t\t\t\ten,\n\t\t\t\tBase,\n\t\t\t);\n\t\t\tmainGroup = 52049BCA2935039F00807AA3;\n\t\t\tpackageReferences = (\n\t\t\t\t52049C062935E04D00807AA3 /* XCRemoteSwiftPackageReference \"FlyingFox\" */,\n\t\t\t);\n\t\t\tproductRefGroup = 52049BD42935039F00807AA3 /* Products */;\n\t\t\tprojectDirPath = \"\";\n\t\t\tprojectRoot = \"\";\n\t\t\ttargets = (\n\t\t\t\t52049BD22935039F00807AA3 /* maestro-driver-ios */,\n\t\t\t\t52049BF2293503A200807AA3 /* maestro-driver-iosUITests */,\n\t\t\t\tBBBB00A02DF24A00000000A0 /* MaestroDriverLib */,\n\t\t\t\tBBBB00A12DF24A00000000A1 /* MaestroDriverLibTests */,\n\t\t\t\t85861EAEA94A3C855AED94AD /* maestro-driver-iosTests */,\n\t\t\t);\n\t\t};\n/* End PBXProject section */\n\n/* Begin PBXResourcesBuildPhase section */\n\t\t52049BD12935039F00807AA3 /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t52049BE3293503A200807AA3 /* LaunchScreen.storyboard in Resources */,\n\t\t\t\t52049BE0293503A200807AA3 /* Assets.xcassets in Resources */,\n\t\t\t\t52049BDE2935039F00807AA3 /* Main.storyboard in Resources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\t52049BF1293503A200807AA3 /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\tA4C710470E989DA1B8CF25A3 /* Resources */ = {\n\t\t\tisa = PBXResourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXResourcesBuildPhase section */\n\n/* Begin PBXSourcesBuildPhase section */\n\t\t52049BCF2935039F00807AA3 /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t52049BDB2935039F00807AA3 /* ViewController.swift in Sources */,\n\t\t\t\t52049BD72935039F00807AA3 /* AppDelegate.swift in Sources */,\n\t\t\t\t52049BD92935039F00807AA3 /* SceneDelegate.swift in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\t52049BEF293503A200807AA3 /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\t610D58F92A45B5DA00B4BBEB /* ViewHierarchyHandler.swift in Sources */,\n\t\t\t\t52F0B1B32B3C26DF00C6471A /* KeyboardHandlerRequest.swift in Sources */,\n\t\t\t\t612DE4122984114B003C2BE0 /* RunnerDaemonProxy.swift in Sources */,\n\t\t\t\t61C0AFEB29C863BB005D1FC5 /* PressButtonHandler.swift in Sources */,\n\t\t\t\t94826F4429DB082100795E9B /* SystemPermissionHelper.swift in Sources */,\n\t\t\t\t52D8FAAC2D6DD3A20015FAB3 /* FBLogger.m in Sources */,\n\t\t\t\t612432A02A4DA72F00F5F619 /* Logger.swift in Sources */,\n\t\t\t\t521C1F142D86D3900024EE42 /* TerminateAppRequest.swift in Sources */,\n\t\t\t\t9407362E2940CA1600A72E99 /* GetRunningAppRequest.swift in Sources */,\n\t\t\t\t521C1F072D8017480024EE42 /* AXClientProxy.m in Sources */,\n\t\t\t\t61C0AFED29C88926005D1FC5 /* EraseTextHandler.swift in Sources */,\n\t\t\t\t32ECCB262980449200A1A0A0 /* TouchRequest.swift in Sources */,\n\t\t\t\t5261117A2DE5E8E7007C356B /* XCAXClient_iOS+FBSnapshotReqParams.m in Sources */,\n\t\t\t\t52F33A942AE6823100692902 /* StatusResponse.swift in Sources */,\n\t\t\t\t32A9C73E2D7A631A00545435 /* LaunchAppRequest.swift in Sources */,\n\t\t\t\t32A9C73C2D7A62B500545435 /* LaunchAppHandler.swift in Sources */,\n\t\t\t\t610D58FB2A49E3CA00B4BBEB /* AXClientSwizzler.swift in Sources */,\n\t\t\t\tF328D3E62A2A98E7000546D3 /* StringExtensions.swift in Sources */,\n\t\t\t\t5B8E0ABC2CD562D000E9D439 /* SetOrientationHandler.swift in Sources */,\n\t\t\t\t61C0AFEF29C8961F005D1FC5 /* EraseTextRequest.swift in Sources */,\n\t\t\t\t521C1F102D8051FC0024EE42 /* XCTestDaemonsProxy.m in Sources */,\n\t\t\t\t61A79B9729DF0B8A00C38882 /* SwipeRouteHandlerV2.swift in Sources */,\n\t\t\t\t52F0B1B52B3C27F700C6471A /* KeyboardHandlerResponse.swift in Sources */,\n\t\t\t\t52047F782A7A638E00BF982D /* StatusHandler.swift in Sources */,\n\t\t\t\t5B8E0ABE2CD562F200E9D439 /* SetOrientationRequest.swift in Sources */,\n\t\t\t\t945DD44B29D6F5D8004D8ECF /* SetPermissionsRequest.swift in Sources */,\n\t\t\t\t613E87DF299BE78400FF8551 /* KeyModifierFlags.swift in Sources */,\n\t\t\t\t521C1F122D86CE940024EE42 /* TerminateAppHandler.swift in Sources */,\n\t\t\t\t94A90DDE298AE72A006EB769 /* XCUIElement+Extensions.swift in Sources */,\n\t\t\t\t945DD44D29D6F73B004D8ECF /* SetPermissionsHandler.swift in Sources */,\n\t\t\t\t61C0AFE729C7AB0C005D1FC5 /* PressKeyHandler.swift in Sources */,\n\t\t\t\t61C0AFF129C8C01F005D1FC5 /* DeviceInfoHandler.swift in Sources */,\n\t\t\t\t943A9088293F5EAA00C85136 /* RunningAppRouteHandler.swift in Sources */,\n\t\t\t\t613E87D7299A64BD00FF8551 /* PointerEventPath.swift in Sources */,\n\t\t\t\t9811C9092C49751D00DDACA0 /* ScreenSizeHelper.swift in Sources */,\n\t\t\t\t9468FA7D2AA741F100254AA3 /* TextInputHelper.swift in Sources */,\n\t\t\t\t61C0AFF729D34FEA005D1FC5 /* EventTarget.swift in Sources */,\n\t\t\t\t612DE414298426EF003C2BE0 /* EventRecord.swift in Sources */,\n\t\t\t\t32ECCB28298044C200A1A0A0 /* TouchRouteHandler.swift in Sources */,\n\t\t\t\t943A9081293F5C2500C85136 /* RouteHandlerFactory.swift in Sources */,\n\t\t\t\t6124329C2A4B368100F5F619 /* ViewHierarchyRequest.swift in Sources */,\n\t\t\t\t61C0AFE529C7AAB3005D1FC5 /* PressKeyRequest.swift in Sources */,\n\t\t\t\t61C0AFF329C8C040005D1FC5 /* DeviceInfoResponse.swift in Sources */,\n\t\t\t\t94256BAD298D39DE00CDB55D /* ScreenDiffHandler.swift in Sources */,\n\t\t\t\t521C1F0B2D801A360024EE42 /* XCUIApplication+Helper.m in Sources */,\n\t\t\t\t949535FC299FD67E00FD0159 /* XCTestHTTPServer.swift in Sources */,\n\t\t\t\t52D8FA9D2D6DCFAF0015FAB3 /* XCUIApplicationProcess+FBQuiescence.m in Sources */,\n\t\t\t\t32097965297092A800340282 /* InputTextRequest.swift in Sources */,\n\t\t\t\t52049BF8293503A200807AA3 /* maestro_driver_iosUITests.swift in Sources */,\n\t\t\t\t52D8FAB02D6DD4100015FAB3 /* FBConfiguration.m in Sources */,\n\t\t\t\t61C0AFE929C86378005D1FC5 /* PressButtonRequest.swift in Sources */,\n\t\t\t\t6124329E2A4DA5BB00F5F619 /* AXElement.swift in Sources */,\n\t\t\t\t32762AF82965E2A200FB69BD /* SwipeRouteHandler.swift in Sources */,\n\t\t\t\t522785812A54410D008DBC0A /* AppError.swift in Sources */,\n\t\t\t\t320979632970925500340282 /* InputTextRouteHandler.swift in Sources */,\n\t\t\t\t52E35D432A654F67001D97A8 /* RunningApp.swift in Sources */,\n\t\t\t\t9494CBD12982F719009C987C /* ScreenshotHandler.swift in Sources */,\n\t\t\t\t32762AFA2966DC8300FB69BD /* SwipeRequest.swift in Sources */,\n\t\t\t\t52F0B1B12B3C25BC00C6471A /* KeyboardRouteHandler.swift in Sources */,\n\t\t\t\t52D8FAB42D6DD54F0015FAB3 /* XCUIApplication+FBQuiescence.m in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\tBBBB00702DF24A0000000070 /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\tBBBB00012DF24A0000000001 /* AXElement.swift in Sources */,\n\t\t\t\tBBBB00022DF24A0000000002 /* AXFrame.swift in Sources */,\n\t\t\t\tBBBB00032DF24A0000000003 /* ElementType.swift in Sources */,\n\t\t\t\tBBBB00042DF24A0000000004 /* PermissionValue.swift in Sources */,\n\t\t\t\tBBBB00052DF24A0000000005 /* PermissionButtonFinder.swift in Sources */,\n\t\t\t\tBBBB00062DF24A0000000006 /* MaestroDriverLib.swift in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\tBBBB00712DF24A0000000071 /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\tBBBB000A2DF24A000000000A /* PermissionButtonFinderTests.swift in Sources */,\n\t\t\t\tBBBB000B2DF24A000000000B /* AXElementTests.swift in Sources */,\n\t\t\t\tBBBB000C2DF24A000000000C /* AXFrameTests.swift in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n\t\tF1A2303EDBE4483B98F2B146 /* Sources */ = {\n\t\t\tisa = PBXSourcesBuildPhase;\n\t\t\tbuildActionMask = 2147483647;\n\t\t\tfiles = (\n\t\t\t\tD2244FD4EDCE31D1BAFD0549 /* SnapshotParametersTests.swift in Sources */,\n\t\t\t\t641B1A7714A4B3E6B7C2A114 /* AXClientProxy.m in Sources */,\n\t\t\t\tC2DB03C0028C821C6F580B2C /* XCAXClient_iOS+FBSnapshotReqParams.m in Sources */,\n\t\t\t);\n\t\t\trunOnlyForDeploymentPostprocessing = 0;\n\t\t};\n/* End PBXSourcesBuildPhase section */\n\n/* Begin PBXTargetDependency section */\n\t\t52049BF5293503A200807AA3 /* PBXTargetDependency */ = {\n\t\t\tisa = PBXTargetDependency;\n\t\t\ttarget = 52049BD22935039F00807AA3 /* maestro-driver-ios */;\n\t\t\ttargetProxy = 52049BF4293503A200807AA3 /* PBXContainerItemProxy */;\n\t\t};\n\t\tA94E226D52BD4874F051FD7C /* PBXTargetDependency */ = {\n\t\t\tisa = PBXTargetDependency;\n\t\t\tname = \"maestro-driver-ios\";\n\t\t\ttarget = 52049BD22935039F00807AA3 /* maestro-driver-ios */;\n\t\t\ttargetProxy = 87E61CDE27EC91E5197ED513 /* PBXContainerItemProxy */;\n\t\t};\n\t\tBBBB00802DF24A0000000080 /* PBXTargetDependency */ = {\n\t\t\tisa = PBXTargetDependency;\n\t\t\ttarget = BBBB00A02DF24A00000000A0 /* MaestroDriverLib */;\n\t\t\ttargetProxy = BBBB00812DF24A0000000081 /* PBXContainerItemProxy */;\n\t\t};\n\t\tBBBB00822DF24A0000000082 /* PBXTargetDependency */ = {\n\t\t\tisa = PBXTargetDependency;\n\t\t\ttarget = BBBB00A02DF24A00000000A0 /* MaestroDriverLib */;\n\t\t\ttargetProxy = BBBB00832DF24A0000000083 /* PBXContainerItemProxy */;\n\t\t};\n/* End PBXTargetDependency section */\n\n/* Begin PBXVariantGroup section */\n\t\t52049BDC2935039F00807AA3 /* Main.storyboard */ = {\n\t\t\tisa = PBXVariantGroup;\n\t\t\tchildren = (\n\t\t\t\t52049BDD2935039F00807AA3 /* Base */,\n\t\t\t);\n\t\t\tname = Main.storyboard;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n\t\t52049BE1293503A200807AA3 /* LaunchScreen.storyboard */ = {\n\t\t\tisa = PBXVariantGroup;\n\t\t\tchildren = (\n\t\t\t\t52049BE2293503A200807AA3 /* Base */,\n\t\t\t);\n\t\t\tname = LaunchScreen.storyboard;\n\t\t\tsourceTree = \"<group>\";\n\t\t};\n/* End PBXVariantGroup section */\n\n/* Begin XCBuildConfiguration section */\n\t\t52049BFB293503A200807AA3 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++17\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_DOCUMENTATION_COMMENTS = YES;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = dwarf;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tENABLE_TESTABILITY = YES;\n\t\t\t\tGCC_DYNAMIC_NO_PIC = NO;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_OPTIMIZATION_LEVEL = 0;\n\t\t\t\tGCC_PREPROCESSOR_DEFINITIONS = (\n\t\t\t\t\t\"DEBUG=1\",\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t);\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;\n\t\t\t\tMTL_FAST_MATH = YES;\n\t\t\t\tONLY_ACTIVE_ARCH = YES;\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-Onone\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t52049BFC293503A200807AA3 /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;\n\t\t\t\tCC_C_LANGUAGE_STANDARD = gnu11;\n\t\t\t\tCLANG_ANALYZER_NONNULL = YES;\n\t\t\t\tCLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;\n\t\t\t\tCLANG_CXX_LANGUAGE_STANDARD = \"gnu++17\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_ARC = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = YES;\n\t\t\t\tCLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;\n\t\t\t\tCLANG_WARN_BOOL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_COMMA = YES;\n\t\t\t\tCLANG_WARN_CONSTANT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;\n\t\t\t\tCLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;\n\t\t\t\tCLANG_WARN_DOCUMENTATION_COMMENTS = YES;\n\t\t\t\tCLANG_WARN_EMPTY_BODY = YES;\n\t\t\t\tCLANG_WARN_ENUM_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_INFINITE_RECURSION = YES;\n\t\t\t\tCLANG_WARN_INT_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;\n\t\t\t\tCLANG_WARN_OBJC_LITERAL_CONVERSION = YES;\n\t\t\t\tCLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;\n\t\t\t\tCLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;\n\t\t\t\tCLANG_WARN_RANGE_LOOP_ANALYSIS = YES;\n\t\t\t\tCLANG_WARN_STRICT_PROTOTYPES = YES;\n\t\t\t\tCLANG_WARN_SUSPICIOUS_MOVE = YES;\n\t\t\t\tCLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;\n\t\t\t\tCLANG_WARN_UNREACHABLE_CODE = YES;\n\t\t\t\tCLANG_WARN__DUPLICATE_METHOD_MATCH = YES;\n\t\t\t\tCOPY_PHASE_STRIP = NO;\n\t\t\t\tDEBUG_INFORMATION_FORMAT = \"dwarf-with-dsym\";\n\t\t\t\tENABLE_NS_ASSERTIONS = NO;\n\t\t\t\tENABLE_STRICT_OBJC_MSGSEND = YES;\n\t\t\t\tGCC_NO_COMMON_BLOCKS = YES;\n\t\t\t\tGCC_WARN_64_TO_32_BIT_CONVERSION = YES;\n\t\t\t\tGCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;\n\t\t\t\tGCC_WARN_UNDECLARED_SELECTOR = YES;\n\t\t\t\tGCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;\n\t\t\t\tGCC_WARN_UNUSED_FUNCTION = YES;\n\t\t\t\tGCC_WARN_UNUSED_VARIABLE = YES;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n\t\t\t\tMTL_ENABLE_DEBUG_INFO = NO;\n\t\t\t\tMTL_FAST_MATH = YES;\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSWIFT_COMPILATION_MODE = wholemodule;\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-O\";\n\t\t\t\tVALIDATE_PRODUCT = YES;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\t52049BFE293503A200807AA3 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCURRENT_PROJECT_VERSION = 1;\n\t\t\t\tDEVELOPMENT_TEAM = \"\";\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tINFOPLIST_FILE = \"maestro-driver-ios/Info.plist\";\n\t\t\t\tINFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;\n\t\t\t\tINFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;\n\t\t\t\tINFOPLIST_KEY_UIMainStoryboardFile = Main;\n\t\t\t\tINFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = \"UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight\";\n\t\t\t\tINFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = \"UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight\";\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tMARKETING_VERSION = 1.0;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = \"dev.mobile.maestro-driver-ios\";\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t52049BFF293503A200807AA3 /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;\n\t\t\t\tASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCURRENT_PROJECT_VERSION = 1;\n\t\t\t\tDEVELOPMENT_TEAM = \"\";\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tINFOPLIST_FILE = \"maestro-driver-ios/Info.plist\";\n\t\t\t\tINFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;\n\t\t\t\tINFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;\n\t\t\t\tINFOPLIST_KEY_UIMainStoryboardFile = Main;\n\t\t\t\tINFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = \"UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight\";\n\t\t\t\tINFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = \"UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight\";\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tMARKETING_VERSION = 1.0;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = \"dev.mobile.maestro-driver-ios\";\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\t52049C04293503A200807AA3 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCURRENT_PROJECT_VERSION = 1;\n\t\t\t\tDEVELOPMENT_TEAM = 25CQD4CKK3;\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t\t\"@loader_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tMARKETING_VERSION = 1.0;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = \"dev.mobile.maestro-driver-iosUITests\";\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = NO;\n\t\t\t\tSWIFT_OBJC_BRIDGING_HEADER = \"maestro-driver-iosUITests/Categories/maestro-driver-iosUITests-Bridging-Header.h\";\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-Onone\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t\tTEST_TARGET_NAME = \"maestro-driver-ios\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\t52049C05293503A200807AA3 /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCURRENT_PROJECT_VERSION = 1;\n\t\t\t\tDEVELOPMENT_TEAM = 25CQD4CKK3;\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t\t\"@loader_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tMARKETING_VERSION = 1.0;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = \"dev.mobile.maestro-driver-iosUITests\";\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = NO;\n\t\t\t\tSWIFT_OBJC_BRIDGING_HEADER = \"maestro-driver-iosUITests/Categories/maestro-driver-iosUITests-Bridging-Header.h\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t\tTEST_TARGET_NAME = \"maestro-driver-ios\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\t5CDDA14E6B80E97BE8A128F0 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;\n\t\t\t\tBUNDLE_LOADER = \"$(TEST_HOST)\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = NO;\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tHEADER_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"$(SRCROOT)/maestro-driver-iosUITests/PrivateHeaders/XCTest\",\n\t\t\t\t);\n\t\t\t\tINFOPLIST_FILE = \"maestro-driver-iosTests/Info.plist\";\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t\t\"@loader_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tOTHER_LDFLAGS = \"-ObjC\";\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = \"dev.mobile.maestro-driver-iosTests\";\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSWIFT_OBJC_BRIDGING_HEADER = \"maestro-driver-iosTests/maestro-driver-iosTests-Bridging-Header.h\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t\tTEST_HOST = \"$(BUILT_PRODUCTS_DIR)/maestro-driver-ios.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/maestro-driver-ios\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\tBBBB00C02DF24A00000000C0 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tBUILD_LIBRARY_FOR_DISTRIBUTION = YES;\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCURRENT_PROJECT_VERSION = 1;\n\t\t\t\tDEFINES_MODULE = YES;\n\t\t\t\tDYLIB_COMPATIBILITY_VERSION = 1;\n\t\t\t\tDYLIB_CURRENT_VERSION = 1;\n\t\t\t\tDYLIB_INSTALL_NAME_BASE = \"@rpath\";\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tINFOPLIST_FILE = MaestroDriverLib/Info.plist;\n\t\t\t\tINFOPLIST_KEY_NSHumanReadableCopyright = \"\";\n\t\t\t\tINSTALL_PATH = \"$(LOCAL_LIBRARY_DIR)/Frameworks\";\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t\t\"@loader_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tMARKETING_VERSION = 1.0;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = dev.mobile.MaestroDriverLib;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME:c99extidentifier)\";\n\t\t\t\tSKIP_INSTALL = YES;\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-Onone\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\tBBBB00C12DF24A00000000C1 /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tBUILD_LIBRARY_FOR_DISTRIBUTION = YES;\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCURRENT_PROJECT_VERSION = 1;\n\t\t\t\tDEFINES_MODULE = YES;\n\t\t\t\tDYLIB_COMPATIBILITY_VERSION = 1;\n\t\t\t\tDYLIB_CURRENT_VERSION = 1;\n\t\t\t\tDYLIB_INSTALL_NAME_BASE = \"@rpath\";\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tINFOPLIST_FILE = MaestroDriverLib/Info.plist;\n\t\t\t\tINFOPLIST_KEY_NSHumanReadableCopyright = \"\";\n\t\t\t\tINSTALL_PATH = \"$(LOCAL_LIBRARY_DIR)/Frameworks\";\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t\t\"@loader_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tMARKETING_VERSION = 1.0;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = dev.mobile.MaestroDriverLib;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME:c99extidentifier)\";\n\t\t\t\tSKIP_INSTALL = YES;\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\tBBBB00C22DF24A00000000C2 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCURRENT_PROJECT_VERSION = 1;\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t\t\"@loader_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tMARKETING_VERSION = 1.0;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = dev.mobile.MaestroDriverLibTests;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = NO;\n\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = \"-Onone\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t};\n\t\t\tname = Debug;\n\t\t};\n\t\tBBBB00C32DF24A00000000C3 /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tCURRENT_PROJECT_VERSION = 1;\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t\t\"@loader_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tMARKETING_VERSION = 1.0;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = dev.mobile.MaestroDriverLibTests;\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSWIFT_EMIT_LOC_STRINGS = NO;\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n\t\tF6BBED1CBAD34F05CA974E4C /* Release */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {\n\t\t\t\tALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;\n\t\t\t\tBUNDLE_LOADER = \"$(TEST_HOST)\";\n\t\t\t\tCLANG_ENABLE_MODULES = YES;\n\t\t\t\tCLANG_ENABLE_OBJC_WEAK = NO;\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tGENERATE_INFOPLIST_FILE = YES;\n\t\t\t\tHEADER_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"$(SRCROOT)/maestro-driver-iosUITests/PrivateHeaders/XCTest\",\n\t\t\t\t);\n\t\t\t\tINFOPLIST_FILE = \"maestro-driver-iosTests/Info.plist\";\n\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (\n\t\t\t\t\t\"$(inherited)\",\n\t\t\t\t\t\"@executable_path/Frameworks\",\n\t\t\t\t\t\"@loader_path/Frameworks\",\n\t\t\t\t);\n\t\t\t\tOTHER_LDFLAGS = \"-ObjC\";\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = \"dev.mobile.maestro-driver-iosTests\";\n\t\t\t\tPRODUCT_NAME = \"$(TARGET_NAME)\";\n\t\t\t\tSDKROOT = iphoneos;\n\t\t\t\tSWIFT_OBJC_BRIDGING_HEADER = \"maestro-driver-iosTests/maestro-driver-iosTests-Bridging-Header.h\";\n\t\t\t\tSWIFT_VERSION = 5.0;\n\t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\t\t\t\tTEST_HOST = \"$(BUILT_PRODUCTS_DIR)/maestro-driver-ios.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/maestro-driver-ios\";\n\t\t\t\tVALIDATE_PRODUCT = YES;\n\t\t\t};\n\t\t\tname = Release;\n\t\t};\n/* End XCBuildConfiguration section */\n\n/* Begin XCConfigurationList section */\n\t\t52049BCE2935039F00807AA3 /* Build configuration list for PBXProject \"maestro-driver-ios\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t52049BFB293503A200807AA3 /* Debug */,\n\t\t\t\t52049BFC293503A200807AA3 /* Release */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\t52049BFD293503A200807AA3 /* Build configuration list for PBXNativeTarget \"maestro-driver-ios\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t52049BFE293503A200807AA3 /* Debug */,\n\t\t\t\t52049BFF293503A200807AA3 /* Release */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\t52049C03293503A200807AA3 /* Build configuration list for PBXNativeTarget \"maestro-driver-iosUITests\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\t52049C04293503A200807AA3 /* Debug */,\n\t\t\t\t52049C05293503A200807AA3 /* Release */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\t86375B66D218F69066FF14CA /* Build configuration list for PBXNativeTarget \"maestro-driver-iosTests\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\tF6BBED1CBAD34F05CA974E4C /* Release */,\n\t\t\t\t5CDDA14E6B80E97BE8A128F0 /* Debug */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\tBBBB00B02DF24A00000000B0 /* Build configuration list for PBXNativeTarget \"MaestroDriverLib\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\tBBBB00C02DF24A00000000C0 /* Debug */,\n\t\t\t\tBBBB00C12DF24A00000000C1 /* Release */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n\t\tBBBB00B12DF24A00000000B1 /* Build configuration list for PBXNativeTarget \"MaestroDriverLibTests\" */ = {\n\t\t\tisa = XCConfigurationList;\n\t\t\tbuildConfigurations = (\n\t\t\t\tBBBB00C22DF24A00000000C2 /* Debug */,\n\t\t\t\tBBBB00C32DF24A00000000C3 /* Release */,\n\t\t\t);\n\t\t\tdefaultConfigurationIsVisible = 0;\n\t\t\tdefaultConfigurationName = Release;\n\t\t};\n/* End XCConfigurationList section */\n\n/* Begin XCRemoteSwiftPackageReference section */\n\t\t52049C062935E04D00807AA3 /* XCRemoteSwiftPackageReference \"FlyingFox\" */ = {\n\t\t\tisa = XCRemoteSwiftPackageReference;\n\t\t\trepositoryURL = \"https://github.com/swhitty/FlyingFox\";\n\t\t\trequirement = {\n\t\t\t\tkind = exactVersion;\n\t\t\t\tversion = 0.22.0;\n\t\t\t};\n\t\t};\n/* End XCRemoteSwiftPackageReference section */\n\n/* Begin XCSwiftPackageProductDependency section */\n\t\t5279BFD52935ECE20056C609 /* FlyingFox */ = {\n\t\t\tisa = XCSwiftPackageProductDependency;\n\t\t\tpackage = 52049C062935E04D00807AA3 /* XCRemoteSwiftPackageReference \"FlyingFox\" */;\n\t\t\tproductName = FlyingFox;\n\t\t};\n/* End XCSwiftPackageProductDependency section */\n\t};\n\trootObject = 52049BCB2935039F00807AA3 /* Project object */;\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-ios.xcodeproj/project.xcworkspace/contents.xcworkspacedata",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace\n   version = \"1.0\">\n   <FileRef\n      location = \"self:\">\n   </FileRef>\n</Workspace>\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-ios.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>IDEDidComputeMac32BitWarning</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-ios.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved",
    "content": "{\n  \"originHash\" : \"fcc5a9e047bc89c422e9ca5571e5b664446abc3c4c0b838c23d066c241e1a3f6\",\n  \"pins\" : [\n    {\n      \"identity\" : \"flyingfox\",\n      \"kind\" : \"remoteSourceControl\",\n      \"location\" : \"https://github.com/swhitty/FlyingFox\",\n      \"state\" : {\n        \"revision\" : \"3ad076e081749cef043e25ac01719a503b772113\",\n        \"version\" : \"0.22.0\"\n      }\n    }\n  ],\n  \"version\" : 3\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-ios.xcodeproj/xcshareddata/xcschemes/maestro-driver-ios.xcscheme",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"1340\"\n   version = \"1.3\">\n   <BuildAction\n      parallelizeBuildables = \"YES\"\n      buildImplicitDependencies = \"YES\">\n      <BuildActionEntries>\n         <BuildActionEntry\n            buildForTesting = \"YES\"\n            buildForRunning = \"YES\"\n            buildForProfiling = \"YES\"\n            buildForArchiving = \"YES\"\n            buildForAnalyzing = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"52049BD22935039F00807AA3\"\n               BuildableName = \"maestro-driver-ios.app\"\n               BlueprintName = \"maestro-driver-ios\"\n               ReferencedContainer = \"container:maestro-driver-ios.xcodeproj\">\n            </BuildableReference>\n         </BuildActionEntry>\n      </BuildActionEntries>\n   </BuildAction>\n   <TestAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\">\n      <Testables>\n         <TestableReference\n            skipped = \"NO\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"52049BF2293503A200807AA3\"\n               BuildableName = \"maestro-driver-iosUITests.xctest\"\n               BlueprintName = \"maestro-driver-iosUITests\"\n               ReferencedContainer = \"container:maestro-driver-ios.xcodeproj\">\n            </BuildableReference>\n            <SkippedTests>\n               <Test\n                  Identifier = \"ViewHierarchyHandlerTests\">\n               </Test>\n               <Test\n                  Identifier = \"ViewHierarchyHandlerTests/testViewHierarchyHandlerReturnsNonEmptyHierarchy()\">\n               </Test>\n            </SkippedTests>\n         </TestableReference>\n      </Testables>\n   </TestAction>\n   <LaunchAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      launchStyle = \"0\"\n      useCustomWorkingDirectory = \"NO\"\n      ignoresPersistentStateOnLaunch = \"NO\"\n      debugDocumentVersioning = \"YES\"\n      debugServiceExtension = \"internal\"\n      allowLocationSimulation = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"52049BD22935039F00807AA3\"\n            BuildableName = \"maestro-driver-ios.app\"\n            BlueprintName = \"maestro-driver-ios\"\n            ReferencedContainer = \"container:maestro-driver-ios.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </LaunchAction>\n   <ProfileAction\n      buildConfiguration = \"Release\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      savedToolIdentifier = \"\"\n      useCustomWorkingDirectory = \"NO\"\n      debugDocumentVersioning = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"52049BD22935039F00807AA3\"\n            BuildableName = \"maestro-driver-ios.app\"\n            BlueprintName = \"maestro-driver-ios\"\n            ReferencedContainer = \"container:maestro-driver-ios.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </ProfileAction>\n   <AnalyzeAction\n      buildConfiguration = \"Debug\">\n   </AnalyzeAction>\n   <ArchiveAction\n      buildConfiguration = \"Release\"\n      revealArchiveInOrganizer = \"YES\">\n   </ArchiveAction>\n</Scheme>\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-ios.xcodeproj/xcshareddata/xcschemes/maestro-driver-iosTests.xcscheme",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Scheme\n   LastUpgradeVersion = \"1620\"\n   version = \"1.7\">\n   <BuildAction\n      parallelizeBuildables = \"NO\"\n      buildImplicitDependencies = \"YES\"\n      buildArchitectures = \"Automatic\">\n      <BuildActionEntries>\n         <BuildActionEntry\n            buildForTesting = \"YES\"\n            buildForRunning = \"YES\"\n            buildForProfiling = \"YES\"\n            buildForArchiving = \"YES\"\n            buildForAnalyzing = \"YES\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"52049BD22935039F00807AA3\"\n               BuildableName = \"maestro-driver-ios.app\"\n               BlueprintName = \"maestro-driver-ios\"\n               ReferencedContainer = \"container:maestro-driver-ios.xcodeproj\">\n            </BuildableReference>\n         </BuildActionEntry>\n      </BuildActionEntries>\n   </BuildAction>\n   <TestAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      shouldUseLaunchSchemeArgsEnv = \"NO\"\n      shouldAutocreateTestPlan = \"YES\">\n      <EnvironmentVariables>\n         <EnvironmentVariable\n            key = \"snapshotKeyHonorModalViews\"\n            value = \"false\"\n            isEnabled = \"YES\">\n         </EnvironmentVariable>\n      </EnvironmentVariables>\n      <Testables>\n         <TestableReference\n            skipped = \"NO\"\n            parallelizable = \"NO\">\n            <BuildableReference\n               BuildableIdentifier = \"primary\"\n               BlueprintIdentifier = \"85861EAEA94A3C855AED94AD\"\n               BuildableName = \"maestro-driver-iosTests.xctest\"\n               BlueprintName = \"maestro-driver-iosTests\"\n               ReferencedContainer = \"container:maestro-driver-ios.xcodeproj\">\n            </BuildableReference>\n         </TestableReference>\n      </Testables>\n   </TestAction>\n   <LaunchAction\n      buildConfiguration = \"Debug\"\n      selectedDebuggerIdentifier = \"Xcode.DebuggerFoundation.Debugger.LLDB\"\n      selectedLauncherIdentifier = \"Xcode.DebuggerFoundation.Launcher.LLDB\"\n      launchStyle = \"0\"\n      useCustomWorkingDirectory = \"NO\"\n      ignoresPersistentStateOnLaunch = \"NO\"\n      debugDocumentVersioning = \"YES\"\n      debugServiceExtension = \"internal\"\n      allowLocationSimulation = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"52049BD22935039F00807AA3\"\n            BuildableName = \"maestro-driver-ios.app\"\n            BlueprintName = \"maestro-driver-ios\"\n            ReferencedContainer = \"container:maestro-driver-ios.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </LaunchAction>\n   <ProfileAction\n      buildConfiguration = \"Release\"\n      shouldUseLaunchSchemeArgsEnv = \"YES\"\n      savedToolIdentifier = \"\"\n      useCustomWorkingDirectory = \"NO\"\n      debugDocumentVersioning = \"YES\">\n      <BuildableProductRunnable\n         runnableDebuggingMode = \"0\">\n         <BuildableReference\n            BuildableIdentifier = \"primary\"\n            BlueprintIdentifier = \"52049BD22935039F00807AA3\"\n            BuildableName = \"maestro-driver-ios.app\"\n            BlueprintName = \"maestro-driver-ios\"\n            ReferencedContainer = \"container:maestro-driver-ios.xcodeproj\">\n         </BuildableReference>\n      </BuildableProductRunnable>\n   </ProfileAction>\n   <AnalyzeAction\n      buildConfiguration = \"Debug\">\n   </AnalyzeAction>\n   <ArchiveAction\n      buildConfiguration = \"Release\"\n      revealArchiveInOrganizer = \"YES\">\n   </ArchiveAction>\n</Scheme>\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosTests/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>CFBundleDevelopmentRegion</key>\n\t<string>$(DEVELOPMENT_LANGUAGE)</string>\n\t<key>CFBundleExecutable</key>\n\t<string>$(EXECUTABLE_NAME)</string>\n\t<key>CFBundleIdentifier</key>\n\t<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>\n\t<key>CFBundleInfoDictionaryVersion</key>\n\t<string>6.0</string>\n\t<key>CFBundleName</key>\n\t<string>$(PRODUCT_NAME)</string>\n\t<key>CFBundlePackageType</key>\n\t<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>\n\t<key>CFBundleShortVersionString</key>\n\t<string>1.0</string>\n\t<key>CFBundleVersion</key>\n\t<string>1</string>\n</dict>\n</plist>"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosTests/SnapshotParametersTests.swift",
    "content": "import XCTest\n\n/// Tests to verify XCTest's accessibility snapshot parameters and swizzling behavior.\n/// These tests run as unit tests hosted in the simulator, giving access to XCTest runtime.\n/// The test scheme sets `snapshotKeyHonorModalViews=false` to trigger swizzling.\nfinal class SnapshotParametersTests: XCTestCase {\n\n    /// Tests that the default snapshot request parameters exist with expected values.\n    /// If Apple changes the private API, this test will fail.\n    func testDefaultParametersExist() {\n        let parameterDictionary = AXClientProxy.sharedClient().defaultParameters()\n\n        XCTAssertNotNil(parameterDictionary, \"Parameter dictionary should not be nil - XCAXClient_iOS may have changed\")\n\n        guard let params = parameterDictionary else {\n            return\n        }\n\n        // Verify expected keys exist\n        let expectedKeys = [\"maxChildren\", \"maxDepth\", \"maxArrayCount\", \"traverseFromParentsToChildren\"]\n        for key in expectedKeys {\n            XCTAssertNotNil(params[key], \"Expected parameter key '\\(key)' is missing - Apple may have changed the API\")\n        }\n    }\n\n    /// Tests that the default parameter values match expected XCTest defaults.\n    func testDefaultParameterValues() {\n        guard let params = AXClientProxy.sharedClient().defaultParameters() else {\n            XCTFail(\"Parameter dictionary is nil\")\n            return\n        }\n\n\n        XCTAssertEqual(params[\"maxChildren\"] as? Int, 2147483647,\n                       \"maxChildren default changed - swizzling may need update\")\n        XCTAssertEqual(params[\"maxDepth\"] as? Int, 2147483647,\n                       \"maxDepth default changed - swizzling may need update\")\n        XCTAssertEqual(params[\"maxArrayCount\"] as? Int, 2147483647,\n                       \"maxArrayCount default changed - swizzling may need update\")\n        XCTAssertEqual(params[\"traverseFromParentsToChildren\"] as? Int, 1,\n                       \"traverseFromParentsToChildren default changed - swizzling may need update\")\n    }\n\n    // MARK: - Swizzling Tests\n\n    /// Tests that the custom parameter storage works correctly.\n    /// This verifies FBSetCustomParameterForElementSnapshot was called during +load.\n    func testCustomParameterWasSet() {\n        let customValue = FBGetCustomParameterForElementSnapshot(\"snapshotKeyHonorModalViews\")\n\n        XCTAssertNotNil(customValue,\n            \"Custom parameter 'snapshotKeyHonorModalViews' was not set - swizzling +load may have failed\")\n        XCTAssertEqual(customValue as? NSNumber, NSNumber(value: 0),\n            \"Custom parameter 'snapshotKeyHonorModalViews' should be 0 (false)\")\n    }\n\n    /// Tests that the swizzled defaultParameters includes snapshotKeyHonorModalViews=0.\n    /// This is the critical test that verifies swizzling is working end-to-end.\n    func testSwizzledDefaultParametersIncludesCustomParameter() {\n        guard let params = AXClientProxy.sharedClient().defaultParameters() else {\n            XCTFail(\"Parameter dictionary is nil - XCAXClient_iOS may have changed\")\n            return\n        }\n\n        // After swizzling, defaultParameters should include our custom parameter\n        let snapshotKeyHonorModalViews = params[\"snapshotKeyHonorModalViews\"]\n\n        XCTAssertNotNil(snapshotKeyHonorModalViews,\n            \"snapshotKeyHonorModalViews key is missing from swizzled defaultParameters - \" +\n            \"swizzling may have failed or Apple changed the API\")\n        XCTAssertEqual(snapshotKeyHonorModalViews as? NSNumber, NSNumber(value: 0),\n            \"snapshotKeyHonorModalViews should be 0 (disabled) after swizzling\")\n    }\n\n    /// Tests that XCTElementQuery class exists (required for snapshotParameters swizzling).\n    func testXCTElementQueryClassExists() {\n        let elementQueryClass: AnyClass? = NSClassFromString(\"XCTElementQuery\")\n\n        XCTAssertNotNil(elementQueryClass,\n            \"XCTElementQuery class not found - Apple may have renamed or removed it\")\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosTests/maestro-driver-iosTests-Bridging-Header.h",
    "content": "//\n//  maestro-driver-iosTests-Bridging-Header.h\n//  maestro-driver-iosTests\n//\n\n// Private XCTest headers\n#import \"XCAXClient_iOS.h\"\n#import \"CDStructures.h\"\n#import \"_XCTestImplementation.h\"\n\n// Utilities\n#import \"AXClientProxy.h\"\n#import \"XCAccessibilityElement.h\"\n\n// Swizzling categories\n#import \"XCAXClient_iOS+FBSnapshotReqParams.h\""
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Categories/XCAXClient_iOS+FBSnapshotReqParams.h",
    "content": "/**\n * Copyright (c) 2015-present, Facebook, Inc.\n * All rights reserved.\n *\n * This source code is licensed under the BSD-style license found in the\n * LICENSE file in the root directory of this source tree. An additional grant\n * of patent rights can be found in the PATENTS file in the same directory.\n */\n\n#import <XCTest/XCTest.h>\n\n#import \"XCAXClient_iOS.h\"\n\nNS_ASSUME_NONNULL_BEGIN\n\nextern NSString *const FBSnapshotMaxDepthKey;\n\nvoid FBSetCustomParameterForElementSnapshot (NSString* name, id value);\n\nid __nullable FBGetCustomParameterForElementSnapshot (NSString *name);\n\n@interface XCAXClient_iOS (FBSnapshotReqParams)\n\n@end\n\nNS_ASSUME_NONNULL_END\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Categories/XCAXClient_iOS+FBSnapshotReqParams.m",
    "content": "/**\n * Copyright (c) 2015-present, Facebook, Inc.\n * All rights reserved.\n *\n * This source code is licensed under the BSD-style license found in the\n * LICENSE file in the root directory of this source tree. An additional grant\n * of patent rights can be found in the PATENTS file in the same directory.\n */\n\n#import \"XCAXClient_iOS+FBSnapshotReqParams.h\"\n\n#import <objc/runtime.h>\n\n/**\n Available parameters with their default values for XCTest:\n  @\"maxChildren\" : (int)2147483647\n  @\"traverseFromParentsToChildren\" : YES\n  @\"maxArrayCount\" : (int)2147483647\n  @\"snapshotKeyHonorModalViews\" : NO\n  @\"maxDepth\" : (int)2147483647\n */\nNSString *const FBSnapshotMaxDepthKey = @\"maxDepth\";\n\nstatic id (*original_defaultParameters)(id, SEL);\nstatic id (*original_snapshotParameters)(id, SEL);\nstatic NSDictionary *defaultRequestParameters;\nstatic NSDictionary *defaultAdditionalRequestParameters;\nstatic NSMutableDictionary *customRequestParameters;\n\nvoid FBSetCustomParameterForElementSnapshot(NSString *name, id value) {\n    static dispatch_once_t onceToken;\n    dispatch_once(&onceToken, ^{\n      customRequestParameters = [NSMutableDictionary new];\n    });\n    customRequestParameters[name] = value;\n}\n\nid FBGetCustomParameterForElementSnapshot(NSString *name) {\n    return customRequestParameters[name];\n}\n\nstatic id swizzledDefaultParameters(id self, SEL _cmd) {\n    static dispatch_once_t onceToken;\n    dispatch_once(&onceToken, ^{\n      defaultRequestParameters = original_defaultParameters(self, _cmd);\n    });\n    NSMutableDictionary *result =\n        [NSMutableDictionary dictionaryWithDictionary:defaultRequestParameters];\n    [result addEntriesFromDictionary:defaultAdditionalRequestParameters ?: @{}];\n    [result addEntriesFromDictionary:customRequestParameters ?: @{}];\n    return result.copy;\n}\n\nstatic id swizzledSnapshotParameters(id self, SEL _cmd) {\n    NSDictionary *result = original_snapshotParameters(self, _cmd);\n    defaultAdditionalRequestParameters = result;\n    return result;\n}\n\n@implementation XCAXClient_iOS (FBSnapshotReqParams)\n\n#pragma clang diagnostic push\n#pragma clang diagnostic ignored \"-Wobjc-load-method\"\n#pragma clang diagnostic ignored \"-Wcast-function-type-strict\"\n\n+ (void)load {\n    // snapshotKeyHonorModalViews to false to make modals and dialogs visible that are invisible otherwise\n    NSString *snapshotKeyHonorModalViewsKey = [[NSProcessInfo processInfo] environment][@\"snapshotKeyHonorModalViews\"];\n    NSLog(@\"snapshotKeyHonorModalViews configured to value: %@\", snapshotKeyHonorModalViewsKey);\n    if ([snapshotKeyHonorModalViewsKey isEqualToString:@\"false\"]) {\n        NSLog(@\"Disabling snapshotKeyHonorModalViews to make elements behind modals visible\");\n        FBSetCustomParameterForElementSnapshot(@\"snapshotKeyHonorModalViews\", @0);\n\n        // Swizzle defaultParameters on XCAXClient_iOS\n        Method original_defaultParametersMethod =\n            class_getInstanceMethod(self.class, @selector(defaultParameters));\n        if (original_defaultParametersMethod == NULL) {\n            NSLog(@\"[ERROR] Swizzling failed: Could not find method 'defaultParameters' on XCAXClient_iOS. \"\n                  \"Apple may have changed the private API in this OS version.\");\n        } else {\n            IMP swizzledDefaultParametersImp = (IMP)swizzledDefaultParameters;\n            original_defaultParameters = (id(*)(id, SEL))method_setImplementation(original_defaultParametersMethod, swizzledDefaultParametersImp);\n            if (original_defaultParameters == NULL) {\n                NSLog(@\"[ERROR] Swizzling failed: method_setImplementation returned NULL for 'defaultParameters'.\");\n            } else {\n                NSLog(@\"Successfully swizzled 'defaultParameters' on XCAXClient_iOS\");\n            }\n        }\n\n        // Swizzle snapshotParameters on XCTElementQuery\n        Class elementQueryClass = NSClassFromString(@\"XCTElementQuery\");\n        if (elementQueryClass == nil) {\n            NSLog(@\"[ERROR] Swizzling failed: Could not find class 'XCTElementQuery'. \"\n                  \"Apple may have changed the private API in this OS version.\");\n        } else {\n            Method original_snapshotParametersMethod = class_getInstanceMethod(elementQueryClass, NSSelectorFromString(@\"snapshotParameters\"));\n            if (original_snapshotParametersMethod == NULL) {\n                NSLog(@\"[ERROR] Swizzling failed: Could not find method 'snapshotParameters' on XCTElementQuery. \"\n                      \"Apple may have changed the private API in this OS version.\");\n            } else {\n                IMP swizzledSnapshotParametersImp = (IMP)swizzledSnapshotParameters;\n                original_snapshotParameters = (id(*)(id, SEL))method_setImplementation(original_snapshotParametersMethod, swizzledSnapshotParametersImp);\n                if (original_snapshotParameters == NULL) {\n                    NSLog(@\"[ERROR] Swizzling failed: method_setImplementation returned NULL for 'snapshotParameters'.\");\n                } else {\n                    NSLog(@\"Successfully swizzled 'snapshotParameters' on XCTElementQuery\");\n                }\n            }\n        }\n    }\n}\n\n#pragma clang diagnostic pop\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Categories/XCUIApplication+FBQuiescence.h",
    "content": "/**\n * Copyright (c) 2015-present, Facebook, Inc.\n * All rights reserved.\n *\n * This source code is licensed under the BSD-style license found in the\n * LICENSE file in the root directory of this source tree. An additional grant\n * of patent rights can be found in the PATENTS file in the same directory.\n */\n\n#import <XCTest/XCTest.h>\n#import \"XCUIApplication.h\"\n\nNS_ASSUME_NONNULL_BEGIN\n\n@interface XCUIApplication (FBQuiescence)\n\n/**\n It allows to turn on/off waiting for application quiescence, while performing queries. Defaults to YES.\n This value mirrors the corresponding property of the connected XCUIApplicationProcess instance.\n */\n@property (nonatomic, assign) BOOL fb_shouldWaitForQuiescence;\n\n@end\n\nNS_ASSUME_NONNULL_END\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Categories/XCUIApplication+FBQuiescence.m",
    "content": "/**\n * Copyright (c) 2015-present, Facebook, Inc.\n * All rights reserved.\n *\n * This source code is licensed under the BSD-style license found in the\n * LICENSE file in the root directory of this source tree. An additional grant\n * of patent rights can be found in the PATENTS file in the same directory.\n */\n\n#import \"XCUIApplication+FBQuiescence.h\"\n\n#import \"XCUIApplicationImpl.h\"\n#import \"XCUIApplicationProcess.h\"\n#import \"XCUIApplicationProcess+FBQuiescence.h\"\n\n\n@implementation XCUIApplication (FBQuiescence)\n\n- (BOOL)fb_shouldWaitForQuiescence\n{\n  return [[self applicationImpl] currentProcess].fb_shouldWaitForQuiescence.boolValue;\n}\n\n- (void)setFb_shouldWaitForQuiescence:(BOOL)value\n{\n  [[self applicationImpl] currentProcess].fb_shouldWaitForQuiescence = @(value);\n}\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Categories/XCUIApplication+Helper.h",
    "content": "#import <XCTest/XCTest.h>\n\n@interface XCUIApplication (Helper)\n\n+ (NSArray<NSDictionary<NSString *, id> *> *)activeAppsInfo;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Categories/XCUIApplication+Helper.m",
    "content": "#import \"XCUIApplication+Helper.h\"\n#import \"AXClientProxy.h\"\n#import \"FBLogger.h\"\n#import \"XCTestDaemonsProxy.h\"\n#import \"XCAccessibilityElement.h\"\n#import \"XCTestManager_ManagerInterface-Protocol.h\"\n\n@implementation XCUIApplication (Helper)\n\n+ (NSArray<NSDictionary<NSString *, id> *> *)appsInfoWithAxElements:(NSArray<id<XCAccessibilityElement>> *)axElements\n{\n    NSMutableArray<NSDictionary<NSString *, id> *> *result = [NSMutableArray array];\n    id<XCTestManager_ManagerInterface> proxy = [XCTestDaemonsProxy testRunnerProxy];\n    for (id<XCAccessibilityElement> axElement in axElements) {\n        NSMutableDictionary<NSString *, id> *appInfo = [NSMutableDictionary dictionary];\n        pid_t pid = axElement.processIdentifier;\n        appInfo[@\"pid\"] = @(pid);\n        __block NSString *bundleId = nil;\n        dispatch_semaphore_t sem = dispatch_semaphore_create(0);\n        [proxy _XCT_requestBundleIDForPID:pid\n                                    reply:^(NSString *bundleID, NSError *error) {\n            if (nil == error) {\n                bundleId = bundleID;\n            } else {\n                [FBLogger logFmt:@\"Cannot request the bundle ID for process ID %@: %@\", @(pid), error.description];\n            }\n            dispatch_semaphore_signal(sem);\n        }];\n        dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)));\n        appInfo[@\"bundleId\"] = bundleId ?: @\"unknowBundleId\";\n        [result addObject:appInfo.copy];\n    }\n    return result.copy;\n}\n\n+ (NSArray<NSDictionary<NSString *, id> *> *)activeAppsInfo\n{\n    return [self appsInfoWithAxElements:[AXClientProxy.sharedClient activeApplications]];\n}\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Categories/XCUIApplicationProcess+FBQuiescence.h",
    "content": "/**\n * Copyright (c) 2015-present, Facebook, Inc.\n * All rights reserved.\n *\n * This source code is licensed under the BSD-style license found in the\n * LICENSE file in the root directory of this source tree. An additional grant\n * of patent rights can be found in the PATENTS file in the same directory.\n */\n\n#import <XCTest/XCTest.h>\n\n#import \"XCUIApplicationProcess.h\"\n\nNS_ASSUME_NONNULL_BEGIN\n\n@interface XCUIApplicationProcess (FBQuiescence)\n\n/*! Defines wtether the process should perform quiescence checks. YES by default */\n@property (nonatomic) NSNumber* fb_shouldWaitForQuiescence;\n\n/**\n @param waitForAnimations Set it to YES if XCTest should also wait for application animations to complete\n */\n- (void)fb_waitForQuiescenceIncludingAnimationsIdle:(bool)waitForAnimations;\n\n@end\n\nNS_ASSUME_NONNULL_END\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Categories/XCUIApplicationProcess+FBQuiescence.m",
    "content": "#import \"XCUIApplicationProcess+FBQuiescence.h\"\n\n#import <objc/runtime.h>\n\n#import \"FBConfiguration.h\"\n#import \"FBLogger.h\"\n\nstatic void (*original_waitForQuiescenceIncludingAnimationsIdle)(id, SEL, BOOL);\nstatic void (*original_waitForQuiescenceIncludingAnimationsIdlePreEvent)(id, SEL, BOOL, BOOL);\n\nstatic void swizzledWaitForQuiescenceIncludingAnimationsIdle(id self, SEL _cmd, BOOL includingAnimations)\n{\n  NSString *bundleId = [self bundleID];\n  if (![[self fb_shouldWaitForQuiescence] boolValue] || FBConfiguration.waitForIdleTimeout < DBL_EPSILON) {\n    [FBLogger logFmt:@\"Quiescence checks are disabled for %@ application. Making it to believe it is idling\",\n     bundleId];\n    return;\n  }\n\n  NSTimeInterval desiredTimeout = FBConfiguration.waitForIdleTimeout;\n  NSTimeInterval previousTimeout = _XCTApplicationStateTimeout();\n  _XCTSetApplicationStateTimeout(desiredTimeout);\n  [FBLogger logFmt:@\"Waiting up to %@s until %@ is in idle state (%@ animations)\",\n   @(desiredTimeout), bundleId, includingAnimations ? @\"including\" : @\"excluding\"];\n  @try {\n    original_waitForQuiescenceIncludingAnimationsIdle(self, _cmd, includingAnimations);\n  } @finally {\n    _XCTSetApplicationStateTimeout(previousTimeout);\n  }\n}\n\nstatic void swizzledWaitForQuiescenceIncludingAnimationsIdlePreEvent(id self, SEL _cmd, BOOL includingAnimations, BOOL isPreEvent)\n{\n  NSString *bundleId = [self bundleID];\n  if (![[self fb_shouldWaitForQuiescence] boolValue] || FBConfiguration.waitForIdleTimeout < DBL_EPSILON) {\n    [FBLogger logFmt:@\"Quiescence checks are disabled for %@ application. Making it to believe it is idling\",\n     bundleId];\n    return;\n  }\n\n  NSTimeInterval desiredTimeout = FBConfiguration.waitForIdleTimeout;\n  NSTimeInterval previousTimeout = _XCTApplicationStateTimeout();\n  _XCTSetApplicationStateTimeout(desiredTimeout);\n  [FBLogger logFmt:@\"Waiting up to %@s until %@ is in idle state (%@ animations)\",\n   @(desiredTimeout), bundleId, includingAnimations ? @\"including\" : @\"excluding\"];\n  @try {\n    original_waitForQuiescenceIncludingAnimationsIdlePreEvent(self, _cmd, includingAnimations, isPreEvent);\n  } @finally {\n    _XCTSetApplicationStateTimeout(previousTimeout);\n  }\n}\n\n@implementation XCUIApplicationProcess (FBQuiescence)\n\n#pragma clang diagnostic push\n#pragma clang diagnostic ignored \"-Wobjc-load-method\"\n#pragma clang diagnostic ignored \"-Wcast-function-type-strict\"\n\n+ (void)load\n{\n  Method waitForQuiescenceIncludingAnimationsIdleMethod = class_getInstanceMethod(self.class, @selector(waitForQuiescenceIncludingAnimationsIdle:));\n  Method waitForQuiescenceIncludingAnimationsIdlePreEventMethod = class_getInstanceMethod(self.class, @selector(waitForQuiescenceIncludingAnimationsIdle:isPreEvent:));\n  if (nil != waitForQuiescenceIncludingAnimationsIdleMethod) {\n    IMP swizzledImp = (IMP)swizzledWaitForQuiescenceIncludingAnimationsIdle;\n    original_waitForQuiescenceIncludingAnimationsIdle = (void (*)(id, SEL, BOOL)) method_setImplementation(waitForQuiescenceIncludingAnimationsIdleMethod, swizzledImp);\n  } else if (nil != waitForQuiescenceIncludingAnimationsIdlePreEventMethod) {\n    IMP swizzledImp = (IMP)swizzledWaitForQuiescenceIncludingAnimationsIdlePreEvent;\n    original_waitForQuiescenceIncludingAnimationsIdlePreEvent = (void (*)(id, SEL, BOOL, BOOL)) method_setImplementation(waitForQuiescenceIncludingAnimationsIdlePreEventMethod, swizzledImp);\n  } else {\n    [FBLogger log:@\"Could not find method -[XCUIApplicationProcess waitForQuiescenceIncludingAnimationsIdle:]\"];\n  }\n}\n\n#pragma clang diagnostic pop\n\nstatic char XCUIAPPLICATIONPROCESS_SHOULD_WAIT_FOR_QUIESCENCE;\n\n@dynamic fb_shouldWaitForQuiescence;\n\n- (NSNumber *)fb_shouldWaitForQuiescence\n{\n  id result = objc_getAssociatedObject(self, &XCUIAPPLICATIONPROCESS_SHOULD_WAIT_FOR_QUIESCENCE);\n  if (nil == result) {\n    return @(YES);\n  }\n  return (NSNumber *)result;\n}\n\n- (void)setFb_shouldWaitForQuiescence:(NSNumber *)value\n{\n  objc_setAssociatedObject(self, &XCUIAPPLICATIONPROCESS_SHOULD_WAIT_FOR_QUIESCENCE, value, OBJC_ASSOCIATION_RETAIN_NONATOMIC);\n}\n\n- (void)fb_waitForQuiescenceIncludingAnimationsIdle:(bool)waitForAnimations\n{\n  if ([self respondsToSelector:@selector(waitForQuiescenceIncludingAnimationsIdle:)]) {\n    [self waitForQuiescenceIncludingAnimationsIdle:waitForAnimations];\n  } else if ([self respondsToSelector:@selector(waitForQuiescenceIncludingAnimationsIdle:isPreEvent:)]) {\n    [self waitForQuiescenceIncludingAnimationsIdle:waitForAnimations isPreEvent:NO];\n  } else {\n      @throw [NSException exceptionWithName: @\"NoApiFound\" reason:@\"The current driver build is not compatible to your device OS version\" userInfo:@{}];\n  }\n}\n\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Categories/maestro-driver-iosUITests-Bridging-Header.h",
    "content": "//\n//  Use this file to import your target's public headers that you would like to expose to Swift.\n//\n\n#import \"XCUIApplication+FBQuiescence.h\"\n#import \"XCUIApplication+Helper.h\"\n#import \"XCAXClient_iOS+FBSnapshotReqParams.h\"\n#import \"AXClientProxy.h\"\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/CDStructures.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#pragma mark Blocks\n\ntypedef void (^CDUnknownBlockType)(void); // return type and parameters are unknown\n\ntypedef struct {\n    unsigned int _field1;\n    unsigned int _field2;\n    unsigned int _field3;\n    unsigned int _field4;\n    unsigned int _field5;\n    unsigned int _field6;\n    unsigned int _field7;\n} CDStruct_a561fd19;\n\ntypedef struct {\n    unsigned short _field1;\n    unsigned short _field2;\n    unsigned short _field3[1];\n} CDStruct_27a325c0;\n\nint _XCTSetApplicationStateTimeout(double timeout);\ndouble _XCTApplicationStateTimeout(void);\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/NSString-XCTAdditions.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <Foundation/Foundation.h>\n\n@interface NSString (XCTAdditions)\n- (id)xct_quotedSwiftStringRepresentation;\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/NSValue-XCTestAdditions.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <Foundation/Foundation.h>\n\n@interface NSValue (XCTestAdditions)\n- (id)xct_contentDescription;\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/UIGestureRecognizer-RecordingAdditions.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <UIKit/UIGestureRecognizer.h>\n\n@interface UIGestureRecognizer (RecordingAdditions)\n- (id)_automationName;\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/UILongPressGestureRecognizer-RecordingAdditions.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <UIKit/UILongPressGestureRecognizer.h>\n\n@interface UILongPressGestureRecognizer (RecordingAdditions)\n- (id)_automationName;\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/UIPanGestureRecognizer-RecordingAdditions.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <UIKit/UIPanGestureRecognizer.h>\n\n@interface UIPanGestureRecognizer (RecordingAdditions)\n- (id)_automationName;\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/UIPinchGestureRecognizer-RecordingAdditions.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <UIKit/UIPinchGestureRecognizer.h>\n\n@interface UIPinchGestureRecognizer (RecordingAdditions)\n- (id)_automationName;\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/UISwipeGestureRecognizer-RecordingAdditions.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <UIKit/UISwipeGestureRecognizer.h>\n\n@interface UISwipeGestureRecognizer (RecordingAdditions)\n- (id)_automationName;\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/UITapGestureRecognizer-RecordingAdditions.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <UIKit/UITapGestureRecognizer.h>\n\n@interface UITapGestureRecognizer (RecordingAdditions)\n- (id)_automationName;\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCAXClient_iOS.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <CDStructures.h>\n#import <CoreGraphics/CoreGraphics.h>\n\n@class NSMutableDictionary;\n\n@interface XCAXClient_iOS : NSObject\n{\n    NSMutableDictionary *_userTestingNotificationHandlers;\n    NSMutableDictionary *_cacheAccessibilityLoadedValuesForPIDs;\n    unsigned long long *_alertNotificationCounter;\n}\n@property double AXTimeout;\n\n// Added since Xcode 10.2\n@property(readonly) id applicationProcessTracker;\n\n- (BOOL)_setAXTimeout:(double)arg1 error:(NSError **)arg2;\n- (NSData *)screenshotData;\n- (BOOL)performAction:(int)arg1 onElement:(id)arg2 value:(id)arg3 error:(id *)arg4;\n- (id)parameterizedAttributeForElement:(id)arg1 attribute:(id)arg2 parameter:(id)arg3;\n- (BOOL)setAttribute:(id)arg1 value:(id)arg2 element:(id)arg3 outError:(id *)arg4;\n// since Xcode10\n- (id)attributesForElement:(id)arg1 attributes:(id)arg2 error:(id *)arg3;\n- (id)attributesForElementSnapshot:(id)arg1 attributeList:(id)arg2;\n- (id)snapshotForApplication:(id)arg1 attributeList:(id)arg2 parameters:(id)arg3;\n- (id)defaultParameters;\n- (id)defaultAttributes;\n- (void)notifyWhenViewControllerViewDidDisappearReply:(CDUnknownBlockType)arg1;\n- (void)notifyWhenViewControllerViewDidAppearReply:(CDUnknownBlockType)arg1;\n- (void)notifyWhenNoAnimationsAreActiveForApplication:(id)arg1 reply:(CDUnknownBlockType)arg2;\n- (void)notifyWhenEventLoopIsIdleForApplication:(id)arg1 reply:(CDUnknownBlockType)arg2;\n- (id)interruptingUIElementAffectingSnapshot:(id)arg1;\n- (void)handleAccessibilityNotification:(int)arg1 withPayload:(id)arg2;\n- (void)notifyOnNextOccurrenceOfUserTestingEvent:(id)arg1 handler:(CDUnknownBlockType)arg2;\n- (void)handleUserTestingNotification:(id)arg1;\n- (id)elementAtPoint:(CGPoint)arg1 error:(id *)arg2;\n- (BOOL)cachedAccessibilityLoadedValueForPID:(int)arg1;\n- (NSArray/*XCAccessibilityElement*/ *)activeApplications;\n- (id)systemApplication;\n- (BOOL)enableFauxCollectionViewCells:(id *)arg1;\n- (BOOL)loadAccessibility:(id *)arg1;\n- (BOOL)_registerForAXNotification:(int)arg1 error:(id *)arg2;\n- (BOOL)_loadAccessibility:(id *)arg1;\n// Since Xcode 11\n- (id)requestSnapshotForElement:(id/*XCAccessibilityElement*/)arg1 attributes:(id)arg2 parameters:(id)arg3 error:(NSError **)arg4;\n\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCActivityRecord.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSArray, NSData, NSDate, NSString, NSUUID, XCSynthesizedEventRecord;\n\n@interface XCActivityRecord : NSObject <NSSecureCoding>\n{\n    NSString *_title;\n    NSUUID *_uuid;\n    NSDate *_start;\n    NSDate *_finish;\n    BOOL _hasSubactivities;\n    NSData *_screenImageData;\n    id/*XCElementSnapshot*/ _snapshot;\n    NSArray *_elementsOfInterest;\n    XCSynthesizedEventRecord *_synthesizedEvent;\n    NSData *_diagnosticReportData;\n    NSData *_memoryGraphData;\n}\n\n@property(copy) NSData *memoryGraphData; // @synthesize memoryGraphData=_memoryGraphData;\n@property(copy) NSData *diagnosticReportData; // @synthesize diagnosticReportData=_diagnosticReportData;\n@property(retain) XCSynthesizedEventRecord *synthesizedEvent; // @synthesize synthesizedEvent=_synthesizedEvent;\n@property(copy) NSArray *elementsOfInterest; // @synthesize elementsOfInterest=_elementsOfInterest;\n@property(retain) id/*XCElementSnapshot*/ *snapshot; // @synthesize snapshot=_snapshot;\n@property(copy) NSData *screenImageData; // @synthesize screenImageData=_screenImageData;\n@property BOOL hasSubactivities; // @synthesize hasSubactivities=_hasSubactivities;\n@property(copy) NSDate *start; // @synthesize start=_start;\n@property(copy) NSDate *finish; // @synthesize finish=_finish;\n@property(copy) NSUUID *uuid; // @synthesize uuid=_uuid;\n@property(copy) NSString *title; // @synthesize title=_title;\n@property(readonly) double duration;\n\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCApplicationMonitor.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"XCTestObservation.h\"\n\n@class NSArray, NSMutableDictionary, NSObject<OS_dispatch_queue>, NSString;\n\n@interface XCApplicationMonitor : NSObject <XCTUIApplicationMonitor>\n{\n    NSMutableDictionary *_applicationImplementations;\n    NSMutableDictionary *_applicationProcessesForPID;\n    NSMutableDictionary *_applicationProcessesForToken;\n    NSMutableSet *_launchedApplications;\n    NSObject<OS_dispatch_queue> *_queue;\n}\n@property NSObject<OS_dispatch_queue> *queue; // @synthesize queue=_queue;\n@property(readonly, copy) NSArray *monitoredApplications;\n\n+ (instancetype)sharedMonitor;\n- (void)requestAutomationSessionForTestTargetWithPID:(int)arg1 reply:(CDUnknownBlockType)arg2;\n- (void)processWithToken:(id)arg1 exitedWithStatus:(int)arg2;\n- (void)stopTrackingProcessWithToken:(id)arg1;\n- (void)crashInProcessWithBundleID:(id)arg1 path:(id)arg2 pid:(int)arg3 symbol:(id)arg4;\n- (void)waitForUnrequestedTerminationOfLaunchedApplicationsWithTimeout:(double)arg1;\n- (void)_waitForCrashReportOrCleanExitStatusOfApp:(id)arg1;\n- (id)_appFromSet:(id)arg1 thatTransitionedToNotRunningDuringTimeInterval:(double)arg2;\n- (void)terminationTrackedForApplicationProcess:(id)arg1;\n- (void)launchRequestedForApplicationProcess:(id)arg1;\n- (void)_terminateApplicationProcess:(id)arg1;\n- (void)terminateApplicationProcess:(id)arg1 withToken:(id)arg2;\n- (id)monitoredApplicationWithProcessIdentifier:(int)arg1;\n- (void)applicationWithBundleID:(id)arg1 didUpdatePID:(int)arg2 state:(unsigned long long)arg3;\n- (void)_beginMonitoringApplication:(id)arg1;\n- (void)setApplicationProcess:(id)arg1 forToken:(id)arg2;\n- (id)applicationProcessWithToken:(id)arg1;\n- (void)setApplicationProcess:(id)arg1 forPID:(int)arg2;\n- (id)applicationProcessWithPID:(int)arg1;\n- (id)applicationImplementationForApplicationAtPath:(id)arg1 bundleID:(id)arg2;\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCApplicationMonitor_iOS.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCApplicationMonitor.h>\n\n@interface XCApplicationMonitor_iOS : XCApplicationMonitor\n{\n}\n\n- (void)_terminateApplicationProcess:(id)arg1;\n- (id)monitoredApplicationWithProcessIdentifier:(int)arg1;\n- (void)_beginMonitoringApplication:(id)arg1;\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCApplicationQuery.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCUIElementQuery.h>\n\n@class XCUIApplication;\n\n@interface XCApplicationQuery : XCUIElementQuery\n{\n    XCUIApplication *_application;\n    id/*XCElementSnapshot*/ _lastSnapshot;\n}\n\n@property(retain) id/*XCElementSnapshot*/ lastSnapshot; // @synthesize lastSnapshot=_lastSnapshot;\n- (id)matchingSnapshotsWithError:(id *)arg1;\n- (id)application;\n- (id)initWithApplication:(id)arg1;\n\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCDebugLogDelegate-Protocol.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSString;\n\n@protocol XCDebugLogDelegate <NSObject>\n- (void)logDebugMessage:(NSString *)arg1;\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCEventGenerator.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <TargetConditionals.h>\n\n#import <UIKit/UIKit.h>\n\n#import <CDStructures.h>\n\n@class XCSynthesizedEventRecord;\n\ntypedef void (^XCEventGeneratorHandler)(XCSynthesizedEventRecord *record, NSError *error);\n\n@interface XCEventGenerator : NSObject\n{\n  NSObject<OS_dispatch_queue> *_eventQueue;\n  struct __CFRunLoopObserver *_generationObserver;\n  unsigned long long _generation;\n}\n\n+ (id)sharedGenerator;\n@property unsigned long long generation; // @synthesize generation=_generation;\n//@property(readonly) NSObject<OS_dispatch_queue> *eventQueue; // @synthesize eventQueue=_eventQueue;\n\n#if TARGET_OS_TV\n// TODO: tvOS-specific headers\n\n#elif TARGET_OS_IPHONE\n- (double)rotateInRect:(CGRect)arg1 withRotation:(double)arg2 velocity:(double)arg3 orientation:(UIInterfaceOrientation)arg4 handler:(XCEventGeneratorHandler)arg5;\n- (double)pinchInRect:(CGRect)arg1 withScale:(double)arg2 velocity:(double)arg3 orientation:(UIInterfaceOrientation)arg4 handler:(XCEventGeneratorHandler)arg5;\n- (double)pressAtPoint:(CGPoint)arg1 forDuration:(double)arg2 liftAtPoint:(CGPoint)arg3 velocity:(double)arg4 orientation:(UIInterfaceOrientation)arg5 name:(NSString *)arg6 handler:(XCEventGeneratorHandler)arg7;\n- (double)pressAtPoint:(CGPoint)arg1 forDuration:(double)arg2 orientation:(UIInterfaceOrientation)arg3 handler:(XCEventGeneratorHandler)arg4;\n\n// iOS 9.x specific, gone in iOS 10.3\n- (double)tapWithNumberOfTaps:(unsigned long long)arg1 numberOfTouches:(unsigned long long)arg2 inRect:(CGRect)arg3 orientation:(UIInterfaceOrientation)arg4 handler:(XCEventGeneratorHandler)arg5;\n- (double)twoFingerTapInRect:(CGRect)arg1 orientation:(UIInterfaceOrientation)arg2 handler:(XCEventGeneratorHandler)arg3;\n- (double)doubleTapAtPoint:(CGPoint)arg1 orientation:(UIInterfaceOrientation)arg2 handler:(XCEventGeneratorHandler)arg3;\n- (double)tapAtPoint:(CGPoint)arg1 orientation:(UIInterfaceOrientation)arg2 handler:(XCEventGeneratorHandler)arg3;\n\n// iOS 10.x specific\n- (double)tapAtTouchLocations:(NSArray *)locations numberOfTaps:(NSInteger)numberOfTaps orientation:(UIInterfaceOrientation)orientation handler:(XCEventGeneratorHandler)handler;\n\n// iOS 10.3 specific\n- (double)forcePressAtPoint:(struct CGPoint)arg1 orientation:(long long)arg2 handler:(CDUnknownBlockType)arg3;\n\n#elif TARGET_OS_MAC\n- (double)sendKeyboardInputs:(id)arg1 layout:(id)arg2 handler:(CDUnknownBlockType)arg3;\n- (double)sendKey:(id)arg1 modifierFlags:(unsigned long long)arg2 handler:(CDUnknownBlockType)arg3;\n- (double)sendString:(id)arg1 handler:(CDUnknownBlockType)arg2;\n- (double)setModifiers:(unsigned long long)arg1 merge:(BOOL)arg2 original:(unsigned long long *)arg3 handler:(CDUnknownBlockType)arg4;\n- (double)sendKey:(unsigned short)arg1 down:(BOOL)arg2 modifiers:(unsigned long long)arg3 string:(id)arg4 handler:(CDUnknownBlockType)arg5;\n- (double)hitKey:(unsigned short)arg1 handler:(CDUnknownBlockType)arg2;\n- (double)scrollByX:(double)arg1 y:(double)arg2 handler:(CDUnknownBlockType)arg3;\n- (double)clickAtPoint:(CGPoint)arg1 forDuration:(double)arg2 releaseAtPoint:(CGPoint)arg3 velocity:(double)arg4 handler:(CDUnknownBlockType)arg5;\n- (double)clickAndDragFromPoint:(CGPoint)arg1 toPoint:(CGPoint)arg2 handler:(CDUnknownBlockType)arg3;\n- (double)rightClickAtPoint:(CGPoint)arg1 handler:(CDUnknownBlockType)arg2;\n- (double)doubleClickAtPoint:(CGPoint)arg1 handler:(CDUnknownBlockType)arg2;\n- (double)clickAtPoint:(CGPoint)arg1 handler:(CDUnknownBlockType)arg2;\n- (double)hoverAtPoint:(CGPoint)arg1 handler:(CDUnknownBlockType)arg2;\n- (CGPoint)_currentMousePosition;\n- (void)_clickMouseButton:(unsigned int)arg1 withCount:(unsigned long long)arg2 atPoint:(CGPoint)arg3 handleCompletion:(CDUnknownBlockType)arg4;\n- (void)_moveMouseToPoint:(CGPoint)arg1 handleCompletion:(CDUnknownBlockType)arg2;\n- (void)_postCGEvent:(struct __CGEvent *)arg1 handleCompletion:(CDUnknownBlockType)arg2;\n#endif\n\n- (void)_startEventSequenceWithSteppingCallback:(CDUnknownBlockType)arg1;\n- (void)_scheduleCallback:(CDUnknownBlockType)arg1 afterInterval:(double)arg2;\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCKeyMappingPath.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSSet, NSString;\n\n@interface XCKeyMappingPath : NSObject <NSCopying>\n{\n    unsigned long long _keyState;\n    XCKeyMappingPath *_next;\n    NSSet *_inputs;\n    NSString *_output;\n    unsigned long long _length;\n    NSString *_producedString;\n}\n@property(readonly, copy) NSString *producedString; // @synthesize producedString=_producedString;\n@property(readonly) unsigned long long length; // @synthesize length=_length;\n@property(readonly, copy) NSString *output; // @synthesize output=_output;\n@property(readonly, copy) NSSet *inputs; // @synthesize inputs=_inputs;\n@property(readonly, copy) XCKeyMappingPath *next; // @synthesize next=_next;\n@property(readonly) unsigned long long keyState; // @synthesize keyState=_keyState;\n@property(readonly, getter=isEmpty) BOOL empty;\n@property(readonly, getter=isComplete) BOOL complete;\n\n+ (id)pathWithKeyState:(unsigned long long)arg1 next:(id)arg2 inputs:(id)arg3 output:(id)arg4;\n+ (id)emptyPath;\n\n- (id)inputSequenceWithRequiredFlags:(unsigned long long)arg1 excludedFlags:(unsigned long long)arg2;\n- (id)inputWithRequiredFlags:(unsigned long long)arg1 excludedFlags:(unsigned long long)arg2;\n\n- (id)initWithKeyState:(unsigned long long)arg1 next:(id)arg2 inputs:(id)arg3 output:(id)arg4;\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCKeyboardInputSolver.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSArray, NSMutableArray, NSMutableDictionary, NSString, XCKeyboardKeyMap;\n\n@interface XCKeyboardInputSolver : NSObject <NSCopying>\n{\n    XCKeyboardKeyMap *_keyMap;\n    NSString *_string;\n    unsigned long long _requiredFlags;\n    unsigned long long _excludedFlags;\n    unsigned long long _currentFlags;\n    BOOL _includeModifierKeys;\n    struct _NSRange _unsolvedRange;\n    NSMutableArray *_solvedInputs;\n    NSMutableDictionary *_solvingPaths;\n}\n\n@property(readonly) NSArray *solvedInputs; // @synthesize solvedInputs=_solvedInputs;\n@property(readonly) struct _NSRange unsolvedRange; // @synthesize unsolvedRange=_unsolvedRange;\n@property BOOL includeModifierKeys; // @synthesize includeModifierKeys=_includeModifierKeys;\n@property unsigned long long currentFlags; // @synthesize currentFlags=_currentFlags;\n@property unsigned long long excludedFlags; // @synthesize excludedFlags=_excludedFlags;\n@property unsigned long long requiredFlags; // @synthesize requiredFlags=_requiredFlags;\n@property(readonly, copy) NSString *string; // @synthesize string=_string;\n@property(readonly) XCKeyboardKeyMap *keyMap; // @synthesize keyMap=_keyMap;\n@property(readonly, getter=isComplete) BOOL complete;\n\n- (id)_solve;\n- (id)solve;\n- (void)solveWithSolutionRange:(struct _NSRange)arg1 results:(id)arg2;\n- (id)extractCompletePathsWithSolutionRange:(struct _NSRange)arg1;\n- (unsigned long long)advancePaths;\n- (void)advancePath:(id)arg1 range:(id)arg2;\n- (id)initWithKeyMap:(id)arg1 string:(id)arg2;\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCKeyboardKeyMap.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n\n\n@class NSCharacterSet, NSData, NSDictionary, NSIndexSet, NSSet;\n\n@interface XCKeyboardKeyMap : NSObject\n{\n    struct __GSKeyboard *_inputSource;\n    NSData *_layoutData;\n    const struct {\n        unsigned short _field1;\n        unsigned short _field2;\n        unsigned int _field3;\n        unsigned int _field4;\n        CDStruct_a561fd19 _field5[1];\n    } *_layoutHeader;\n    const CDStruct_a561fd19 *_keyboardType;\n    const struct {\n        unsigned short _field1;\n        unsigned short _field2;\n        unsigned int _field3;\n        unsigned char _field4[1];\n    } *_keyModifiersToTableNum;\n    const struct {\n        unsigned short _field1;\n        unsigned short _field2;\n        unsigned int _field3;\n        unsigned int _field4[1];\n    } *_keyToCharTableIndex;\n    const struct {\n        unsigned short _field1;\n        unsigned short _field2;\n        unsigned int _field3[1];\n    } *_keyStateRecordsIndex;\n    const CDStruct_27a325c0 *_keyStateTerminators;\n    const CDStruct_27a325c0 *_keySequenceDataIndex;\n    NSSet *_numericPadKeyCodes;\n    NSDictionary *_systemKeyForKeyCode;\n    NSDictionary *_inputsForSystemKey;\n    NSDictionary *_inputForKey;\n    unsigned long long _longestSystemKey;\n    NSDictionary *_modifiersForTableID;\n    NSCharacterSet *_validKeyOutputIDs;\n    NSDictionary *_inputsForKeyOutputID;\n    NSSet *_safeTerminationInputs;\n    struct _NSRange _keyStateOutputIDsRange;\n    NSIndexSet *_keyStatesWithTerminator;\n    NSCharacterSet *_validKeyStates;\n    NSCharacterSet *_validSequenceIDs;\n    BOOL _canEmitSequenceIDAndKeyState;\n    NSDictionary *_inexactSequencesNFC;\n    unsigned long long _longestInexactSequence;\n    NSDictionary *_stringsForIntendedStrings;\n}\n@property(readonly, getter=isPrimary) BOOL primary;\n@property(readonly) BOOL canEmitSequenceIDAndKeyState; // @synthesize canEmitSequenceIDAndKeyState=_canEmitSequenceIDAndKeyState;\n\n- (id)stringForIntendedString:(id)arg1;\n- (id)stringForInputs:(id)arg1;\n- (id)stringForInput:(id)arg1;\n- (id)_stringForInput:(id)arg1 keyState:(unsigned long long *)arg2 output:(id)arg3;\n- (void)addCachedPaths:(id)arg1 endingString:(id)arg2 range:(struct _NSRange)arg3;\n- (id)cachedPathsEndingString:(id)arg1 range:(struct _NSRange)arg2;\n- (void)_pathsForSequenceID:(unsigned short)arg1 range:(id)arg2 nextPath:(id)arg3 results:(id)arg4;\n- (BOOL)_pathsForSystemKeyEndingString:(id)arg1 range:(struct _NSRange)arg2 nextPath:(id)arg3 results:(id)arg4;\n- (id)pathsEndingString:(id)arg1 range:(id)arg2 nextPath:(id)arg3;\n- (id)_pathByTerminatingKeyState:(unsigned short)arg1 next:(id)arg2 output:(id)arg3 sequenceID:(unsigned short)arg4;\n- (id)pathsForSequenceID:(unsigned short)arg1 nextPath:(id)arg2;\n- (void)_sequenceIDsEndingString:(id)arg1 range:(struct _NSRange)arg2 suffixRange:(struct _NSRange)arg3 results:(id)arg4;\n- (id)sequenceIDsEndingString:(id)arg1 range:(struct _NSRange)arg2;\n- (id)sequenceIDsForString:(id)arg1 range:(struct _NSRange)arg2;\n- (id)sequenceIDsForString:(id)arg1;\n- (id)stringForSequenceID:(unsigned short)arg1;\n- (id)inputsForOutputID:(unsigned short)arg1;\n- (id)inputsForText:(id)arg1 currentFlags:(unsigned long long)arg2;\n- (id)inputsForText:(id)arg1;\n- (id)inputsToSetModifierFlags:(unsigned long long)arg1 currentFlags:(unsigned long long)arg2;\n- (id)inputForKey:(id)arg1 modifierFlags:(unsigned long long)arg2;\n- (BOOL)canEmitKeyState:(unsigned short)arg1;\n- (BOOL)canEmitSequenceIDAsOutputID:(unsigned short)arg1;\n- (BOOL)canEmitSequenceID:(unsigned short)arg1;\n- (BOOL)canEmitOutputID:(unsigned short)arg1;\n- (unsigned long long)uniqueKeyboardType:(unsigned long long)arg1;\n- (BOOL)supportsKeyboardType:(unsigned long long)arg1;\n\n- (void)_initIntendedStrings;\n- (void)_initInexactSequences;\n- (void)_initValidity;\n- (void)_initKeyStates;\n- (void)_initKeyOutputs;\n- (void)_initModifiers;\n- (void)_initKeyboardKeys;\n- (id)initWithInputSource:(struct __GSKeyboard *)arg1 layoutData:(id)arg2 index:(unsigned long long)arg3;\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCKeyboardLayout.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSArray, NSData, NSString, XCKeyboardKeyMap;\n\n@interface XCKeyboardLayout : NSObject\n{\n    struct __GSKeyboard *_source;\n    NSString *_identifier;\n    NSData *_data;\n    NSArray *_keyMaps;\n    XCKeyboardKeyMap *_primaryKeyMap;\n}\n@property(readonly) XCKeyboardKeyMap *primaryKeyMap; // @synthesize primaryKeyMap=_primaryKeyMap;\n@property(readonly, copy) NSString *identifier; // @synthesize identifier=_identifier;\n\n+ (id)unicodeHexKeyboardLayout;\n+ (id)currentKeyboardLayout;\n+ (void)enumerateKeyboardLayoutsUsingBlock:(CDUnknownBlockType)arg1;\n+ (id)keyboardLayoutWithInputSource:(struct __GSKeyboard *)arg1;\n+ (id)keyboardLayoutWithIdentifier:(id)arg1;\n\n- (BOOL)deactivate:(id)arg1 error:(id *)arg2;\n- (id)activateWithError:(id *)arg1;\n- (id)_setActiveLayoutState:(id)arg1 error:(id *)arg2;\n- (void)enumerateKeyMapsUsingBlock:(CDUnknownBlockType)arg1;\n- (id)keyMapForKeyboardType:(unsigned long long)arg1;\n\n- (id)initWithInputSource:(struct __GSKeyboard *)arg1;\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCPointerEvent.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@interface XCPointerEvent : NSObject <NSSecureCoding>\n{\n    unsigned long long _eventType;\n    unsigned long long _buttonType;\n    double _pressure;\n    double _offset;\n    struct CGPoint _coordinate;\n}\n@property double offset; // @synthesize offset=_offset;\n@property double pressure; // @synthesize pressure=_pressure;\n@property struct CGPoint coordinate; // @synthesize coordinate=_coordinate;\n@property unsigned long long buttonType; // @synthesize buttonType=_buttonType;\n@property unsigned long long eventType; // @synthesize eventType=_eventType;\n\n+ (CDUnknownBlockType)offsetComparator;\n+ (id)pointerEventWithType:(unsigned long long)arg1 buttonType:(unsigned long long)arg2 coordinate:(struct CGPoint)arg3 pressure:(double)arg4 offset:(double)arg5;\n+ (id)pointerEventWithType:(unsigned long long)arg1 buttonType:(unsigned long long)arg2 coordinate:(struct CGPoint)arg3 offset:(double)arg4;\n// available since Xcode 10.2\n+ (id)keyboardEventForKeyCode:(unsigned long long)arg1 keyPhase:(unsigned long long)arg2 modifierFlags:(unsigned long long)arg3 offset:(double)arg4;\n\n\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCPointerEventPath.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSArray, NSMutableArray;\n\n@interface XCPointerEventPath : NSObject <NSSecureCoding>\n{\n    NSMutableArray *_pointerEvents;\n    BOOL _immutable;\n    unsigned long long _pathType;\n    unsigned long long _index;\n}\n@property BOOL immutable; // @synthesize immutable=_immutable;\n@property unsigned long long index; // @synthesize index=_index;\n@property(readonly) unsigned long long pathType; // @synthesize pathType=_pathType;\n@property(readonly) NSArray *pointerEvents;\n\n- (id)firstEventAfterOffset:(double)arg1;\n- (id)lastEventBeforeOffset:(double)arg1;\n- (void)_addPointerEvent:(id)arg1;\n- (void)releaseButton:(unsigned long long)arg1 atOffset:(double)arg2;\n- (void)pressButton:(unsigned long long)arg1 atOffset:(double)arg2;\n- (void)liftUpAtOffset:(double)arg1;\n- (void)moveToPoint:(struct CGPoint)arg1 atOffset:(double)arg2;\n- (void)pressDownWithPressure:(double)arg1 atOffset:(double)arg2;\n- (void)pressDownAtOffset:(double)arg1;\n- (id)initForMouseAtPoint:(struct CGPoint)arg1 offset:(double)arg2;\n- (id)initForTouchAtPoint:(CGPoint)arg1 offset:(double)arg2;\n// Since Xcode 10.2\n- (void)typeKey:(id)arg1 modifiers:(unsigned long long)arg2 atOffset:(double)arg3;\n// Since Xcode 12.beta5\n- (void)typeText:(id)arg1 atOffset:(double)arg2 typingSpeed:(unsigned long long)arg3 shouldRedact:(_Bool)arg4;\n// Since Xcode 10.2\n- (id)initForTextInput;\n// Since Xcode 10.2\n- (void)setModifiers:(unsigned long long)arg1 mergeWithCurrentModifierFlags:(_Bool)arg2 atOffset:(double)arg3;\n\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCSourceCodeRecording.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSArray, NSMutableArray, NSMutableDictionary, NSMutableSet;\n\n@interface XCSourceCodeRecording : NSObject\n{\n    unsigned long long _language;\n    NSMutableArray *_treeNodes;\n    NSMutableSet *_variableTreeNodes;\n    NSArray *_reservedNames;\n    NSMutableDictionary *_variableNameToContentNodeDictionary;\n    long long _nextVariableCount;\n}\n\n@property(retain) NSMutableDictionary *variableNameToContentNodeDictionary; // @synthesize variableNameToContentNodeDictionary=_variableNameToContentNodeDictionary;\n@property(retain, setter=_setTreeNodes:) NSArray *_treeNodes; // @synthesize _treeNodes;\n@property(readonly) unsigned long long language; // @synthesize language=_language;\n- (BOOL)_shareLongestCommonSection_StartAtIndex:(long long)arg1 nextCandidateIndex:(long long *)arg2;\n- (BOOL)_createAndShareLocalVariableUsingSourceNode:(id)arg1 atIndex:(long long)arg2;\n- (id)_variableNameForVariableContentNode:(id)arg1;\n- (unsigned long long)_variableClassTypeForVariableContentNode:(id)arg1;\n- (id)_variableSuffixForElementType:(unsigned long long)arg1 classType:(unsigned long long)arg2;\n- (id)_transformedVariablePrefixForLabel:(id)arg1;\n- (id)_variableNameForElementType:(unsigned long long)arg1 label:(id)arg2 classType:(unsigned long long)arg3;\n- (id)_uniqueVariableNameWithName:(id)arg1;\n- (id)_nodes:(id)arg1 matchingDistanceFromRoot:(BOOL)arg2 variableContentNode:(id)arg3 withVariableName:(id)arg4 startingIndex:(long long)arg5 replacedNodes:(long long *)arg6 indexOfFirstReplacedNode:(long long *)arg7;\n- (BOOL)_shareCommonSectionsUsingExistingLocalVariables;\n- (void)_shareCommonSectionsInLocalVariables;\n- (id)variableNodeForNode:(id)arg1 withName:(id)arg2 variableType:(unsigned long long)arg3;\n- (id)_sourceCodePrefixForVariableName:(id)arg1 variableType:(unsigned long long)arg2;\n- (id)_stringRepresentationWithOptions:(unsigned long long)arg1 error:(id *)arg2;\n- (id)stringRepresentationWithError:(id *)arg1;\n- (void)appendNode:(id)arg1 replaceLastNode:(BOOL)arg2;\n- (id)copy;\n- (id)initWithLanguage:(unsigned long long)arg1 reservedNames:(id)arg2;\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCSourceCodeTreeNode.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSArray, NSIndexPath, NSNumber, NSSet, NSString;\n\n@interface XCSourceCodeTreeNode : NSObject <NSSecureCoding>\n{\n    NSString *_sourceCodePrefix;\n    NSString *_sourceCodeSuffix;\n    NSArray *_childNodes;\n    long long _selectedChildNodeIndex;\n    XCSourceCodeTreeNode *_parentNode;\n    NSSet *_identifierValues;\n    NSNumber *_index;\n    NSString *_queryType;\n    NSNumber *_returnType;\n    NSNumber *_calleeType;\n    NSNumber *_elementType;\n}\n@property(copy, setter=_setElementType:) NSNumber *_elementType; // @synthesize _elementType;\n@property(copy, setter=_setCalleeType:) NSNumber *_calleeType; // @synthesize _calleeType;\n@property(copy, setter=_setReturnType:) NSNumber *_returnType; // @synthesize _returnType;\n@property(copy, setter=_setQueryType:) NSString *_queryType; // @synthesize _queryType;\n@property(copy, setter=_setIndex:) NSNumber *_index; // @synthesize _index;\n@property(copy, setter=_setIdentifierValues:) NSSet *_identifierValues; // @synthesize _identifierValues;\n@property(retain) XCSourceCodeTreeNode *selectedChildNode;\n@property(readonly) NSIndexPath *selectedChildNodeIndexPath;\n@property unsigned long long selectedChildNodeIndex;\n@property(retain) NSArray *childNodes;\n@property(copy) NSString *sourceCodeSuffix;\n@property(copy) NSString *sourceCodePrefix;\n@property __weak XCSourceCodeTreeNode *parentNode;\n@property(readonly) XCSourceCodeTreeNode *rootNode;\n@property(readonly, copy) NSString *displayName;\n\n+ (id)_stringRepresentationsOfNodesAsSeparateLines:(id)arg1 language:(unsigned long long)arg2 options:(unsigned long long)arg3 error:(id *)arg4;\n+ (id)stringRepresentationsOfNodesAsSeparateLines:(id)arg1 language:(unsigned long long)arg2 error:(id *)arg3;\n+ (unsigned long long)_defaultOptions;\n+ (id)treeForStringRepresentation:(id)arg1 range:(struct _NSRange)arg2 error:(id *)arg3;\n+ (struct _NSRange)_rangeOfFirstSourceCodeTreeInString:(id)arg1 range:(struct _NSRange)arg2 compiledSourceCodeRange:(struct _NSRange *)arg3 jsonRange:(struct _NSRange *)arg4;\n+ (struct _NSRange)rangeOfFirstSourceCodeTreeInString:(id)arg1 range:(struct _NSRange)arg2;\n+ (id)_sourceCodeForNodes:(id)arg1 error:(id *)arg2;\n+ (BOOL)_isContentOfNodesArraysEqual:(id)arg1 ignoringSelection:(BOOL)arg2 toDistanceFromRoot:(long long)arg3;\n+ (BOOL)_isContentOfNodesEqual:(id)arg1 ignoringSelection:(BOOL)arg2 toDistanceFromRoot:(long long)arg3;\n+ (BOOL)_isContentEqualIgnoringSelection:(BOOL)arg1 childNodes:(id)arg2 childNodes:(id)arg3 toDistanceFromRoot:(long long)arg4;\n+ (id)_nodesByMergingSimilarNodes:(id)arg1;\n+ (void)_shareSourceCodeStringsForNodes:(id)arg1;\n\n- (void)_absorbOnlyChildrenIntoParents;\n- (id)_treeByPushingOutPrefix:(id *)arg1 error:(id *)arg2;\n- (id)copy;\n- (id)_copyIncludingNodesWithDistanceFromRoot:(long long)arg1 passingTest:(CDUnknownBlockType)arg2;\n- (id)_copyIncludingNodesWithDistanceFromRoot:(unsigned long long)arg1 descendantChildrenArrays:(id)arg2 selectedChildNodeIndexes:(id)arg3;\n- (id)_copyIncludingNodesWithMinimumDistanceFromLeaf:(unsigned long long)arg1 descendantChildrenArrays:(id)arg2 selectedChildNodeIndexes:(id)arg3;\n- (BOOL)_canPushPutSolitaryRootNodes;\n- (unsigned long long)_distanceFromRoot;\n- (unsigned long long)_minimumDistanceFromLeaf;\n- (unsigned long long)_maximumDistanceFromLeaf;\n- (id)_stringRepresentationWithCompiledCodeRange:(struct _NSRange *)arg1 options:(unsigned long long)arg2 error:(id *)arg3;\n- (id)_stringRepresentationWithOptions:(unsigned long long)arg1 error:(id *)arg2;\n- (BOOL)_leavesHaveNoNonLeafSiblingsAndHaveSamePrefix:(id *)arg1 suffix:(id *)arg2;\n- (BOOL)_leavesHaveSameAccumulatedPrefix:(id *)arg1;\n- (id)stringRepresentationWithCompiledCodeRange:(struct _NSRange *)arg1 error:(id *)arg2;\n- (id)stringRepresentationWithError:(id *)arg1;\n- (id)initWithCoder:(id)arg1;\n- (void)encodeWithCoder:(id)arg1;\n- (id)_treeAsJSONWithError:(id *)arg1;\n- (id)descriptionWithDepth:(unsigned long long)arg1;\n- (id)_depthStringWithDepth:(unsigned long long)arg1;\n- (id)sourceCodeForAllDescendants;\n- (id)selectedDescendantsSourceCodeWithError:(id *)arg1;\n- (id)selectedChildNodesIndexesWithError:(id *)arg1;\n- (void)setChildrenOnAllLeafNodes:(id)arg1 selectChildNodeIndex:(unsigned long long)arg2;\n- (BOOL)_isContentEqual:(id)arg1 ignoringSelection:(BOOL)arg2 toDistanceFromRoot:(unsigned long long)arg3;\n- (unsigned long long)_descendantCount;\n- (BOOL)setChildNodes:(id)arg1 error:(id *)arg2;\n- (BOOL)_canHaveSiblingNode:(id)arg1;\n- (id)initWithSourceCodePrefix:(id)arg1 sourceCodeSuffix:(id)arg2;\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCSourceCodeTreeNodeEnumerator.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSMutableArray;\n\n@interface XCSourceCodeTreeNodeEnumerator : NSObject\n{\n    NSMutableArray *_remainingNodes;\n}\n\n- (id)nextObject;\n- (id)initWithNode:(id)arg1;\n\n@end\n\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCSymbolicationRecord.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSString;\n\n@interface XCSymbolicationRecord : NSObject\n{\n    unsigned long long _lineNumber;\n    NSString *_filePath;\n    NSString *_symbolName;\n    NSString *_symbolOwner;\n}\n@property(copy) NSString *symbolOwner; // @synthesize symbolOwner=_symbolOwner;\n@property(copy) NSString *symbolName; // @synthesize symbolName=_symbolName;\n@property(copy) NSString *filePath; // @synthesize filePath=_filePath;\n@property unsigned long long lineNumber; // @synthesize lineNumber=_lineNumber;\n\n+ (id)symbolicationRecordFromRemoteServiceForAddress:(unsigned long long)arg1;\n+ (id)symbolicationRecordForTask:(unsigned int)arg1 address:(unsigned long long)arg2;\n+ (id)symbolicationRecordForAddress:(unsigned long long)arg1;\n+ (void)_setCurrentProcessIsRemoteService;\n+ (id)_symbolicationRecordForSymbolicator:(struct _CSTypeRef)arg1 address:(unsigned long long)arg2;\n+ (id)failureRecord;\n+ (BOOL)softLinkCoreSymbolication;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCSymbolicatorHolder.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@interface XCSymbolicatorHolder : NSObject\n{\n    struct _CSTypeRef _symbolicator;\n}\n\n@property struct _CSTypeRef symbolicator; // @synthesize symbolicator=_symbolicator;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCSynthesizedEventRecord.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSArray, NSMutableArray, NSString, XCPointerEventPath;\n\n@interface XCSynthesizedEventRecord : NSObject <NSSecureCoding>\n{\n    NSMutableArray *_eventPaths;\n    NSString *_name;\n#if !TARGET_OS_TV\n    UIInterfaceOrientation _interfaceOrientation;\n#endif\n}\n#if !TARGET_OS_TV\n@property(readonly) UIInterfaceOrientation interfaceOrientation; // @synthesize interfaceOrientation=_interfaceOrientation;\n#endif\n@property(readonly, copy) NSString *name; // @synthesize name=_name;\n@property(readonly) double maximumOffset;\n@property(readonly) NSArray *eventPaths;\n\n- (void)addPointerEventPath:(XCPointerEventPath *)arg1;\n#if !TARGET_OS_TV\n- (id)initWithName:(NSString *)arg1 interfaceOrientation:(UIInterfaceOrientation)arg2;\n#endif\n- (id)initWithName:(id)arg1;\n- (id)init;\n- (BOOL)synthesizeWithError:(NSError **)arg1;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTAXClient-Protocol.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n@class NSData;\n\n@protocol XCTAXClient <NSObject>\n- (void)handleAccessibilityNotification:(int)arg1 withPayload:(NSData *)arg2;\n@end\n\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTAsyncActivity-Protocol.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n@class NSError;\n\n@protocol XCTAsyncActivity <NSObject>\n@property(readonly) BOOL timedOut;\n@property(readonly) NSError *error;\n- (void)finishWithError:(NSError *)arg1;\n@end\n\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTAsyncActivity.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCTestExpectation.h>\n\n#import \"XCTAsyncActivity.h\"\n\n@class NSError, NSString;\n\n@interface XCTAsyncActivity : XCTestExpectation <XCTAsyncActivity>\n{\n    NSError *_error;\n    BOOL _timedOut;\n}\n@property BOOL timedOut; // @synthesize timedOut=_timedOut;\n@property(retain) NSError *error; // @synthesize error=_error;\n\n- (void)finishWithError:(id)arg1;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTAutomationTarget-Protocol.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n@protocol XCTAutomationTarget <NSObject>\n- (void)requestHostAppExecutableNameWithReply:(void (^)(NSString *))arg1;\n@end\n\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTDarwinNotificationExpectation.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCTestExpectation.h>\n\n@class NSString, _XCTDarwinNotificationExpectationImplementation;\n\n@interface XCTDarwinNotificationExpectation : XCTestExpectation\n{\n    id _internal;\n}\n@property(retain) _XCTDarwinNotificationExpectationImplementation *internal; // @synthesize internal=_internal;\n@property(copy) CDUnknownBlockType handler;\n@property(readonly, copy) NSString *notificationName;\n\n- (void)cleanup;\n- (void)fulfill;\n- (id)initWithNotificationName:(id)arg1;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTElementSetTransformer-Protocol.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit) (Debug version compiled Jun  9 2015 22:53:21).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2014 by Steve Nygard.\n//\n\n@class NSOrderedSet, NSSet, NSString;\n@protocol XCTMatchingElementIterator;\n\n@protocol XCTElementSetTransformer <NSObject>\n@property BOOL stopsOnFirstMatch;\n@property(readonly) BOOL supportsAttributeKeyPathAnalysis;\n@property(copy) NSString *transformationDescription;\n@property(readonly) BOOL supportsRemoteEvaluation;\n- (NSSet *)requiredKeyPathsOrError:(id *)arg1;\n- (id <XCTMatchingElementIterator>)iteratorForInput:(id/*XCElementSnapshot*/)arg1;\n- (NSOrderedSet *)transform:(NSOrderedSet *)arg1 relatedElements:(id *)arg2;\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTKVOExpectation.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCTestExpectation.h>\n\n@class NSString, _XCKVOExpectationImplementation;\n\n@interface XCTKVOExpectation : XCTestExpectation\n{\n    id _internal;\n}\n@property(retain) _XCKVOExpectationImplementation *internal; // @synthesize internal=_internal;\n@property(copy) CDUnknownBlockType handler;\n@property(readonly) unsigned long long options;\n@property(readonly) id expectedValue;\n@property(readonly) id observedObject;\n@property(readonly, copy) NSString *keyPath;\n\n- (void)cleanup;\n- (void)fulfill;\n- (id)initWithKeyPath:(id)arg1 object:(id)arg2;\n- (id)initWithKeyPath:(id)arg1 object:(id)arg2 expectedValue:(id)arg3;\n- (id)initWithKeyPath:(id)arg1 object:(id)arg2 expectedValue:(id)arg3 options:(unsigned long long)arg4;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTMetric.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n@class NSArray, NSDictionary, NSString;\n\n@interface XCTMetric : NSObject\n{\n    NSString *_identifier;\n    NSString *_name;\n    NSString *_units;\n    NSDictionary *_baseline;\n    NSDictionary *_defaultBaseline;\n    NSArray *_measurements;\n}\n@property(copy) NSArray *measurements; // @synthesize measurements=_measurements;\n@property(copy) NSDictionary *defaultBaseline; // @synthesize defaultBaseline=_defaultBaseline;\n@property(copy) NSDictionary *baseline; // @synthesize baseline=_baseline;\n@property(copy) NSString *units; // @synthesize units=_units;\n@property(copy) NSString *name; // @synthesize name=_name;\n@property(copy) NSString *identifier; // @synthesize identifier=_identifier;\n\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTNSNotificationExpectation.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCTestExpectation.h>\n\n@class NSNotificationCenter, NSString, _XCTNSNotificationExpectationImplementation;\n\n@interface XCTNSNotificationExpectation : XCTestExpectation\n{\n    id _internal;\n}\n@property(retain) _XCTNSNotificationExpectationImplementation *internal; // @synthesize internal=_internal;\n@property(copy) CDUnknownBlockType handler;\n@property(readonly) NSNotificationCenter *notificationCenter;\n@property(readonly, copy) NSString *notificationName;\n@property(readonly) id observedObject;\n\n- (void)cleanup;\n- (void)fulfill;\n- (id)initWithName:(id)arg1;\n- (id)initWithName:(id)arg1 object:(id)arg2;\n- (id)initWithName:(id)arg1 object:(id)arg2 notificationCenter:(id)arg3;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTNSPredicateExpectation.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCTestExpectation.h>\n\n@class NSPredicate, _XCTNSPredicateExpectationImplementation;\n\n@interface XCTNSPredicateExpectation : XCTestExpectation\n{\n    id _internal;\n}\n@property(retain) _XCTNSPredicateExpectationImplementation *internal; // @synthesize internal=_internal;\n@property(copy) CDUnknownBlockType handler;\n@property(readonly, copy) NSPredicate *predicate;\n@property(readonly) id object;\n\n- (void)cleanup;\n- (void)fulfill;\n- (id)initWithPredicate:(id)arg1 object:(id)arg2;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTNSPredicateExpectationObject-Protocol.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n@class XCTNSPredicateExpectation;\n\n@protocol XCTNSPredicateExpectationObject <NSObject>\n\n@optional\n- (BOOL)evaluatePredicateForExpectation:(XCTNSPredicateExpectation *)arg1 debugMessage:(id *)arg2;\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTRunnerAutomationSession.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n#import \"XCTRunnerAutomationSession.h\"\n\n@class NSString, NSXPCConnection;\n\n@interface XCTRunnerAutomationSession : NSObject <XCTRunnerAutomationSession>\n{\n    NSXPCConnection *_connection;\n}\n@property NSXPCConnection *connection; // @synthesize connection=_connection;\n\n- (id)initWithEndpoint:(id)arg1;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTRunnerDaemonSession.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"XCTestManager_TestsInterface-Protocol.h\"\n#import \"XCEventGenerator.h\"\n#import <CDStructures.h>\n#import <UIKit/UIKit.h>\n\n@class NSMutableDictionary, NSXPCConnection, XCSynthesizedEventRecord;\n#if !TARGET_OS_TV // tvOS does not provide relevant APIs\n@class CLLocation;\n#endif\n@protocol XCTUIApplicationMonitor, XCTAXClient, XCTestManager_ManagerInterface;\n\n// iOS since 10.3\n@interface XCTRunnerDaemonSession : NSObject <XCTestManager_TestsInterface>\n{\n    NSObject<OS_dispatch_queue> *_queue;\n    id <XCTUIApplicationMonitor> _applicationMonitor;\n    id <XCTAXClient> _accessibilityClient;\n    NSXPCConnection *_connection;\n    unsigned long long _daemonProtocolVersion;\n    NSMutableDictionary *_invalidationHandlers;\n}\n@property(retain) NSObject<OS_dispatch_queue> *queue; // @synthesize queue=_queue;\n@property id <XCTAXClient> accessibilityClient; // @synthesize accessibilityClient=_accessibilityClient;\n@property id <XCTUIApplicationMonitor> applicationMonitor; // @synthesize applicationMonitor=_applicationMonitor;\n@property(retain) NSMutableDictionary *invalidationHandlers; // @synthesize invalidationHandlers=_invalidationHandlers;\n@property(retain) NSXPCConnection *connection; // @synthesize connection=_connection;\n@property(readonly) BOOL useLegacyEventCoordinateTransformationPath;\n@property unsigned long long daemonProtocolVersion;\n@property(readonly) id <XCTestManager_ManagerInterface> daemonProxy;\n\n+ (instancetype)sharedSession;\n\n- (void)injectVoiceRecognitionAudioInputPaths:(id)arg1 completion:(CDUnknownBlockType)arg2;\n- (void)injectAssistantRecognitionStrings:(id)arg1 completion:(CDUnknownBlockType)arg2;\n- (void)startSiriUIRequestWithAudioFileURL:(id)arg1 completion:(CDUnknownBlockType)arg2;\n- (void)startSiriUIRequestWithText:(id)arg1 completion:(CDUnknownBlockType)arg2;\n- (void)requestDTServiceHubConnectionWithReply:(CDUnknownBlockType)arg1;\n- (void)enableFauxCollectionViewCells:(CDUnknownBlockType)arg1;\n- (void)loadAccessibilityWithTimeout:(double)arg1 reply:(CDUnknownBlockType)arg2;\n- (void)setAXTimeout:(double)arg1 reply:(CDUnknownBlockType)arg2;\n- (void)requestScreenshotWithReply:(CDUnknownBlockType)arg1;\n- (void)sendString:(id)arg1 maximumFrequency:(unsigned long long)arg2 completion:(CDUnknownBlockType)arg3;\n- (void)updateDeviceOrientation:(long long)arg1 completion:(CDUnknownBlockType)arg2;\n- (void)performDeviceEvent:(id)arg1 completion:(CDUnknownBlockType)arg2;\n- (void)synthesizeEvent:(XCSynthesizedEventRecord *)arg1 completion:(void (^)(NSError *))arg2;\n- (void)requestElementAtPoint:(CGPoint)arg1 reply:(CDUnknownBlockType)arg2;\n- (void)fetchParameterizedAttributeForElement:(id)arg1 attribute:(id)arg2 parameter:(id)arg3 reply:(CDUnknownBlockType)arg4;\n- (void)setAttribute:(id)arg1 value:(id)arg2 element:(id)arg3 reply:(CDUnknownBlockType)arg4;\n- (void)fetchAttributesForElement:(id)arg1 attributes:(id)arg2 reply:(CDUnknownBlockType)arg3;\n- (void)snapshotForElement:(id)arg1 attributes:(id)arg2 parameters:(id)arg3 reply:(void (^)(id/*XCElementSnapshot*/, NSError *))arg4;\n- (void)terminateApplicationWithBundleID:(id)arg1 completion:(CDUnknownBlockType)arg2;\n- (void)performAccessibilityAction:(int)arg1 onElement:(id)arg2 value:(id)arg3 reply:(CDUnknownBlockType)arg4;\n- (void)unregisterForAccessibilityNotification:(int)arg1 registrationToken:(id)arg2 reply:(CDUnknownBlockType)arg3;\n- (void)registerForAccessibilityNotification:(int)arg1 reply:(CDUnknownBlockType)arg2;\n- (void)launchApplicationWithBundleID:(id)arg1 arguments:(id)arg2 environment:(id)arg3 completion:(CDUnknownBlockType)arg4;\n- (void)startMonitoringApplicationWithBundleID:(id)arg1;\n- (void)requestBackgroundAssertionForPID:(int)arg1 reply:(CDUnknownBlockType)arg2;\n- (void)requestAutomationSessionForTestTargetWithPID:(int)arg1 reply:(CDUnknownBlockType)arg2;\n- (void)requestIDEConnectionSocketForSessionIdentifier:(id)arg1 reply:(CDUnknownBlockType)arg2;\n- (void)_XCT_receivedAccessibilityNotification:(int)arg1 withPayload:(id)arg2;\n- (void)_XCT_applicationWithBundleID:(id)arg1 didUpdatePID:(int)arg2 andState:(unsigned long long)arg3;\n- (void)unregisterInvalidationHandlerWithToken:(id)arg1;\n- (id)registerInvalidationHandler:(CDUnknownBlockType)arg1;\n- (void)_reportInvalidation;\n- (id)initWithConnection:(id)arg1;\n\n// Since Xcode 14.3\n- (void)openURL:(NSURL *)arg1 usingApplication:(NSString *)arg2 completion:(void (^)(_Bool, NSError *))arg3;\n- (void)openDefaultApplicationForURL:(NSURL *)arg1 completion:(void (^)(_Bool, NSError *))arg2;\n#if !TARGET_OS_TV // tvOS does not provide relevant APIs\n- (void)setSimulatedLocation:(CLLocation *)arg1 completion:(void (^)(_Bool, NSError *))arg2;\n- (void)getSimulatedLocationWithReply:(void (^)(CLLocation *, NSError *))arg1;\n- (void)clearSimulatedLocationWithReply:(void (^)(_Bool, NSError *))arg1;\n@property(readonly) _Bool supportsLocationSimulation;\n#endif\n\n// Since Xcode 15.0-beta1\n- (void)stopScreenRecordingWithUUID:(NSUUID *)arg1\n                          withReply:(void (^)(NSError *))arg2;\n- (void)startScreenRecordingWithRequest:(id/* XCTScreenRecordingRequest */)arg1\n                              withReply:(void (^)(id/* XCTAttachmentFutureMetadata */, NSError *))arg2;\n- (_Bool)supportsScreenRecording;\n- (_Bool)preferScreenshotsOverScreenRecordings;\n\n// Since Xcode 10.2\n- (void)launchApplicationWithPath:(NSString *)arg1\n                         bundleID:(NSString *)arg2\n                        arguments:(NSArray *)arg3\n                      environment:(NSDictionary *)arg4\n                       completion:(void (^)(_Bool, NSError *))arg5;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTRunnerIDESession.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n#import \"XCTTestRunSessionDelegate.h\"\n#import \"XCTestDriverInterface.h\"\n#import \"XCTestObservation.h\"\n\n@class DTXConnection, NSObject<OS_dispatch_queue>, NSString, XCTestRun;\n\n@interface XCTRunnerIDESession : NSObject <XCTestObservation, XCTestDriverInterface, XCTTestRunSessionDelegate>\n{\n    NSObject<OS_dispatch_queue> *_queue;\n    DTXConnection *_IDEConnection;\n    id <XCTestManager_IDEInterface><NSObject> _IDEProxy;\n    long long _IDEProtocolVersion;\n    id <XCTUIApplicationMonitor> _applicationMonitor;\n    XCTestRun *_currentTestRun;\n    CDUnknownBlockType _readinessReply;\n}\n@property(copy) CDUnknownBlockType readinessReply; // @synthesize readinessReply=_readinessReply;\n@property(retain) id <XCTestManager_IDEInterface><NSObject> IDEProxy; // @synthesize IDEProxy=_IDEProxy;\n@property(retain) DTXConnection *IDEConnection; // @synthesize IDEConnection=_IDEConnection;\n@property __weak id <XCTUIApplicationMonitor> applicationMonitor; // @synthesize applicationMonitor=_applicationMonitor;\n@property(retain) NSObject<OS_dispatch_queue> *queue; // @synthesize queue=_queue;\n@property(readonly) BOOL reportsCrashes;\n@property long long IDEProtocolVersion; // @synthesize IDEProtocolVersion=_IDEProtocolVersion;\n\n+ (int)connectedSocketForLocalPath:(id)arg1 error:(id *)arg2;\n+ (void)setSharedSession:(id)arg1;\n+ (id)sharedSession;\n+ (id)sharedSessionQueue;\n\n- (void)testBundleDidFinish:(id)arg1;\n- (void)_testCase:(id)arg1 didFinishActivity:(id)arg2;\n- (void)_testCase:(id)arg1 willStartActivity:(id)arg2;\n- (void)_testCase:(id)arg1 didMeasureValues:(id)arg2 forPerformanceMetricID:(id)arg3 name:(id)arg4 unitsOfMeasurement:(id)arg5 baselineName:(id)arg6 baselineAverage:(id)arg7 maxPercentRegression:(id)arg8 maxPercentRelativeStandardDeviation:(id)arg9 maxRegression:(id)arg10 maxStandardDeviation:(id)arg11 file:(id)arg12 line:(unsigned long long)arg13;\n- (void)testCase:(id)arg1 didFailWithDescription:(id)arg2 inFile:(id)arg3 atLine:(unsigned long long)arg4;\n- (void)testCaseDidFinish:(id)arg1;\n- (void)testCaseWillStart:(id)arg1;\n- (void)testSuiteDidFinish:(id)arg1;\n- (void)testSuite:(id)arg1 didFailWithDescription:(id)arg2 inFile:(id)arg3 atLine:(unsigned long long)arg4;\n- (void)testSuiteWillStart:(id)arg1;\n- (void)testBundleWillStart:(id)arg1;\n- (id)_IDE_processWithToken:(id)arg1 exitedWithStatus:(id)arg2;\n- (id)_IDE_stopTrackingProcessWithToken:(id)arg1;\n- (void)terminateProcessWithToken:(id)arg1 completion:(CDUnknownBlockType)arg2;\n- (void)requestLaunchProgressForProcessWithToken:(id)arg1 completion:(CDUnknownBlockType)arg2;\n- (void)launchProcessWithPath:(id)arg1 bundleID:(id)arg2 arguments:(id)arg3 environmentVariables:(id)arg4 completion:(CDUnknownBlockType)arg5;\n- (id)_IDE_processWithBundleID:(id)arg1 path:(id)arg2 pid:(id)arg3 crashedUnderSymbol:(id)arg4;\n- (void)reportStallOnMainThreadInTestCase:(id)arg1 method:(id)arg2 file:(id)arg3 line:(unsigned long long)arg4;\n- (void)logDebugMessage:(id)arg1;\n- (void)testRunSessionDidFinishExecutingTestPlan:(id)arg1 reply:(CDUnknownBlockType)arg2;\n- (void)testRunSession:(id)arg1 initializationForUITestingDidFailWithError:(id)arg2;\n- (void)testRunSessionDidBeginInitializingForUITesting:(id)arg1;\n- (void)testRunSessionDidBeginExecutingTestPlan:(id)arg1;\n- (id)_IDE_startExecutingTestPlanWithProtocolVersion:(id)arg1;\n- (void)requestReadinessForTesting:(CDUnknownBlockType)arg1;\n- (id)initWithConnectedSocket:(int)arg1;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTTestRunSession.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n@class XCTestConfiguration;\n\n@interface XCTTestRunSession : NSObject\n{\n    XCTestConfiguration *_testConfiguration;\n    id <XCTTestRunSessionDelegate> _delegate;\n}\n@property id <XCTTestRunSessionDelegate> delegate; // @synthesize delegate=_delegate;\n@property(retain) XCTestConfiguration *testConfiguration; // @synthesize testConfiguration=_testConfiguration;\n\n- (BOOL)runTestsAndReturnError:(id *)arg1;\n- (BOOL)_preTestingInitialization;\n- (void)resumeAppSleep:(id)arg1;\n- (id)suspendAppSleep;\n- (id)initWithTestConfiguration:(id)arg1 delegate:(id)arg2;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTTestRunSessionDelegate-Protocol.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n@class NSError, XCTTestRunSession;\n\n@protocol XCTTestRunSessionDelegate <NSObject>\n- (void)testRunSessionDidFinishExecutingTestPlan:(XCTTestRunSession *)arg1 reply:(void (^)(void))arg2;\n- (void)testRunSession:(XCTTestRunSession *)arg1 initializationForUITestingDidFailWithError:(NSError *)arg2;\n- (void)testRunSessionDidBeginInitializingForUITesting:(XCTTestRunSession *)arg1;\n- (void)testRunSessionDidBeginExecutingTestPlan:(XCTTestRunSession *)arg1;\n@end\n\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTUIApplicationMonitor-Protocol.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n@class NSNumber, NSString;\n\n@protocol XCTUIApplicationMonitor <NSObject>\n- (void)applicationWithBundleID:(NSString *)arg1 didUpdatePID:(int)arg2 state:(unsigned long long)arg3;\n- (void)processWithToken:(NSNumber *)arg1 exitedWithStatus:(int)arg2;\n- (void)stopTrackingProcessWithToken:(NSNumber *)arg1;\n- (void)crashInProcessWithBundleID:(NSString *)arg1 path:(NSString *)arg2 pid:(int)arg3 symbol:(NSString *)arg4;\n@end\n\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTWaiter.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n#import \"XCTWaiterManagement.h\"\n#import \"XCTestExpectationDelegate.h\"\n\n@class NSArray, NSObject<OS_dispatch_queue>, NSString, _XCTWaiterImpl;\n\n@interface XCTWaiter : NSObject <XCTestExpectationDelegate, XCTWaiterManagement>\n{\n    id _internalImplementation;\n}\n@property(readonly) _XCTWaiterImpl *internalImplementation; // @synthesize internalImplementation=_internalImplementation;\n@property(readonly) double timeout;\n@property(readonly, getter=isInProgress) BOOL inProgress;\n@property struct __CFRunLoop *waitingRunLoop;\n@property(readonly, nonatomic) NSObject<OS_dispatch_queue> *delegateQueue;\n@property(readonly, nonatomic) NSObject<OS_dispatch_queue> *queue;\n@property(readonly, copy) NSArray *waitCallStackReturnAddresses;\n@property(readonly) NSArray *fulfilledExpectations;\n@property __weak id <XCTWaiterDelegate> delegate;\n\n+ (id)waitForActivity:(id)arg1 timeout:(double)arg2 block:(CDUnknownBlockType)arg3;\n+ (long long)waitForExpectations:(id)arg1 timeout:(double)arg2 enforceOrder:(BOOL)arg3;\n+ (long long)waitForExpectations:(id)arg1 timeout:(double)arg2;\n+ (void)wait:(double)arg1;\n+ (void)setStallHandler:(CDUnknownBlockType)arg1;\n+ (void)handleStalledWaiter:(id)arg1;\n+ (CDUnknownBlockType)installWatchdogForWaiter:(id)arg1 timeout:(double)arg2;\n\n- (long long)result;\n- (void)setState:(long long)arg1;\n- (long long)state;\n- (void)setWaitCallStackReturnAddresses:(id)arg1;\n- (void)_queue_validateExpectationFulfillmentWithTimeoutState:(BOOL)arg1;\n- (BOOL)_queue_enforceOrderingWithFulfilledExpectations:(id)arg1;\n- (void)_queue_computeInitiallyFulfilledExpectations;\n- (void)_queue_setExpectations:(id)arg1;\n- (void)_validateExpectationFulfillmentWithTimeoutState:(BOOL)arg1;\n- (void)didFulfillExpectation:(id)arg1;\n- (void)cancelPrimitiveWait;\n- (void)cancelWaiting;\n- (void)primitiveWait:(double)arg1;\n- (void)interruptForWaiter:(id)arg1;\n- (long long)waitForExpectations:(id)arg1 timeout:(double)arg2 enforceOrder:(BOOL)arg3;\n- (long long)waitForExpectations:(id)arg1 timeout:(double)arg2;\n- (id)initWithDelegate:(id)arg1;\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTWaiterDelegate-Protocol.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n@class NSArray, XCTWaiter, XCTestExpectation;\n\n@protocol XCTWaiterDelegate <NSObject>\n- (void)waiter:(XCTWaiter *)arg1 didFulfillInvertedExpectation:(XCTestExpectation *)arg2;\n- (void)waiter:(XCTWaiter *)arg1 fulfillmentDidViolateOrderingConstraintsForExpectation:(XCTestExpectation *)arg2 requiredExpectation:(XCTestExpectation *)arg3;\n- (void)waiter:(XCTWaiter *)arg1 didTimeoutWithUnfulfilledExpectations:(NSArray *)arg2;\n@end\n\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTWaiterDelegatePrivate-Protocol.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class XCTWaiter;\n\n@protocol XCTWaiterDelegatePrivate\n- (void)nestedWaiter:(XCTWaiter *)arg1 wasInterruptedByTimedOutWaiter:(XCTWaiter *)arg2;\n@end\n\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTWaiterManagement-Protocol.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n@protocol XCTWaiterManagement <NSObject>\n@property(readonly, getter=isInProgress) BOOL inProgress;\n- (void)interruptForWaiter:(id <XCTWaiterManagement>)arg1;\n@end\n\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTWaiterManager.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n@class NSMutableArray, NSObject<OS_dispatch_queue>, NSThread;\n\n@interface XCTWaiterManager : NSObject\n{\n    NSMutableArray *_waiterStack;\n    NSThread *_thread;\n    NSObject<OS_dispatch_queue> *_queue;\n}\n@property(readonly) NSObject<OS_dispatch_queue> *queue; // @synthesize queue=_queue;\n@property NSThread *thread; // @synthesize thread=_thread;\n@property(retain) NSMutableArray *waiterStack; // @synthesize waiterStack=_waiterStack;\n\n+ (id)threadLocalManager;\n\n- (void)waiterDidFinishWaiting:(id)arg1;\n- (void)waiterTimedOutWhileWaiting:(id)arg1;\n- (void)waiterWillBeginWaiting:(id)arg1;\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTest.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSString, XCTestRun;\n\n@interface XCTest : NSObject\n{\n    id _internal;\n}\n@property(readonly) NSString *nameForLegacyLogging;\n@property(readonly) NSString *languageAgnosticTestMethodName;\n@property(readonly) NSString *languageAgnosticTestClassName;\n@property(readonly) XCTestRun *testRun;\n@property(readonly) Class testRunClass;\n@property(readonly) Class _requiredTestRunBaseClass;\n@property(readonly, copy) NSString *name;\n@property(readonly) unsigned long long testCaseCount;\n@property(readonly) NSString *_methodNameForReporting;\n@property(readonly) NSString *_classNameForReporting;\n\n+ (id)languageAgnosticTestClassNameForTestClass:(Class)arg1;\n\n- (void)tearDown;\n- (void)setUp;\n- (void)runTest;\n- (id)run;\n- (void)performTest:(id)arg1;\n- (id)init;\n- (void)removeTestsWithNames:(id)arg1;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestCase.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCTest.h>\n\n#import <WebDriverAgentLib/CDStructures.h>\n\n@class NSInvocation, XCTestCaseRun, XCTestContext, _XCTestCaseImplementation;\n\n@interface XCTestCase()\n{\n    id _internalImplementation;\n}\n@property(retain) _XCTestCaseImplementation *internalImplementation; // @synthesize internalImplementation=_internalImplementation;\n@property(readonly) XCTestContext *testContext;\n@property(readonly) unsigned long long activityRecordStackDepth;\n@property(nonatomic) BOOL shouldHaltWhenReceivesControl;\n@property(nonatomic) BOOL shouldSetShouldHaltWhenReceivesControl; // @synthesize shouldSetShouldHaltWhenReceivesControl=_shouldSetShouldHaltWhenReceivesControl;\n@property(retain) XCTestCaseRun *testCaseRun;\n\n+ (id)_baselineDictionary;\n+ (BOOL)_treatMissingBaselinesAsTestFailures;\n+ (id)knownMemoryMetrics;\n+ (id)measurementFormatter;\n+ (BOOL)_reportPerformanceFailuresForLargeImprovements;\n+ (BOOL)_enableSymbolication;\n\n+ (BOOL)isInheritingTestCases;\n+ (id)_testStartActvityDateFormatter;\n+ (id)testCaseWithSelector:(SEL)arg1;\n\n\n+ (void)tearDown;\n+ (void)setUp;\n+ (id)defaultTestSuite;\n+ (id)allTestMethodInvocations;\n+ (void)_allTestMethodInvocations:(id)arg1;\n+ (id)testMethodInvocations;\n+ (id)allSubclasses;\n- (void)startActivityWithTitle:(id)arg1 block:(CDUnknownBlockType)arg2;\n- (void)registerDefaultMetrics;\n- (id)baselinesDictionaryForTest;\n- (void)_logAndReportPerformanceMetrics:(id)arg1 perfMetricResultsForIDs:(id)arg2 withBaselinesForTest:(id)arg3;\n- (void)_logAndReportPerformanceMetrics:(id)arg1 perfMetricResultsForIDs:(id)arg2 withBaselinesForTest:(id)arg3 defaultBaselinesForPerfMetricID:(id)arg4;\n- (void)registerMetricID:(id)arg1 name:(id)arg2 unitString:(id)arg3;\n- (void)registerMetricID:(id)arg1 name:(id)arg2 unit:(id)arg3;\n- (void)reportMetric:(id)arg1 reportFailures:(BOOL)arg2;\n- (void)reportMeasurements:(id)arg1 forMetricID:(id)arg2 reportFailures:(BOOL)arg3;\n- (void)_recordValues:(id)arg1 forPerformanceMetricID:(id)arg2 name:(id)arg3 unitsOfMeasurement:(id)arg4 baselineName:(id)arg5 baselineAverage:(id)arg6 maxPercentRegression:(id)arg7 maxPercentRelativeStandardDeviation:(id)arg8 maxRegression:(id)arg9 maxStandardDeviation:(id)arg10 file:(id)arg11 line:(unsigned long long)arg12;\n- (id)_symbolicationRecordForTestCodeInAddressStack:(id)arg1;\n- (void)stopMeasuring;\n- (void)startMeasuring;\n- (BOOL)_isMeasuringMetrics;\n- (BOOL)_didStopMeasuring;\n- (BOOL)_didStartMeasuring;\n- (BOOL)_didMeasureMetrics;\n- (id)_perfMetricsForID;\n- (void)_logMemoryGraphDataFromFilePath:(id)arg1 withTitle:(id)arg2;\n- (void)_logMemoryGraphData:(id)arg1 withTitle:(id)arg2;\n- (unsigned long long)numberOfTestIterationsForTestWithSelector:(SEL)arg1;\n- (void)afterTestIteration:(unsigned long long)arg1 selector:(SEL)arg2;\n- (void)beforeTestIteration:(unsigned long long)arg1 selector:(SEL)arg2;\n- (void)tearDownTestWithSelector:(SEL)arg1;\n- (void)setUpTestWithSelector:(SEL)arg1;\n- (void)performTest:(id)arg1;\n- (void)invokeTest;\n- (Class)testRunClass;\n- (Class)_requiredTestRunBaseClass;\n- (void)_recordUnexpectedFailureWithDescription:(id)arg1 error:(id)arg2;\n- (void)_recordUnexpectedFailureWithDescription:(id)arg1 exception:(id)arg2;\n// Exists since Xcode 9.4.1, at least\n- (void)recordFailureWithDescription:(NSString *)arg1 inFile:(NSString *)arg2 atLine:(NSUInteger)arg3 expected:(BOOL)arg4;\n- (void)_enqueueFailureWithDescription:(NSString *)description inFile:(NSString *)filePath atLine:(NSUInteger)lineNumber expected:(BOOL)expected;\n- (void)_dequeueFailures;\n- (void)_interruptTest;\n- (BOOL)isEqual:(id)arg1;\n- (id)nameForLegacyLogging;\n- (id)name;\n- (id)languageAgnosticTestMethodName;\n- (unsigned long long)testCaseCount;\n- (id)initWithSelector:(SEL)arg1;\n- (id)init;\n- (void)waiter:(id)arg1 didFulfillInvertedExpectation:(id)arg2;\n- (void)waiter:(id)arg1 fulfillmentDidViolateOrderingConstraintsForExpectation:(id)arg2 requiredExpectation:(id)arg3;\n- (void)waiter:(id)arg1 didTimeoutWithUnfulfilledExpectations:(id)arg2;\n- (id)expectationForPredicate:(id)arg1 evaluatedWithObject:(id)arg2 handler:(CDUnknownBlockType)arg3;\n- (id)expectationForNotification:(id)arg1 object:(id)arg2 handler:(CDUnknownBlockType)arg3;\n- (id)keyValueObservingExpectationForObject:(id)arg1 keyPath:(id)arg2 handler:(CDUnknownBlockType)arg3;\n- (id)keyValueObservingExpectationForObject:(id)arg1 keyPath:(id)arg2 expectedValue:(id)arg3;\n- (void)_addExpectation:(id)arg1;\n- (void)waitForExpectations:(id)arg1 timeout:(double)arg2 enforceOrder:(BOOL)arg3;\n- (void)waitForExpectations:(id)arg1 timeout:(double)arg2;\n- (void)waitForExpectationsWithTimeout:(double)arg1 handler:(CDUnknownBlockType)arg2;\n- (void)_waitForExpectations:(id)arg1 timeout:(double)arg2 enforceOrder:(BOOL)arg3 handler:(CDUnknownBlockType)arg4;\n- (id)expectationWithDescription:(id)arg1;\n- (id)_expectationForDarwinNotification:(id)arg1;\n- (void)nestedWaiter:(id)arg1 wasInterruptedByTimedOutWaiter:(id)arg2;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestCaseRun.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCTestRun.h>\n\n@interface XCTestCaseRun : XCTestRun\n{\n}\n\n- (void)_recordValues:(id)arg1 forPerformanceMetricID:(id)arg2 name:(id)arg3 unitsOfMeasurement:(id)arg4 baselineName:(id)arg5 baselineAverage:(id)arg6 maxPercentRegression:(id)arg7 maxPercentRelativeStandardDeviation:(id)arg8 maxRegression:(id)arg9 maxStandardDeviation:(id)arg10 file:(id)arg11 line:(unsigned long long)arg12;\n- (void)recordFailureInTest:(id)arg1 withDescription:(id)arg2 inFile:(id)arg3 atLine:(unsigned long long)arg4 expected:(BOOL)arg5;\n- (void)stop;\n- (void)start;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestCaseSuite.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCTestSuite.h>\n\n@interface XCTestCaseSuite : XCTestSuite\n{\n    Class _testCaseClass;\n}\n\n+ (id)emptyTestSuiteForTestCaseClass:(Class)arg1;\n- (void)tearDown;\n- (void)setUp;\n- (id)initWithTestCaseClass:(Class)arg1;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestConfiguration.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSSet, NSString, NSURL, NSUUID;\n\n@interface XCTestConfiguration : NSObject <NSSecureCoding>\n{\n    NSURL *_testBundleURL;\n    NSString *_testBundleRelativePath;\n    NSString *_absolutePath;\n    NSSet *_testsToSkip;\n    NSSet *_testsToRun;\n    BOOL _reportResultsToIDE;\n    NSUUID *_sessionIdentifier;\n    NSString *_pathToXcodeReportingSocket;\n    BOOL _disablePerformanceMetrics;\n    BOOL _treatMissingBaselinesAsFailures;\n    NSURL *_baselineFileURL;\n    NSString *_baselineFileRelativePath;\n    NSString *_targetApplicationPath;\n    NSString *_targetApplicationBundleID;\n    NSString *_productModuleName;\n    BOOL _reportActivities;\n    BOOL _testsMustRunOnMainThread;\n    BOOL _initializeForUITesting;\n    NSArray *_targetApplicationArguments;\n    NSDictionary *_targetApplicationEnvironment;\n    NSDictionary *_aggregateStatisticsBeforeCrash;\n    NSString *_automationFrameworkPath;\n    BOOL _emitOSLogs;\n}\n@property BOOL emitOSLogs; // @synthesize emitOSLogs=_emitOSLogs;\n@property(copy) NSString *automationFrameworkPath; // @synthesize automationFrameworkPath=_automationFrameworkPath;\n@property(copy) NSDictionary *aggregateStatisticsBeforeCrash; // @synthesize aggregateStatisticsBeforeCrash=_aggregateStatisticsBeforeCrash;\n@property(copy) NSArray *targetApplicationArguments; // @synthesize targetApplicationArguments=_targetApplicationArguments;\n@property(copy) NSDictionary *targetApplicationEnvironment; // @synthesize targetApplicationEnvironment=_targetApplicationEnvironment;\n@property BOOL initializeForUITesting; // @synthesize initializeForUITesting=_initializeForUITesting;\n@property BOOL testsMustRunOnMainThread; // @synthesize testsMustRunOnMainThread=_testsMustRunOnMainThread;\n@property BOOL reportActivities; // @synthesize reportActivities=_reportActivities;\n@property(copy) NSString *productModuleName; // @synthesize productModuleName=_productModuleName;\n@property(copy) NSString *targetApplicationBundleID; // @synthesize targetApplicationBundleID=_targetApplicationBundleID;\n@property(copy) NSString *targetApplicationPath; // @synthesize targetApplicationPath=_targetApplicationPath;\n@property BOOL treatMissingBaselinesAsFailures; // @synthesize treatMissingBaselinesAsFailures=_treatMissingBaselinesAsFailures;\n@property BOOL disablePerformanceMetrics; // @synthesize disablePerformanceMetrics=_disablePerformanceMetrics;\n@property BOOL reportResultsToIDE; // @synthesize reportResultsToIDE=_reportResultsToIDE;\n@property(copy, nonatomic) NSURL *baselineFileURL; // @synthesize baselineFileURL=_baselineFileURL;\n@property(copy) NSString *baselineFileRelativePath; // @synthesize baselineFileRelativePath=_baselineFileRelativePath;\n@property(copy) NSString *pathToXcodeReportingSocket; // @synthesize pathToXcodeReportingSocket=_pathToXcodeReportingSocket;\n@property(copy) NSUUID *sessionIdentifier; // @synthesize sessionIdentifier=_sessionIdentifier;\n@property(copy) NSSet *testsToSkip; // @synthesize testsToSkip=_testsToSkip;\n@property(copy) NSSet *testsToRun; // @synthesize testsToRun=_testsToRun;\n@property(copy, nonatomic) NSURL *testBundleURL; // @synthesize testBundleURL=_testBundleURL;\n@property(copy) NSString *testBundleRelativePath; // @synthesize testBundleRelativePath=_testBundleRelativePath;\n@property(copy) NSString *absolutePath; // @synthesize absolutePath=_absolutePath;\n\n+ (id)configurationWithContentsOfFile:(id)arg1;\n+ (id)activeTestConfiguration;\n+ (void)setActiveTestConfiguration:(id)arg1;\n\n- (BOOL)writeToFile:(id)arg1;\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestContext.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSArray, XCTestContextScope;\n\n@interface XCTestContext : NSObject\n{\n    BOOL _didHandleUIInterruption;\n    XCTestContextScope *_currentScope;\n}\n@property BOOL didHandleUIInterruption; // @synthesize didHandleUIInterruption=_didHandleUIInterruption;\n@property(retain, nonatomic) XCTestContextScope *currentScope; // @synthesize currentScope=_currentScope;\n@property(readonly, copy) NSArray *handlers;\n\n+ (CDUnknownBlockType)defaultAsynchronousUIElementHandler;\n\n- (BOOL)handleAsynchronousUIElement:(id)arg1;\n- (void)removeUIInterruptionMonitor:(id)arg1;\n- (id)addUIInterruptionMonitorWithDescription:(id)arg1 handler:(CDUnknownBlockType)arg2;\n- (void)performInScope:(CDUnknownBlockType)arg1;\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestContextScope.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSMutableArray;\n\n@interface XCTestContextScope : NSObject\n{\n    XCTestContextScope *_parentScope;\n    NSMutableArray *_handlers;\n}\n@property(copy) NSMutableArray *handlers; // @synthesize handlers=_handlers;\n@property(readonly) XCTestContextScope *parentScope; // @synthesize parentScope=_parentScope;\n\n- (id)initWithParentScope:(id)arg1;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestDriver.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <WebDriverAgentLib/CDStructures.h>\n\n#import \"XCDebugLogDelegate-Protocol.h\"\n#import \"XCTestDriverInterface-Protocol.h\"\n#import \"XCTestManager_TestsInterface-Protocol.h\"\n#import \"XCTestManager_IDEInterface-Protocol.h\"\n#import \"XCTestManager_ManagerInterface-Protocol.h\"\n\n@class DTXConnection, NSMutableArray, NSString, NSUUID, NSXPCConnection, XCTestConfiguration, XCTestSuite;\n\n@interface XCTestDriver : NSObject <XCTestManager_TestsInterface, XCTestDriverInterface, XCDebugLogDelegate>\n{\n    XCTestConfiguration *_testConfiguration;\n    NSObject<OS_dispatch_queue> *_queue;\n    NSMutableArray *_debugMessageBuffer;\n    int _debugMessageBufferOverflow;\n}\n@property int debugMessageBufferOverflow; // @synthesize debugMessageBufferOverflow=_debugMessageBufferOverflow;\n@property(retain) NSMutableArray *debugMessageBuffer; // @synthesize debugMessageBuffer=_debugMessageBuffer;\n@property(retain) NSObject<OS_dispatch_queue> *queue; // @synthesize queue=_queue;\n@property(readonly) XCTestConfiguration *testConfiguration; // @synthesize testConfiguration=_testConfiguration;\n\n- (void)runTestConfiguration:(id)arg1 completionHandler:(CDUnknownBlockType)arg2;\n- (void)runTestSuite:(id)arg1 completionHandler:(CDUnknownBlockType)arg2;\n- (void)reportStallOnMainThreadInTestCase:(id)arg1 method:(id)arg2 file:(id)arg3 line:(unsigned long long)arg4;\n- (BOOL)runTestsAndReturnError:(id *)arg1;\n- (id)_readyIDESession:(id *)arg1;\n- (int)_connectedSocketForIDESession:(id *)arg1;\n- (void)logDebugMessage:(id)arg1;\n- (id)initWithTestConfiguration:(id)arg1;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestDriverInterface-Protocol.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSNumber;\n\n@protocol XCTestDriverInterface\n- (id)_IDE_processWithToken:(NSNumber *)arg1 exitedWithStatus:(NSNumber *)arg2;\n- (id)_IDE_stopTrackingProcessWithToken:(NSNumber *)arg1;\n- (id)_IDE_processWithBundleID:(NSString *)arg1 path:(NSString *)arg2 pid:(NSNumber *)arg3 crashedUnderSymbol:(NSString *)arg4;\n- (id)_IDE_startExecutingTestPlanWithProtocolVersion:(NSNumber *)arg1;\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestExpectation.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n\n\n@class _XCTestExpectationImplementation;\n\n@interface XCTestExpectation : NSObject\n{\n    id _internalImplementation;\n}\n@property BOOL hasBeenWaitedOn;\n@property id <XCTestExpectationDelegate> delegate;\n@property(readonly, copy) NSArray *fulfillCallStackReturnAddresses;\n@property(readonly) BOOL fulfilled;\n@property BOOL hasInverseBehavior;\n@property(getter=isInverted) BOOL inverted;\n@property(nonatomic) BOOL assertForOverFulfill;\n@property(nonatomic) unsigned long long expectedFulfillmentCount;\n@property(nonatomic) unsigned long long fulfillmentCount;\n@property(readonly) unsigned long long fulfillmentToken;\n@property(readonly) _XCTestExpectationImplementation *internalImplementation; // @synthesize internalImplementation=_internalImplementation;\n@property(copy) NSString *expectationDescription;\n@property(readonly, nonatomic) NSObject<OS_dispatch_queue> *delegateQueue;\n@property(readonly, nonatomic) NSObject<OS_dispatch_queue> *queue;\n\n+ (id)expectationWithDescription:(id)arg1;\n\n- (void)cleanup;\n- (void)_queue_fulfillWithCallStackReturnAddresses:(id)arg1;\n- (void)fulfill;\n\n- (id)initWithDescription:(id)arg1;\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestExpectationDelegate-Protocol.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n@class XCTestExpectation;\n\n@protocol XCTestExpectationDelegate <NSObject>\n- (void)didFulfillExpectation:(XCTestExpectation *)arg1;\n@end\n\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestExpectationWaiter.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCTWaiter.h>\n\n@interface XCTestExpectationWaiter : XCTWaiter\n{\n}\n\n- (long long)wait:(double)arg1 forExpectations:(id)arg2 enforceOrder:(BOOL)arg3;\n- (long long)wait:(double)arg1 forExpectations:(id)arg2;\n\n@end\n\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestLog.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCTestObserver.h>\n\n#import \"XCTestObservation.h\"\n\n@class NSFileHandle, NSString;\n\n@interface XCTestLog : XCTestObserver <XCTestObservation>\n{\n}\n@property(readonly) NSFileHandle *logFileHandle;\n\n+ (id)_messageForTest:(id)arg1 didMeasureValues:(id)arg2 forPerformanceMetricID:(id)arg3 name:(id)arg4 unitsOfMeasurement:(id)arg5 baselineName:(id)arg6 baselineAverage:(id)arg7 maxPercentRegression:(id)arg8 maxPercentRelativeStandardDeviation:(id)arg9 maxRegression:(id)arg10 maxStandardDeviation:(id)arg11 file:(id)arg12 line:(unsigned long long)arg13;\n\n- (void)testCaseDidFail:(id)arg1 withDescription:(id)arg2 inFile:(id)arg3 atLine:(unsigned long long)arg4;\n- (void)_testCase:(id)arg1 didMeasureValues:(id)arg2 forPerformanceMetricID:(id)arg3 name:(id)arg4 unitsOfMeasurement:(id)arg5 baselineName:(id)arg6 baselineAverage:(id)arg7 maxPercentRegression:(id)arg8 maxPercentRelativeStandardDeviation:(id)arg9 maxRegression:(id)arg10 maxStandardDeviation:(id)arg11 file:(id)arg12 line:(unsigned long long)arg13;\n- (void)testCaseDidStop:(id)arg1;\n- (void)testCaseDidStart:(id)arg1;\n- (void)testSuiteDidFail:(id)arg1 withDescription:(id)arg2 inFile:(id)arg3 atLine:(unsigned long long)arg4;\n- (void)testSuiteDidStop:(id)arg1;\n- (void)testSuiteDidStart:(id)arg1;\n- (void)_testDidFail:(id)arg1 withDescription:(id)arg2 inFile:(id)arg3 atLine:(unsigned long long)arg4;\n- (void)testLogWithFormat:(id)arg1 arguments:(char *)arg2;\n- (void)testLogWithFormat:(id)arg1;\n- (id)dateFormatter;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestManager_IDEInterface-Protocol.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSArray, NSDictionary, NSNumber, NSString, XCActivityRecord;\n\n@protocol XCTestManager_IDEInterface\n- (id)_XCT_handleCrashReportData:(NSData *)arg1 fromFileWithName:(NSString *)arg2;\n- (id)_XCT_nativeFocusItemDidChangeAtTime:(NSNumber *)arg1 parameterSnapshot:(id/*XCElementSnapshot*/)arg2 applicationSnapshot:(id/*XCElementSnapshot*/)arg3;\n- (id)_XCT_recordedEventNames:(NSArray *)arg1 timestamp:(NSNumber *)arg2 duration:(NSNumber *)arg3 startLocation:(NSDictionary *)arg4 startElementSnapshot:(id/*XCElementSnapshot*/)arg5 startApplicationSnapshot:(id/*XCElementSnapshot*/)arg6 endLocation:(NSDictionary *)arg7 endElementSnapshot:(id/*XCElementSnapshot*/)arg8 endApplicationSnapshot:(id/*XCElementSnapshot*/)arg9;\n- (id)_XCT_testCase:(NSString *)arg1 method:(NSString *)arg2 didFinishActivity:(XCActivityRecord *)arg3;\n- (id)_XCT_testCase:(NSString *)arg1 method:(NSString *)arg2 willStartActivity:(XCActivityRecord *)arg3;\n- (id)_XCT_recordedOrientationChange:(NSString *)arg1;\n- (id)_XCT_recordedFirstResponderChangedWithApplicationSnapshot:(id/*XCElementSnapshot*/)arg1;\n- (id)_XCT_exchangeCurrentProtocolVersion:(NSNumber *)arg1 minimumVersion:(NSNumber *)arg2;\n- (id)_XCT_recordedKeyEventsWithApplicationSnapshot:(id/*XCElementSnapshot*/)arg1 characters:(NSString *)arg2 charactersIgnoringModifiers:(NSString *)arg3 modifierFlags:(NSNumber *)arg4;\n- (id)_XCT_logDebugMessage:(NSString *)arg1;\n- (id)_XCT_logMessage:(NSString *)arg1;\n- (id)_XCT_testMethod:(NSString *)arg1 ofClass:(NSString *)arg2 didMeasureMetric:(NSDictionary *)arg3 file:(NSString *)arg4 line:(NSNumber *)arg5;\n- (id)_XCT_testCase:(NSString *)arg1 method:(NSString *)arg2 didStallOnMainThreadInFile:(NSString *)arg3 line:(NSNumber *)arg4;\n- (id)_XCT_testCaseDidFinishForTestClass:(NSString *)arg1 method:(NSString *)arg2 withStatus:(NSString *)arg3 duration:(NSNumber *)arg4;\n- (id)_XCT_testCaseDidFailForTestClass:(NSString *)arg1 method:(NSString *)arg2 withMessage:(NSString *)arg3 file:(NSString *)arg4 line:(NSNumber *)arg5;\n- (id)_XCT_testCaseDidStartForTestClass:(NSString *)arg1 method:(NSString *)arg2;\n- (id)_XCT_testSuite:(NSString *)arg1 didFinishAt:(NSString *)arg2 runCount:(NSNumber *)arg3 withFailures:(NSNumber *)arg4 unexpected:(NSNumber *)arg5 testDuration:(NSNumber *)arg6 totalDuration:(NSNumber *)arg7;\n- (id)_XCT_testSuite:(NSString *)arg1 didStartAt:(NSString *)arg2;\n- (id)_XCT_initializationForUITestingDidFailWithError:(NSError *)arg1;\n- (id)_XCT_didBeginInitializingForUITesting;\n- (id)_XCT_didFinishExecutingTestPlan;\n- (id)_XCT_didBeginExecutingTestPlan;\n- (id)_XCT_testBundleReadyWithProtocolVersion:(NSNumber *)arg1 minimumVersion:(NSNumber *)arg2;\n- (id)_XCT_getProgressForLaunch:(id)arg1;\n- (id)_XCT_terminateProcess:(id)arg1;\n- (id)_XCT_launchProcessWithPath:(NSString *)arg1 bundleID:(NSString *)arg2 arguments:(NSArray *)arg3 environmentVariables:(NSDictionary *)arg4;\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestManager_ManagerInterface-Protocol.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <UIKit/UIKit.h>\n\n@class NSArray, NSDictionary, NSNumber, NSString, NSUUID, XCSynthesizedEventRecord, XCTouchGesture, NSXPCListenerEndpoint;\n\n@protocol XCTestManager_ManagerInterface\n// since Xcode9\n- (void)_XCT_requestBundleIDForPID:(int)arg1 reply:(void (^)(NSString *, NSError *))arg2;\n- (void)_XCT_loadAccessibilityWithTimeout:(double)arg1 reply:(void (^)(BOOL, NSError *))arg2;\n- (void)_XCT_injectVoiceRecognitionAudioInputPaths:(NSArray *)arg1 completion:(void (^)(BOOL, NSError *))arg2;\n- (void)_XCT_injectAssistantRecognitionStrings:(NSArray *)arg1 completion:(void (^)(BOOL, NSError *))arg2;\n- (void)_XCT_startSiriUIRequestWithAudioFileURL:(NSURL *)arg1 completion:(void (^)(BOOL, NSError *))arg2;\n- (void)_XCT_startSiriUIRequestWithText:(NSString *)arg1 completion:(void (^)(BOOL, NSError *))arg2;\n- (void)_XCT_requestDTServiceHubConnectionWithReply:(void (^)(NSXPCListenerEndpoint *, NSError *))arg1;\n- (void)_XCT_enableFauxCollectionViewCells:(void (^)(BOOL, NSError *))arg1;\n- (void)_XCT_setAXTimeout:(double)arg1 reply:(void (^)(int))arg2;\n- (void)_XCT_requestScreenshotWithReply:(void (^)(NSData *, NSError *))arg1;\n- (void)_XCT_sendString:(NSString *)arg1 maximumFrequency:(NSUInteger)arg2 completion:(void (^)(NSError *))arg3;\n- (void)_XCT_updateDeviceOrientation:(long long)arg1 completion:(void (^)(NSError *))arg2;\n- (void)_XCT_performDeviceEvent:(id/*XCDeviceEvent*/)arg1 completion:(void (^)(NSError *))arg2;\n- (void)_XCT_synthesizeEvent:(XCSynthesizedEventRecord *)arg1 completion:(void (^)(NSError *))arg2;\n- (void)_XCT_requestElementAtPoint:(CGPoint)arg1 reply:(void (^)(id/*XCAccessibilityElement*/, NSError *))arg2;\n- (void)_XCT_fetchParameterizedAttributeForElement:(id/*XCAccessibilityElement*/)arg1 attributes:(NSNumber *)arg2 parameter:(id)arg3 reply:(void (^)(id, NSError *))arg4;\n- (void)_XCT_setAttribute:(NSNumber *)arg1 value:(id)arg2 element:(id/*XCAccessibilityElement*/)arg3 reply:(void (^)(BOOL, NSError *))arg4;\n- (void)_XCT_fetchAttributes:(id)attributes forElement:(id)element reply:(void (^)(NSDictionary *, NSError *))reply;\n- (void)_XCT_fetchAttributesForElement:(id/*XCAccessibilityElement*/)arg1 attributes:(NSArray *)arg2 reply:(void (^)(NSDictionary *, NSError *))arg3;\n- (void)_XCT_terminateApplicationWithBundleID:(NSString *)arg1 completion:(void (^)(NSError *))arg2;\n- (void)_XCT_performAccessibilityAction:(int)arg1 onElement:(id/*XCAccessibilityElement*/)arg2 withValue:(id)arg3 reply:(void (^)(NSError *))arg4;\n- (void)_XCT_unregisterForAccessibilityNotification:(int)arg1 withRegistrationToken:(NSNumber *)arg2 reply:(void (^)(NSError *))arg3;\n- (void)_XCT_registerForAccessibilityNotification:(int)arg1 reply:(void (^)(NSNumber *, NSError *))arg2;\n- (void)_XCT_launchApplicationWithBundleID:(NSString *)arg1 arguments:(NSArray *)arg2 environment:(NSDictionary *)arg3 completion:(void (^)(NSError *))arg4;\n- (void)_XCT_startMonitoringApplicationWithBundleID:(NSString *)arg1;\n- (void)_XCT_requestBackgroundAssertionForPID:(int)arg1 reply:(void (^)(BOOL))arg2;\n- (void)_XCT_requestBackgroundAssertionWithReply:(void (^)(void))arg1;\n- (void)_XCT_registerTarget;\n- (void)_XCT_requestEndpointForTestTargetWithPID:(int)arg1 preferredBackendPath:(NSString *)arg2 reply:(void (^)(NSXPCListenerEndpoint *, NSError *))arg3;\n- (void)_XCT_requestSocketForSessionIdentifier:(NSUUID *)arg1 reply:(void (^)(NSFileHandle *))arg2;\n- (void)_XCT_exchangeProtocolVersion:(unsigned long long)arg1 reply:(void (^)(unsigned long long))arg2;\n\n// Available since Xcode9\n// The first screenID type changed from \"unsigned int\" to \"long long\" since Xcode 13.3 in XCTAutomationSupport.framework/XCTScreenshotRequest.h\n// but this place is still \"unsigned int\" in the header. Appium/WDA changes to \"long long\" for Xcode 13.3 x iOS 15.4 environment.\n- (void)_XCT_requestScreenshotOfScreenWithID:(long long)arg1 withRect:(struct CGRect)arg2 uti:(NSString *)arg3 compressionQuality:(double)arg4 withReply:(void (^)(NSData *, NSError *))arg5;\n- (void)_XCT_requestScreenshotOfScreenWithID:(long long)arg1 withRect:(struct CGRect)arg2 withReply:(void (^)(NSData *, NSError *))arg3;\n- (void)_XCT_requestSnapshotForElement:(id/*XCAccessibilityElement*/)arg1 attributes:(NSArray *)arg2 parameters:(NSDictionary *)arg3 reply:(void (^)(id/*XCElementSnapshot*/, NSError *))arg4;\n\n// Available since Xcode 12.5. Required (!!!) to use since Xcode 13\n- (void)_XCT_requestScreenshot:(/*XCTScreenshotRequest * */id)arg1 withReply:(void (^)(/*XCTImage * */id, NSError *))arg2;\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestManager_TestsInterface-Protocol.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSData, NSString;\n\n@protocol XCTestManager_TestsInterface\n- (void)_XCT_receivedAccessibilityNotification:(int)arg1 withPayload:(NSData *)arg2;\n- (void)_XCT_applicationWithBundleID:(NSString *)arg1 didUpdatePID:(int)arg2 andState:(unsigned long long)arg3;\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestMisuseObserver.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n#import \"XCTestObservation.h\"\n\n@class NSMutableArray, NSString, XCTestCase, XCTestSuite;\n\n@interface XCTestMisuseObserver : NSObject <XCTestObservation>\n{\n    CDUnknownBlockType _warningLogHandler;\n    NSMutableArray *_testSuiteStack;\n    XCTestCase *_currentTestCase;\n}\n@property(retain) XCTestCase *currentTestCase; // @synthesize currentTestCase=_currentTestCase;\n@property(readonly) NSMutableArray *testSuiteStack; // @synthesize testSuiteStack=_testSuiteStack;\n@property(readonly, copy) CDUnknownBlockType warningLogHandler; // @synthesize warningLogHandler=_warningLogHandler;\n@property(readonly) XCTestSuite *currentTestSuite;\n\n- (void)testCaseDidFinish:(id)arg1;\n- (void)testCase:(id)arg1 didFailWithDescription:(id)arg2 inFile:(id)arg3 atLine:(unsigned long long)arg4;\n- (void)testCaseWillStart:(id)arg1;\n- (void)testSuiteDidFinish:(id)arg1;\n- (void)testSuiteWillStart:(id)arg1;\n- (BOOL)testSuiteStackContainsTestSuite:(id)arg1;\n- (void)removeTestSuiteFromStack:(id)arg1;\n- (void)popCurrentTestSuite;\n- (void)pushTestSuite:(id)arg1;\n- (void)emitWarningLog:(id)arg1;\n\n- (id)initWithWarningLogHandler:(CDUnknownBlockType)arg1;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestObservation-Protocol.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSBundle, NSString, XCTestCase, XCTestSuite;\n\n@protocol XCTestObservation <NSObject>\n\n@optional\n- (void)testCaseDidFinish:(XCTestCase *)arg1;\n- (void)testCase:(XCTestCase *)arg1 didFailWithDescription:(NSString *)arg2 inFile:(NSString *)arg3 atLine:(unsigned long long)arg4;\n- (void)testCaseWillStart:(XCTestCase *)arg1;\n- (void)testSuiteDidFinish:(XCTestSuite *)arg1;\n- (void)testSuite:(XCTestSuite *)arg1 didFailWithDescription:(NSString *)arg2 inFile:(NSString *)arg3 atLine:(unsigned long long)arg4;\n- (void)testSuiteWillStart:(XCTestSuite *)arg1;\n- (void)testBundleDidFinish:(NSBundle *)arg1;\n- (void)testBundleWillStart:(NSBundle *)arg1;\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestObservationCenter.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSMutableSet;\n\n@interface XCTestObservationCenter : NSObject\n{\n    id _internalImplementation;\n}\n@property BOOL suspended;\n@property(readonly) NSMutableSet *observers;\n\n+ (id)sharedTestObservationCenter;\n\n- (void)_testCase:(id)arg1 didFinishActivity:(id)arg2;\n- (void)_testCase:(id)arg1 willStartActivity:(id)arg2;\n- (void)_testCaseDidFail:(id)arg1 withDescription:(id)arg2 inFile:(id)arg3 atLine:(unsigned long long)arg4;\n- (void)_testCase:(id)arg1 didMeasureValues:(id)arg2 forPerformanceMetricID:(id)arg3 name:(id)arg4 unitsOfMeasurement:(id)arg5 baselineName:(id)arg6 baselineAverage:(id)arg7 maxPercentRegression:(id)arg8 maxPercentRelativeStandardDeviation:(id)arg9 maxRegression:(id)arg10 maxStandardDeviation:(id)arg11 file:(id)arg12 line:(unsigned long long)arg13;\n- (void)_testCaseDidStop:(id)arg1;\n- (void)_testCaseDidStart:(id)arg1;\n- (void)_testSuiteDidFail:(id)arg1 withDescription:(id)arg2 inFile:(id)arg3 atLine:(unsigned long long)arg4;\n- (void)_testSuiteDidStop:(id)arg1;\n- (void)_testSuiteDidStart:(id)arg1;\n- (void)_suspendObservationForBlock:(CDUnknownBlockType)arg1;\n- (void)_suspendObservation;\n- (void)_resumeObservation;\n- (void)_observeTestExecutionForBlock:(CDUnknownBlockType)arg1;\n- (void)removeTestObserver:(id)arg1;\n- (void)addTestObserver:(id)arg1;\n- (void)_addLegacyTestObserver:(id)arg1;\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestObserver.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@interface XCTestObserver : NSObject\n{\n}\n\n- (void)testCaseDidFail:(id)arg1 withDescription:(id)arg2 inFile:(id)arg3 atLine:(unsigned long long)arg4;\n- (void)testCaseDidStop:(id)arg1;\n- (void)testCaseDidStart:(id)arg1;\n- (void)testSuiteDidFail:(id)arg1 withDescription:(id)arg2 inFile:(id)arg3 atLine:(unsigned long long)arg4;\n- (void)testSuiteDidStop:(id)arg1;\n- (void)testSuiteDidStart:(id)arg1;\n- (void)stopObserving;\n- (void)startObserving;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestProbe.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@interface XCTestProbe : NSObject\n{\n}\n\n+ (BOOL)isTesting;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestRun.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCTestRun.h>\n\n@class NSDate, XCTest, _XCInternalTestRun;\n\n@interface XCTestRun ()\n{\n    id _internalTestRun;\n}\n@property(readonly) _XCInternalTestRun *implementation; // @synthesize implementation=_internalTestRun;\n@property(readonly) BOOL hasSucceeded;\n@property unsigned long long unexpectedExceptionCountBeforeCrash;\n@property unsigned long long failureCountBeforeCrash;\n@property unsigned long long executionCountBeforeCrash;\n@property(readonly) unsigned long long testCaseCount;\n@property(readonly) unsigned long long unexpectedExceptionCount;\n@property(readonly) unsigned long long failureCount;\n@property(readonly) unsigned long long totalFailureCount;\n@property(readonly) unsigned long long executionCount;\n@property(readonly, copy) NSDate *stopDate;\n@property(readonly, copy) NSDate *startDate;\n@property(readonly) double testDuration;\n@property(readonly) double totalDuration;\n@property(readonly) XCTest *test;\n\n+ (id)testRunWithTest:(id)arg1;\n\n- (void)recordFailureWithDescription:(id)arg1 inFile:(id)arg2 atLine:(unsigned long long)arg3 expected:(BOOL)arg4;\n- (void)stop;\n- (void)start;\n- (id)init;\n- (id)initWithTest:(id)arg1;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestSuite.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCTest.h>\n\n@class NSArray, NSMutableArray, NSString;\n\n@interface XCTestSuite : XCTest\n{\n    id _internalImplementation;\n}\n@property(readonly, copy) NSArray *tests;\n@property(copy) NSString *name;\n\n+ (id)testSuiteForTestConfiguration:(id)arg1;\n+ (id)defaultTestSuite;\n+ (id)allTests;\n+ (id)testSuiteForTestCaseClass:(Class)arg1;\n+ (id)testSuiteForTestCaseWithName:(id)arg1;\n+ (id)testSuiteForBundlePath:(id)arg1;\n+ (id)suiteForBundleCache;\n+ (void)invalidateCache;\n+ (id)_suiteForBundleCache;\n+ (id)emptyTestSuiteNamedFromPath:(id)arg1;\n+ (id)testSuiteWithName:(id)arg1;\n+ (id)testCaseNamesForScopeNames:(id)arg1;\n\n- (id)_initWithTestConfiguration:(id)arg1;\n- (void)_sortTestsUsingComparator:(CDUnknownBlockType)arg1;\n- (void)performTest:(id)arg1;\n- (void)_performProtectedSectionForTest:(id)arg1 testSection:(CDUnknownBlockType)arg2;\n- (void)_recordUnexpectedFailureForTestRun:(id)arg1 description:(id)arg2 exception:(id)arg3;\n- (void)recordFailureWithDescription:(id)arg1 inFile:(id)arg2 atLine:(unsigned long long)arg3 expected:(BOOL)arg4;\n- (Class)testRunClass;\n- (Class)_requiredTestRunBaseClass;\n- (unsigned long long)testCaseCount;\n- (void)setTests:(id)arg1;\n- (void)addTest:(id)arg1;\n- (id)_testSuiteWithIdentifier:(id)arg1;\n- (id)description;\n- (id)initWithName:(id)arg1;\n- (id)init;\n- (void)removeTestsWithNames:(id)arg1;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestSuiteRun.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCTestRun.h>\n\n@class NSArray, NSMutableArray;\n\n@interface XCTestSuiteRun : XCTestRun\n{\n    NSMutableArray *_testRuns;\n}\n@property(readonly, copy) NSArray *testRuns;\n\n- (void)recordFailureWithDescription:(id)arg1 inFile:(id)arg2 atLine:(unsigned long long)arg3 expected:(BOOL)arg4;\n- (double)testDuration;\n- (unsigned long long)unexpectedExceptionCount;\n- (unsigned long long)failureCount;\n- (unsigned long long)executionCount;\n- (void)addTestRun:(id)arg1;\n- (void)stop;\n- (void)start;\n\n- (id)initWithTest:(id)arg1;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCTestWaiter.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCTWaiter.h>\n\n@interface XCTestWaiter : XCTWaiter\n{\n}\n\n@end\n\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCUIApplication.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCUIApplication.h>\n\n@class NSArray, NSDictionary, NSString, XCApplicationQuery, XCUIApplicationImpl;\n\n@interface XCUIApplication ()\n{\n    BOOL _ancillary;\n    BOOL _doesNotHandleUIInterruptions;\n    BOOL _idleAnimationWaitEnabled;\n    XCUIElement *_keyboard;\n    NSArray *_launchArguments;\n    NSDictionary *_launchEnvironment;\n    XCUIApplicationImpl *_applicationImpl;\n    XCApplicationQuery *_applicationQuery;\n    unsigned long long _generation;\n}\n@property unsigned long long generation; // @synthesize generation=_generation;\n@property(retain) XCApplicationQuery *applicationQuery; // @synthesize applicationQuery=_applicationQuery;\n@property(retain) XCUIApplicationImpl *applicationImpl; // @synthesize applicationQuery=_applicationQuery;\n@property(readonly, copy) NSString *bundleID; // @synthesize bundleID=_bundleID;\n@property(readonly, copy) NSString *path; // @synthesize path=_path;\n@property BOOL ancillary; // @synthesize ancillary=_ancillary;\n@property(readonly) XCUIElement *keyboard; // @synthesize keyboard=_keyboard;\n\n@property(getter=isIdleAnimationWaitEnabled) BOOL idleAnimationWaitEnabled; // @synthesize idleAnimationWaitEnabled=_idleAnimationWaitEnabled;\n@property(nonatomic) BOOL doesNotHandleUIInterruptions; // @synthesize doesNotHandleUIInterruptions=_doesNotHandleUIInterruptions;\n@property(readonly) BOOL fauxCollectionViewCellsEnabled;\n#if !TARGET_OS_TV\n@property(readonly, nonatomic) UIInterfaceOrientation interfaceOrientation; //TODO tvos\n#endif\n@property(readonly, nonatomic) BOOL running;\n@property(nonatomic) pid_t processID; // @synthesize processID=_processID;\n@property(readonly) id/*XCAccessibilityElement*/ accessibilityElement;\n\n+ (instancetype)applicationWithPID:(pid_t)processID;\n- (void)activate;\n\n- (void)dismissKeyboard;\n- (BOOL)setFauxCollectionViewCellsEnabled:(BOOL)arg1 error:(id *)arg2;\n- (void)_waitForViewControllerViewDidDisappearWithTimeout:(double)arg1;\n- (void)_waitForQuiescence;\n- (void)terminate;\n- (void)_launchUsingXcode:(BOOL)arg1;\n- (void)launch;\n- (id)application;\n- (id)description;\n- (id)lastSnapshot;\n- (XCUIElementQuery *)query;\n- (void)clearQuery;\n- (void)resolveHandleUIInterruption:(BOOL)arg1;\n- (id)initPrivateWithPath:(id)arg1 bundleID:(id)arg2;\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCUIApplicationImpl.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <Foundation/Foundation.h>\n\n@class NSString, XCUIApplicationProcess;\n\n@interface XCUIApplicationImpl : NSObject\n{\n    NSString *_path;\n    NSString *_bundleID;\n    XCUIApplicationProcess *_currentProcess;\n}\n\n@property(retain, nonatomic) XCUIApplicationProcess *currentProcess; // @synthesize currentProcess=_currentProcess;\n@property(readonly, copy) NSString *bundleID; // @synthesize bundleID=_bundleID;\n@property(readonly, copy) NSString *path; // @synthesize path=_path;\n@property(nonatomic) unsigned long long state;\n@property(nonatomic) int processID;\n@property(readonly) id/*XCAccessibilityElement*/ accessibilityElement;\n\n- (instancetype)initWithPath:(id)arg1 bundleID:(id)arg2;\n\n- (void)launchWithArguments:(id)arg1 environment:(id)arg2 usingXcode:(BOOL)arg3;\n- (void)handleCrashUnderSymbol:(id)arg1;\n- (void)terminate;\n\n- (void)waitForViewControllerViewDidDisappearWithTimeout:(double)arg1;\n\n- (void)_waitForRunningActive;\n- (void)_launchUsingPlatformWithArguments:(id)arg1 environment:(id)arg2;\n- (void)_launchUsingXcodeWithArguments:(id)arg1 environment:(id)arg2;\n- (void)_waitForLaunchProgress;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCUIApplicationProcess.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <Foundation/Foundation.h>\n\n#import <CDStructures.h>\n\n@class XCAXClient_iOS;\n@class XCApplicationMonitor;\n@class XCUIApplicationImpl;\n@protocol XCTestManager_IDEInterface;\n@protocol XCTRunnerAutomationSession;\n\n@interface XCUIApplicationProcess : NSObject\n{\n    NSObject<OS_dispatch_queue> *_queue;\n    BOOL _accessibilityActive;\n    unsigned long long _applicationState;\n    int _processID;\n    id _token;\n    int _exitCode;\n    BOOL _eventLoopHasIdled;\n    BOOL _hasReceivedEventLoopHasIdled;\n    BOOL _animationsHaveFinished;\n    BOOL _hasReceivedAnimationsHaveFinished;\n    BOOL _hasExitCode;\n    BOOL _hasCrashReport;\n    NSString *_bundleID;\n    XCUIApplicationImpl *_applicationImplementation;\n    id <XCTRunnerAutomationSession> _automationSession;\n    id/*XCElementSnapshot*/ _lastSnapshot;\n    XCApplicationMonitor *_applicationMonitor;\n    XCAXClient_iOS *_AXClient_iOS;\n}\n\n+ (BOOL)automaticallyNotifiesObserversForKey:(id)arg1;\n@property XCAXClient_iOS *AXClient_iOS; // @synthesize AXClient_iOS=_AXClient_iOS;\n// Since Xcode 10\n@property(retain) id/*XCElementSnapshot*/ lastSnapshot; // @synthesize lastSnapshot=_lastSnapshot;\n@property XCApplicationMonitor *applicationMonitor; // @synthesize applicationMonitor=_applicationMonitor;\n@property(retain) id <XCTRunnerAutomationSession> automationSession; // @synthesize automationSession=_automationSession;\n@property BOOL hasCrashReport; // @synthesize hasCrashReport=_hasCrashReport;\n@property BOOL hasExitCode; // @synthesize hasExitCode=_hasExitCode;\n@property BOOL hasReceivedAnimationsHaveFinished;\n@property BOOL animationsHaveFinished;\n@property BOOL hasReceivedEventLoopHasIdled;\n@property BOOL eventLoopHasIdled;\n@property int exitCode;\n@property(retain) id token;\n@property(nonatomic) int processID;\n// Since Xcode 10.2\n@property(readonly, copy, nonatomic) NSString *bundleID; // @synthesize bundleID=_bundleID;\n@property(readonly) BOOL running;\n@property XCUIApplicationImpl *applicationImplementation; // @synthesize applicationImplementation=_applicationImplementation;\n@property(nonatomic) unsigned long long applicationState;\n@property(nonatomic) BOOL accessibilityActive;\n@property(readonly, copy) id/*XCAccessibilityElement*/ accessibilityElement;\n\n- (id)init;\n- (id)initWithApplicationMonitor:(id)arg1 AXInterface:(id)arg2;\n\n- (void)terminate;\n- (void)waitForViewControllerViewDidDisappearWithTimeout:(double)arg1;\n- (void)waitForAutomationSession;\n// Before Xcode16-beta5\n- (void)waitForQuiescenceIncludingAnimationsIdle:(BOOL)arg1;\n// Since Xcode16-beta5\n- (void)waitForQuiescenceIncludingAnimationsIdle:(BOOL)arg1 isPreEvent:(BOOL)arg2;\n\n\n- (id)shortDescription;\n- (id)_queue_description;\n\n// Gone with iOS 10.3\n- (void)waitForQuiescence;\n\n// Since Xcode 10.2\n- (void)_notifyWhenAnimationsAreIdle:(void (^)(id, void *))arg1;\n- (_Bool)_supportsAnimationsIdleNotifications;\n- (void)_notifyWhenMainRunLoopIsIdle:(void (^)(id, void *))arg1;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCUICoordinate.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <TargetConditionals.h>\n#import <XCTest/XCUICoordinate.h>\n\n@class XCUIElement;\n\n#if !TARGET_OS_TV\n@interface XCUICoordinate ()\n{\n    XCUIElement *_element;\n    XCUICoordinate *_coordinate;\n    CGVector _normalizedOffset;\n    CGVector _pointsOffset;\n}\n\n@property(readonly) CGVector pointsOffset; // @synthesize pointsOffset=_pointsOffset;\n@property(readonly) CGVector normalizedOffset; // @synthesize normalizedOffset=_normalizedOffset;\n@property(readonly) XCUICoordinate *coordinate; // @synthesize coordinate=_coordinate;\n@property(readonly) XCUIElement *element; // @synthesize element=_element;\n\n- (id)initWithCoordinate:(id)arg1 pointsOffset:(CGVector)arg2;\n- (id)initWithElement:(id)arg1 normalizedOffset:(CGVector)arg2;\n- (id)init;\n\n- (void)pressForDuration:(double)arg1 thenDragToCoordinate:(id)arg2;\n- (void)pressForDuration:(double)arg1;\n- (void)doubleTap;\n- (void)tap;\n- (void)pressWithPressure:(double)arg1 duration:(double)arg2;\n- (void)forcePress;\n\n// Since Xcode 12\n- (void)pressForDuration:(double)duration\n    thenDragToCoordinate:(XCUICoordinate *)otherCoordinate\n            withVelocity:(CGFloat)velocity\n     thenHoldForDuration:(double)holdDuration;\n\n@end\n#endif\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCUIDevice.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCUIDevice.h>\n\n@interface XCUIDevice ()\n\n// Since Xcode 10.2\n@property (readonly) id accessibilityInterface; // implements XCUIAccessibilityInterface\n@property (readonly) id eventSynthesizer; // implements XCUIEventSynthesizing\n@property (readonly) id screenDataSource; // @synthesize screenDataSource=_screenDataSource;\n\n- (_Bool)performDeviceEvent:(id)arg1 error:(id *)arg2;\n\n// Since Xcode 13\n// 1 - Light\n// 2 - Dark\n- (void)setAppearanceMode:(long long)arg1;\n- (long long)appearanceMode;\n\n- (void)pressLockButton;\n- (void)holdHomeButtonForDuration:(double)arg1;\n- (void)_silentPressButton:(long long)arg1;\n// Since Xcode 11\n- (_Bool)supportsPressureInteraction;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCUIElement.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCUIElement.h>\n\n@class NSString, XCUIApplication, XCUICoordinate, XCUIElementQuery;\n\n@interface XCUIElement ()\n{\n    BOOL _safeQueryResolutionEnabled;\n    XCUIElementQuery *_query;\n    id/*XCElementSnapshot*/ _lastSnapshot;\n}\n\n@property BOOL safeQueryResolutionEnabled; // @synthesize safeQueryResolutionEnabled=_safeQueryResolutionEnabled;\n@property(retain) id/*XCElementSnapshot*/ lastSnapshot; // @synthesize lastSnapshot=_lastSnapshot;\n@property(readonly) XCUIElementQuery *query; // @synthesize query=_query;\n#if !TARGET_OS_TV\n@property(readonly, nonatomic) UIInterfaceOrientation interfaceOrientation;\n#endif\n@property(readonly, copy) XCUICoordinate *hitPointCoordinate;\n@property(readonly) BOOL isTopLevelTouchBarElement;\n@property(readonly) BOOL isTouchBarElement;\n@property(readonly) BOOL hasKeyboardFocus;\n@property(readonly, nonatomic) XCUIApplication *application;\n// Added since Xcode 11.0 (beta)\n@property(readonly, copy) XCUIElement *excludingNonModalElements;\n// Added since Xcode 11.0 (GM)\n@property(readonly, copy) XCUIElement *includingNonModalElements;\n\n- (id)initWithElementQuery:(id)arg1;\n\n- (unsigned long long)traits;\n- (void)resolveHandleUIInterruption:(BOOL)arg1;\n- (BOOL)waitForExistenceWithTimeout:(double)arg1;\n- (BOOL)_waitForExistenceWithTimeout:(double)arg1;\n- (id)_hitPointByAttemptingToScrollToVisibleSnapshot:(id)arg1 error:(id *)arg2;\n- (BOOL)evaluatePredicateForExpectation:(id)arg1 debugMessage:(id *)arg2;\n- (void)_swipe:(unsigned long long)arg1;\n- (void)_tapWithNumberOfTaps:(unsigned long long)arg1 numberOfTouches:(unsigned long long)arg2 activityTitle:(id)arg3;\n- (id)_highestNonWindowAncestorOfElement:(id)arg1 notSharedWithElement:(id)arg2;\n- (id)_pointsInFrame:(CGRect)arg1 numberOfTouches:(unsigned long long)arg2;\n// Since 11.3\n- (void)pressWithPressure:(double)arg1 duration:(double)arg2;\n- (void)forcePress;\n- (void)tapWithNumberOfTaps:(unsigned long long)arg1 numberOfTouches:(unsigned long long)arg2;\n- (void)twoFingerTap;\n- (void)doubleTap;\n- (void)tap;\n- (void)pressForDuration:(double)arg1 thenDragToElement:(id)arg2;\n- (void)pressForDuration:(double)arg1;\n\n// Available since Xcode 11.0\n- (_Bool)resolveOrRaiseTestFailure:(_Bool)arg1 error:(id *)arg2;\n- (void)resolveOrRaiseTestFailure;\n// Available since Xcode 10.0\n- (id)screenshot;\n\n// Since Xcode 11.4\n- (void)swipeRightWithVelocity:(double)arg1;\n- (void)swipeLeftWithVelocity:(double)arg1;\n- (void)swipeDownWithVelocity:(double)arg1;\n- (void)swipeUpWithVelocity:(double)arg1;\n\n// Since Xcode 12\n- (void)pressForDuration:(double)duration\n       thenDragToElement:(XCUIElement *)otherElement\n            withVelocity:(CGFloat)velocity\n     thenHoldForDuration:(double)holdDuration;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCUIElementAsynchronousHandlerWrapper.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSString, NSUUID;\n\n@interface XCUIElementAsynchronousHandlerWrapper : NSObject\n{\n    CDUnknownBlockType _handler;\n    NSString *_handlerDescription;\n    NSUUID *_identifier;\n}\n@property(copy) NSUUID *identifier; // @synthesize identifier=_identifier;\n@property(copy) NSString *handlerDescription; // @synthesize handlerDescription=_handlerDescription;\n@property(copy) CDUnknownBlockType handler; // @synthesize handler=_handler;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCUIElementHitPointCoordinate.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCUICoordinate.h>\n#import <TargetConditionals.h>\n\n#if !TARGET_OS_IPHONE\n\n@interface XCUIElementHitPointCoordinate : XCUICoordinate\n{\n}\n\n- (id)description;\n- (struct CGPoint)screenPoint;\n- (id)initWithCoordinate:(id)arg1 pointsOffset:(struct CGVector)arg2;\n- (id)initWithElement:(id)arg1 normalizedOffset:(struct CGVector)arg2;\n- (id)initWithElement:(id)arg1;\n\n@end\n\n#endif\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCUIElementQuery.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <CDStructures.h>\n#import <XCTest/XCUIElementQuery.h>\n#import \"XCTElementSetTransformer-Protocol.h\"\n\n@class NSArray, NSOrderedSet, NSString, XCUIApplication, XCUIElement;\n\n@interface XCUIElementQuery ()\n{\n    BOOL _changesScope;\n    NSString *_queryDescription;\n    XCUIElementQuery *_inputQuery;\n    CDUnknownBlockType _filter;\n    unsigned long long _expressedType;\n    NSArray *_expressedIdentifiers;\n    NSOrderedSet *_lastInput;\n    NSOrderedSet *_lastOutput;\n    id/*XCElementSnapshot*/ _rootElementSnapshot;\n    // Added since Xcode 11.0 (beta)\n    BOOL _modalViewPruningDisabled;\n}\n\n@property(copy) NSOrderedSet *lastOutput; // @synthesize lastOutput=_lastOutput;\n@property(copy) NSOrderedSet *lastInput; // @synthesize lastInput=_lastInput;\n@property(copy) NSArray *expressedIdentifiers; // @synthesize expressedIdentifiers=_expressedIdentifiers;\n@property unsigned long long expressedType; // @synthesize expressedType=_expressedType;\n@property BOOL changesScope; // @synthesize changesScope=_changesScope;\n@property(readonly, copy) CDUnknownBlockType filter; // @synthesize filter=_filter;\n// Added since Xcode 11.0 (beta)\n@property BOOL modalViewPruningDisabled; // @synthesize modalViewPruningDisabled=_modalViewPruningDisabled;\n@property(readonly) XCUIElementQuery *inputQuery; // @synthesize inputQuery=_inputQuery;\n@property(readonly, copy) NSString *queryDescription; // @synthesize queryDescription=_queryDescription;\n@property(readonly, copy) NSString *elementDescription;\n@property(readonly) XCUIApplication *application;\n@property(retain) id/*XCElementSnapshot*/ rootElementSnapshot; // @synthesize rootElementSnapshot=_rootElementSnapshot;\n@property(retain) NSObject<XCTElementSetTransformer> *transformer; // @synthesize transformer = _transformer;\n\n// Added since Xcode 11.0 (beta)\n@property(readonly, copy) XCUIElementQuery *excludingNonModalElements;\n// Added since Xcode 11.0 (GM)\n@property(readonly, copy) XCUIElementQuery *includingNonModalElements;\n\n- (id)matchingSnapshotsWithError:(id *)arg1;\n- (id)matchingSnapshotsHandleUIInterruption:(BOOL)arg1 withError:(id *)arg2;\n- (id)_elementMatchingAccessibilityElementOfSnapshot:(id)arg1;\n- (id)_containingPredicate:(id)arg1 queryDescription:(id)arg2;\n- (id)_predicateWithType:(unsigned long long)arg1 identifier:(id)arg2;\n- (id)_queryWithPredicate:(id)arg1;\n- (id)sorted:(CDUnknownBlockType)arg1;\n- (id)descending:(unsigned long long)arg1;\n- (id)ascending:(unsigned long long)arg1;\n- (id)filter:(CDUnknownBlockType)arg1;\n- (id)_debugInfoWithIndent:(id *)arg1;\n- (id)_derivedExpressedIdentifiers;\n- (unsigned long long)_derivedExpressedType;\n- (id)initWithInputQuery:(id)arg1 queryDescription:(id)arg2 filter:(CDUnknownBlockType)arg3;\n\n// Added since Xcode 11.0\n- (id/*XCElementSnapshot*/)elementSnapshotForDebugDescriptionWithNoMatchesMessage:(id *)arg1;\n// Added since Xcode 11.0\n- (id/*XCElementSnapshot*/)uniqueMatchingSnapshotWithError:(NSError **)arg1;\n/*! DO NOT USE DIRECTLY! Please use fb_firstMatch instead */\n- (XCUIElement *)firstMatch;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCUIHitPointResult.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <Foundation/Foundation.h>\n#import <CoreGraphics/CoreGraphics.h>\n\n@interface XCUIHitPointResult : NSObject\n{\n    BOOL _hittable;\n    CGPoint _hitPoint;\n}\n\n@property(readonly, getter=isHittable) BOOL hittable; // @synthesize hittable=_hittable;\n@property(readonly) struct CGPoint hitPoint; // @synthesize hitPoint=_hitPoint;\n- (id)initWithHitPoint:(struct CGPoint)arg1 hittable:(BOOL)arg2;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCUIRecorderNodeFinder.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSArray, NSMutableArray, NSSet, XCUIRecorderNodeFinderMatch;\n\n@interface XCUIRecorderNodeFinder : NSObject\n{\n    unsigned long long _state;\n    NSSet *_descendantsWithTargetElementType;\n    NSArray *_childrenWithTargetElementType;\n    BOOL _allowDirectChildrenMatches;\n    BOOL _shouldAttemptToUseIdentifier;\n    BOOL _shouldAttemptToUsePlaceholderValue;\n    BOOL _shouldAttemptToUseLabel;\n    BOOL _shouldAttemptToUseTitle;\n    BOOL _shouldAttemptToUseTruncatedValueString;\n    BOOL _allowElementQueries;\n    BOOL _excludeUnlessNecessary;\n    NSMutableArray *_mutableFoundNodeMatches;\n    NSMutableArray *_unprocessedContainsMatches;\n    XCUIRecorderNodeFinderMatch *_ancestorNodeFinderMatch;\n    unsigned long long _targetSnapshotIndex;\n    id/*XCElementSnapshot*/ _targetSnapshot;\n    unsigned long long _language;\n    unsigned long long _platform;\n}\n\n+ (id)nodeToFindElementForSnapshots:(id)arg1 language:(unsigned long long)arg2 platform:(unsigned long long)arg3;\n+ (id)_nodeFindersForSnapshots:(id)arg1 ancestorMatch:(id)arg2 ancestorIndex:(unsigned long long)arg3 stopCombinatorialExpansionIndexes:(id)arg4 excludeUnlessNecessaryElementTypes:(id)arg5 language:(unsigned long long)arg6 platform:(unsigned long long)arg7;\n+ (id)_excludeUnlessNecessaryElementTypesForPlatform:(unsigned long long)arg1;\n+ (id)_stopCombinatorialExpansionElementTypesForPlatform:(unsigned long long)arg1;\n@property BOOL excludeUnlessNecessary; // @synthesize excludeUnlessNecessary=_excludeUnlessNecessary;\n@property BOOL allowElementQueries; // @synthesize allowElementQueries=_allowElementQueries;\n@property unsigned long long platform; // @synthesize platform=_platform;\n@property unsigned long long language; // @synthesize language=_language;\n@property(retain) id/*XCElementSnapshot*/ targetSnapshot; // @synthesize targetSnapshot=_targetSnapshot;\n@property unsigned long long targetSnapshotIndex; // @synthesize targetSnapshotIndex=_targetSnapshotIndex;\n@property(retain) XCUIRecorderNodeFinderMatch *ancestorNodeFinderMatch; // @synthesize ancestorNodeFinderMatch=_ancestorNodeFinderMatch;\n@property(retain) NSMutableArray *unprocessedContainsMatches; // @synthesize unprocessedContainsMatches=_unprocessedContainsMatches;\n@property(retain) NSMutableArray *mutableFoundNodeMatches; // @synthesize mutableFoundNodeMatches=_mutableFoundNodeMatches;\n- (id)descendantsQueryNodeWithTargetElementTypeContainingElementsOfType:(unsigned long long)arg1 identifierValue:(id)arg2;\n- (id)childrenQueryNodeWithTargetElementTypeAndIdentifierValue:(id)arg1;\n- (id)descendantsQueryNodeWithElementType:(unsigned long long)arg1;\n- (id)descendantsQueryNodeWithTargetElementTypeAndIdentifierValue:(id)arg1;\n- (id)childAtIndexNodeWithTargetElementType;\n- (id)childAtIndexNodeWithTargetElementTypeAndIdentifierValue:(id)arg1;\n- (id)uniqueChildNodeWithTargetElementType;\n- (id)uniqueChildNodeWithTargetElementTypeAndIdentifierValue:(id)arg1;\n- (id)uniqueDescendantNodeWithTargetElementType;\n- (id)uniqueDescendantNodeWithTargetElementTypeAndIdentifierValue:(id)arg1;\n- (id)descendantsWithTargetElementTypeContainingDescendantElementsWithType:(unsigned long long)arg1 identifierValue:(id)arg2;\n- (id)childrenWithTargetElementType;\n- (id)childrenWithTargetElementTypeAndIdentifierValue:(id)arg1;\n- (id)descendantsWithTargetElementType;\n- (id)descendantsWithTargetElementTypeAndIdentifierValue:(id)arg1;\n- (id)nextNodeFinderMatch;\n- (id)_stringConstantForString:(id)arg1;\n- (void)removeFromAncestorNodeFinderMatch;\n- (void)invalidate;\n- (id)initWithTargetSnapshot:(id)arg1 targetSnapshotIndex:(unsigned long long)arg2 ancestorMatch:(id)arg3 allowElementQueries:(BOOL)arg4 excludeUnlessNecessary:(BOOL)arg5 language:(unsigned long long)arg6 platform:(unsigned long long)arg7;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCUIRecorderNodeFinderMatch.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSMutableArray, NSSet, XCSourceCodeTreeNode, XCUIRecorderNodeFinder;\n\n@interface XCUIRecorderNodeFinderMatch : NSObject\n{\n    NSSet *_matchingSnapshots;\n    XCSourceCodeTreeNode *_node;\n    XCUIRecorderNodeFinder *_ancestorFinder;\n    NSMutableArray *_descendantFinders;\n}\n@property(retain) NSMutableArray *descendantFinders; // @synthesize descendantFinders=_descendantFinders;\n@property(retain) XCUIRecorderNodeFinder *ancestorFinder; // @synthesize ancestorFinder=_ancestorFinder;\n@property(retain) XCSourceCodeTreeNode *node; // @synthesize node=_node;\n@property(copy) NSSet *matchingSnapshots; // @synthesize matchingSnapshots=_matchingSnapshots;\n\n- (void)invalidate;\n- (id)nodeIncludingDescendants;\n- (id)initWithNode:(id)arg1 matchingSnapshots:(id)arg2 ancestorFinder:(id)arg3;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCUIRecorderTimingMessage.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSString;\n\n@interface XCUIRecorderTimingMessage : NSObject\n{\n    double _start;\n    NSString *_message;\n}\n@property(copy) NSString *message; // @synthesize message=_message;\n@property double start; // @synthesize start=_start;\n\n+ (id)descriptionForTimingMessages:(id)arg1;\n+ (id)messageWithString:(id)arg1;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCUIRecorderUtilities.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSMutableString;\n\n@interface XCUIRecorderUtilities : NSObject\n{\n    unsigned long long _language;\n    unsigned long long _platform;\n    unsigned long long _compareSnapshotsLikePlatform;\n    id/*XCAccessibilityElement*/ _previousFocusedAccessibilityElement;\n    NSMutableString *_previousTyping;\n}\n@property(retain) NSMutableString *previousTyping; // @synthesize previousTyping=_previousTyping;\n@property(retain) id/*XCAccessibilityElement*/ previousFocusedAccessibilityElement; // @synthesize previousFocusedAccessibilityElement=_previousFocusedAccessibilityElement;\n@property unsigned long long _compareSnapshotsLikePlatform; // @synthesize _compareSnapshotsLikePlatform;\n@property unsigned long long language; // @synthesize language=_language;\n@property unsigned long long platform; // @synthesize platform=_platform;\n\n+ (id)applicationNodeForLanguage:(unsigned long long)arg1;\n+ (unsigned long long)currentPlatform;\n\n- (id)performWithKeyModifiersAndBlockNodeForModifierFlags:(unsigned long long)arg1;\n- (id)gestureNodesForKeyDownEventWithCharacters:(id)arg1 charactersIgnoringModifiers:(id)arg2 modifierFlags:(unsigned long long)arg3 focusedAccessibilityElement:(id)arg4 didAppendToPreviousString:(BOOL *)arg5;\n- (id)_stringConstantForString:(id)arg1;\n- (void)clearPreviousTyping;\n- (id)nodeToFindElementForSnapshots:(id)arg1;\n- (id)typeKeyNodeForKey:(id)arg1 modifierFlags:(unsigned long long)arg2;\n- (id)typeStringNodeForString:(id)arg1;\n- (id)stringForKeyModifierFlags:(unsigned long long)arg1;\n- (id)simpleGestureNodeForMethodName:(id)arg1;\n- (id)assertHasFocusNode;\n- (id)remoteNodeWithButtonSymbolName:(id)arg1;\n- (id)commentNodeWithString:(id)arg1;\n- (id)applicationNode;\n- (id)focusedAccessibilityElementForApplicationSnapshot:(id)arg1;\n- (id)snapshotsForAccessibilityElement:(id)arg1 applicationSnapshot:(id)arg2;\n- (id)snapshotInTreeStartingWithSnapshot:(id)arg1 forElement:(id)arg2;\n- (id)_snapshotInTreeStartingWithSnapshot:(id)arg1 passingPredicateBlock:(CDUnknownBlockType)arg2;\n- (id)nodeForOrientationChangeWithSymbolName:(id)arg1;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCUIScreen.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit) (Debug version compiled Nov 29 2017 14:55:25).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2015 by Steve Nygard.\n//\n\n@interface XCUIScreen()\n{\n  _Bool _isMainScreen;\n  long long _displayID;\n}\n@property(readonly) _Bool isMainScreen; // @synthesize isMainScreen=_isMainScreen;\n@property(readonly) long long displayID; // @synthesize displayID=_displayID;\n\n- (id)_clippedScreenshotData:(id)arg1 quality:(long long)arg2 rect:(struct CGRect)arg3 scale:(double)arg4;\n- (id)_screenshotDataForQuality:(long long)arg1 rect:(struct CGRect)arg2 error:(id *)arg3;\n- (id)screenshotDataForQuality:(long long)arg1 rect:(struct CGRect)arg2 error:(id *)arg3;\n- (id)screenshotDataForQuality:(long long)arg1 rect:(struct CGRect)arg2;\n- (id)_modernScreenshotDataForQuality:(long long)arg1 rect:(struct CGRect)arg2 error:(id *)arg3;\n- (id)screenshot;\n- (id)_imageFromData:(id)arg1;\n- (double)scale;\n\n- (id)initWithDisplayID:(long long)arg1 isMainScreen:(_Bool)arg2 device:(id)arg3 screenDataSource:(id)arg4;\n\n@end\n\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/XCUIScreenDataSource-Protocol.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSString;\n\n@protocol XCUIScreenDataSource <NSObject>\n- (void)requestScreenshotOfScreenWithID:(long long)arg1\n                               withRect:(struct CGRect)arg2\n                                  scale:(double)arg3\n                              formatUTI:(NSString *)arg4\n                     compressionQuality:(double)arg5\n                              withReply:(void (^)(NSData *, NSError *))arg6;\n- (void)requestScaleForScreenWithIdentifier:(long long)arg1 completion:(void (^)(double, NSError *))arg2;\n- (void)requestScreenIdentifiersWithCompletion:(void (^)(NSArray *, NSError *))arg1;\n@end\n\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/_XCInternalTestRun.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSDate, XCTest;\n\n@interface _XCInternalTestRun : NSObject\n{\n    XCTest *_test;\n    double _startTimeInterval;\n    double _stopTimeInterval;\n    unsigned long long _executionCount;\n    unsigned long long _failureCount;\n    unsigned long long _unexpectedExceptionCount;\n    BOOL _hasStarted;\n    BOOL _hasStopped;\n    unsigned long long _executionCountBeforeCrash;\n    unsigned long long _failureCountBeforeCrash;\n    unsigned long long _unexpectedExceptionCountBeforeCrash;\n}\n\n@property unsigned long long unexpectedExceptionCountBeforeCrash; // @synthesize unexpectedExceptionCountBeforeCrash=_unexpectedExceptionCountBeforeCrash;\n@property unsigned long long failureCountBeforeCrash; // @synthesize failureCountBeforeCrash=_failureCountBeforeCrash;\n@property unsigned long long executionCountBeforeCrash; // @synthesize executionCountBeforeCrash=_executionCountBeforeCrash;\n@property(readonly) BOOL hasStopped; // @synthesize hasStopped=_hasStopped;\n@property(readonly) XCTest *test; // @synthesize test=_test;\n@property(readonly) unsigned long long testCaseCount;\n@property(readonly) unsigned long long unexpectedExceptionCount;\n@property(readonly) unsigned long long failureCount;\n@property(readonly) unsigned long long executionCount;\n@property(readonly, copy) NSDate *stopDate;\n@property(readonly, copy) NSDate *startDate;\n@property(readonly) double testDuration;\n@property(readonly) double totalDuration;\n\n- (id)initWithTest:(id)arg1;\n- (void)stop;\n- (void)start;\n- (void)recordFailureWithDescription:(id)arg1 inFile:(id)arg2 atLine:(unsigned long long)arg3 expected:(BOOL)arg4;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/_XCKVOExpectationImplementation.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n@class NSObject<OS_dispatch_queue>, NSString, XCTKVOExpectation;\n\n@interface _XCKVOExpectationImplementation : NSObject\n{\n    XCTKVOExpectation *_expectation;\n    id _observedObject;\n    NSString *_keyPath;\n    id _expectedValue;\n    unsigned long long _options;\n    CDUnknownBlockType _handler;\n    NSObject<OS_dispatch_queue> *_queue;\n    BOOL _hasCleanedUp;\n}\n@property(readonly) unsigned long long options; // @synthesize options=_options;\n@property(readonly) id expectedValue; // @synthesize expectedValue=_expectedValue;\n@property(readonly, copy) NSString *keyPath; // @synthesize keyPath=_keyPath;\n@property(readonly) id observedObject; // @synthesize observedObject=_observedObject;\n@property(copy) CDUnknownBlockType handler;\n\n- (void)cleanup;\n- (void)observeValueForKeyPath:(id)arg1 ofObject:(id)arg2 change:(id)arg3 context:(void *)arg4;\n- (id)initWithKeyPath:(id)arg1 object:(id)arg2 expectedValue:(id)arg3 expectation:(id)arg4 options:(unsigned long long)arg5;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/_XCTDarwinNotificationExpectationImplementation.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n@class NSObject<OS_dispatch_queue>, NSString, XCTDarwinNotificationExpectation;\n\n@interface _XCTDarwinNotificationExpectationImplementation : NSObject\n{\n    XCTDarwinNotificationExpectation *_expectation;\n    NSString *_notificationName;\n    int _notifyToken;\n    CDUnknownBlockType _handler;\n    NSObject<OS_dispatch_queue> *_queue;\n    BOOL _hasCleanedUp;\n}\n@property(readonly, copy) NSString *notificationName; // @synthesize notificationName=_notificationName;\n@property(copy) CDUnknownBlockType handler;\n\n- (void)cleanup;\n- (void)_handleNotification;\n- (id)initWithNotificationName:(id)arg1 expectation:(id)arg2;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/_XCTNSNotificationExpectationImplementation.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n@class NSNotificationCenter, NSObject<OS_dispatch_queue>, NSString, XCTNSNotificationExpectation;\n\n@interface _XCTNSNotificationExpectationImplementation : NSObject\n{\n    XCTNSNotificationExpectation *_expectation;\n    id _observedObject;\n    NSString *_notificationName;\n    NSNotificationCenter *_notificationCenter;\n    CDUnknownBlockType _handler;\n    NSObject<OS_dispatch_queue> *_queue;\n    BOOL _hasCleanedUp;\n}\n@property(readonly) NSNotificationCenter *notificationCenter; // @synthesize notificationCenter=_notificationCenter;\n@property(readonly, copy) NSString *notificationName; // @synthesize notificationName=_notificationName;\n@property(readonly) id observedObject; // @synthesize observedObject=_observedObject;\n@property(copy) CDUnknownBlockType handler;\n\n- (void)cleanup;\n- (void)_observeExpectedNotification:(id)arg1;\n- (id)initWithName:(id)arg1 object:(id)arg2 notificationCenter:(id)arg3 expectation:(id)arg4;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/_XCTNSPredicateExpectationImplementation.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n@class NSObject<OS_dispatch_queue>, NSPredicate, NSString, NSTimer, XCTNSPredicateExpectation;\n\n@interface _XCTNSPredicateExpectationImplementation : NSObject\n{\n    XCTNSPredicateExpectation *_expectation;\n    id <XCTNSPredicateExpectationObject> _object;\n    NSPredicate *_predicate;\n    CDUnknownBlockType _handler;\n    NSTimer *_timer;\n    NSObject<OS_dispatch_queue> *_queue;\n    BOOL _hasCleanedUp;\n}\n@property(readonly, copy) NSPredicate *predicate; // @synthesize predicate=_predicate;\n@property(readonly) id <XCTNSPredicateExpectationObject> object; // @synthesize object=_object;\n@property(copy) CDUnknownBlockType handler;\n\n- (void)cleanup;\n- (void)_considerFulfilling;\n- (id)initWithPredicate:(id)arg1 object:(id)arg2 expectation:(id)arg3;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/_XCTWaiterImpl.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import \"NSObject.h\"\n\n@class NSArray, NSMutableArray, NSObject<OS_dispatch_queue>, XCTWaiterManager;\n\n@interface _XCTWaiterImpl : NSObject\n{\n    id <XCTWaiterDelegate> _delegate;\n    XCTWaiterManager *_manager;\n    NSArray *_waitCallStackReturnAddresses;\n    NSObject<OS_dispatch_queue> *_queue;\n    NSObject<OS_dispatch_queue> *_delegateQueue;\n    NSArray *_expectations;\n    NSMutableArray *_fulfilledExpectations;\n    struct __CFRunLoop *_waitingRunLoop;\n    long long _state;\n    double _timeout;\n    long long _result;\n    BOOL _enforceOrderOfFulfillment;\n}\n@property BOOL enforceOrderOfFulfillment; // @synthesize enforceOrderOfFulfillment=_enforceOrderOfFulfillment;\n@property long long result; // @synthesize result=_result;\n@property long long state; // @synthesize state=_state;\n@property(readonly, nonatomic) NSMutableArray *fulfilledExpectations; // @synthesize fulfilledExpectations=_fulfilledExpectations;\n@property(copy, nonatomic) NSArray *expectations; // @synthesize expectations=_expectations;\n@property(readonly, nonatomic) NSObject<OS_dispatch_queue> *delegateQueue; // @synthesize delegateQueue=_delegateQueue;\n@property(readonly, nonatomic) NSObject<OS_dispatch_queue> *queue; // @synthesize queue=_queue;\n@property XCTWaiterManager *manager; // @synthesize manager=_manager;\n@property id <XCTWaiterDelegate> delegate; // @synthesize delegate=_delegate;\n@property double timeout; // @synthesize timeout=_timeout;\n@property struct __CFRunLoop *waitingRunLoop; // @synthesize waitingRunLoop=_waitingRunLoop;\n@property(copy) NSArray *waitCallStackReturnAddresses; // @synthesize waitCallStackReturnAddresses=_waitCallStackReturnAddresses;\n\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/_XCTestCaseImplementation.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSArray, NSInvocation, NSMutableArray, NSMutableDictionary, NSMutableSet, NSString, XCTestCaseRun, XCTestContext, XCTestExpectationWaiter, XCTWaiter;\n\n#import <WebDriverAgentLib/CDStructures.h>\n\n@interface _XCTestCaseImplementation : NSObject\n{\n    NSInvocation *_invocation;\n    XCTestCaseRun *_testCaseRun;\n    BOOL _continueAfterFailure;\n    NSMutableSet *_expectations;\n    NSArray *_activePerformanceMetricIDs;\n    NSMutableDictionary *_perfMetricsForID;\n    unsigned long long _startWallClockTime;\n    struct time_value _startUserTime;\n    struct time_value _startSystemTime;\n    unsigned long long _measuringIteration;\n    BOOL _isMeasuringMetrics;\n    BOOL _didMeasureMetrics;\n    BOOL _didStartMeasuring;\n    BOOL _didStopMeasuring;\n    NSString *_filePathForUnexpectedFailure;\n    unsigned long long _lineNumberForUnexpectedFailure;\n    unsigned long long _callAddressForCurrentWait;\n    NSArray *_callAddressesForLastCreatedExpectation;\n    long long _runLoopNestingCount;\n    XCTWaiter *_currentWaiter;\n    NSMutableArray *_failureRecords;\n    BOOL _shouldHaltWhenReceivesControl;\n    BOOL _shouldIgnoreSubsequentFailures;\n    NSMutableArray *_activityRecordStack;\n    XCTestContext *_testContext;\n}\n\n@property(readonly) XCTestContext *testContext; // @synthesize testContext=_testContext;\n@property(retain, nonatomic) XCTWaiter *currentWaiter; // @synthesize currentWaiter=_currentWaiter;\n@property(retain, nonatomic) NSMutableArray *activityRecordStack; // @synthesize activityRecordStack=_activityRecordStack;\n@property BOOL shouldIgnoreSubsequentFailures; // @synthesize shouldIgnoreSubsequentFailures=_shouldIgnoreSubsequentFailures;\n@property BOOL shouldHaltWhenReceivesControl; // @synthesize shouldHaltWhenReceivesControl=_shouldHaltWhenReceivesControl;\n@property(retain, nonatomic) NSMutableArray *failureRecords; // @synthesize failureRecords=_failureRecords;\n@property long long runLoopNestingCount; // @synthesize runLoopNestingCount=_runLoopNestingCount;\n@property(copy) NSArray *callAddressesForLastCreatedExpectation; // @synthesize callAddressesForLastCreatedExpectation=_callAddressesForLastCreatedExpectation;\n@property unsigned long long callAddressForCurrentWait; // @synthesize callAddressForCurrentWait=_callAddressForCurrentWait;\n@property unsigned long long lineNumberForUnexpectedFailure; // @synthesize lineNumberForUnexpectedFailure=_lineNumberForUnexpectedFailure;\n@property(copy) NSString *filePathForUnexpectedFailure; // @synthesize filePathForUnexpectedFailure=_filePathForUnexpectedFailure;\n@property(retain, nonatomic) NSMutableSet *expectations; // @synthesize expectations=_expectations;\n@property BOOL didStopMeasuring; // @synthesize didStopMeasuring=_didStopMeasuring;\n@property BOOL didStartMeasuring; // @synthesize didStartMeasuring=_didStartMeasuring;\n@property BOOL didMeasureMetrics; // @synthesize didMeasureMetrics=_didMeasureMetrics;\n@property BOOL isMeasuringMetrics; // @synthesize isMeasuringMetrics=_isMeasuringMetrics;\n@property unsigned long long measuringIteration; // @synthesize measuringIteration=_measuringIteration;\n@property struct time_value startUserTime; // @synthesize startUserTime=_startUserTime;\n@property struct time_value startSystemTime; // @synthesize startSystemTime=_startSystemTime;\n@property unsigned long long startWallClockTime; // @synthesize startWallClockTime=_startWallClockTime;\n@property(retain) NSMutableDictionary *perfMetricsForID; // @synthesize perfMetricsForID=_perfMetricsForID;\n@property(copy) NSArray *activePerformanceMetricIDs; // @synthesize activePerformanceMetricIDs=_activePerformanceMetricIDs;\n@property BOOL continueAfterFailure; // @synthesize continueAfterFailure=_continueAfterFailure;\n@property(retain) XCTestCaseRun *testCaseRun; // @synthesize testCaseRun=_testCaseRun;\n@property(retain) NSInvocation *invocation; // @synthesize invocation=_invocation;\n\n- (void)resetExpectations;\n- (void)addExpectation:(id)arg1;\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/_XCTestCaseInterruptionException.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@interface _XCTestCaseInterruptionException : NSException\n{\n}\n\n+ (void)interruptTest;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/_XCTestExpectationImplementation.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSString, XCTestCase;\n\n@interface _XCTestExpectationImplementation : NSObject\n{\n    BOOL _fulfilled;\n    NSString *_expectationDescription;\n    id <XCTestExpectationDelegate> _delegate;\n    BOOL _hasBeenWaitedOn;\n    unsigned long long _expectedFulfillmentCount;\n    unsigned long long _numberOfFulfillments;\n    unsigned long long _fulfillmentToken;\n    NSArray *_fulfillCallStackReturnAddresses;\n    BOOL _inverted;\n    BOOL _assertForOverFulfill;\n    NSObject<OS_dispatch_queue> *_queue;\n    NSObject<OS_dispatch_queue> *_delegateQueue;\n}\n\n@property(readonly, nonatomic) NSObject<OS_dispatch_queue> *delegateQueue; // @synthesize delegateQueue=_delegateQueue;\n@property(readonly, nonatomic) NSObject<OS_dispatch_queue> *queue; // @synthesize queue=_queue;\n@property(nonatomic) unsigned long long numberOfFulfillments; // @synthesize numberOfFulfillments=_numberOfFulfillments;\n@property(nonatomic) unsigned long long expectedFulfillmentCount; // @synthesize expectedFulfillmentCount=_expectedFulfillmentCount;\n@property(copy) NSArray *fulfillCallStackReturnAddresses; // @synthesize fulfillCallStackReturnAddresses=_fulfillCallStackReturnAddresses;\n@property unsigned long long fulfillmentToken; // @synthesize fulfillmentToken=_fulfillmentToken;\n@property BOOL assertForOverFulfill; // @synthesize assertForOverFulfill=_assertForOverFulfill;\n@property BOOL inverted; // @synthesize inverted=_inverted;\n@property BOOL hasBeenWaitedOn; // @synthesize hasBeenWaitedOn=_hasBeenWaitedOn;\n@property(retain) id <XCTestExpectationDelegate> delegate; // @synthesize delegate=_delegate;\n@property(copy) NSString *expectationDescription; // @synthesize expectationDescription=_expectationDescription;\n@property BOOL fulfilled; // @synthesize fulfilled=_fulfilled;\n\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/_XCTestImplementation.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class XCTestRun;\n\n@interface _XCTestImplementation : NSObject\n{\n    XCTestRun *_testRun;\n}\n@property(retain) XCTestRun *testRun; // @synthesize testRun=_testRun;\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/_XCTestObservationCenterImplementation.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n@class NSMutableSet;\n\n@interface _XCTestObservationCenterImplementation : NSObject\n{\n    NSMutableArray *_observers;\n    BOOL _suspended;\n}\n\n@property BOOL suspended; // @synthesize suspended=_suspended;\n@property(retain) NSMutableArray *observers; // @synthesize observers=_observers;\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/PrivateHeaders/XCTest/_XCTestSuiteImplementation.h",
    "content": "//\n//     Generated by class-dump 3.5 (64 bit).\n//\n//     class-dump is Copyright (C) 1997-1998, 2000-2001, 2004-2013 by Steve Nygard.\n//\n\n#import <XCTest/XCTest.h>\n\n@class NSMutableArray, NSString, XCTestConfiguration;\n\n@interface _XCTestSuiteImplementation : XCTest\n{\n    NSString *_name;\n    NSMutableArray *_tests;\n    XCTestConfiguration *_testConfiguration;\n}\n@property(retain) XCTestConfiguration *testConfiguration; // @synthesize testConfiguration=_testConfiguration;\n@property(retain) NSMutableArray *tests; // @synthesize tests=_tests;\n@property(copy) NSString *name; // @synthesize name=_name;\n\n- (id)initWithName:(id)arg1;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Extensions/Logger.swift",
    "content": "\nimport Foundation\nimport os\n\nextension Logger {\n    func measure<T>(message: String, _ block: () throws -> T) rethrows -> T {\n        let start = Date()\n        info(\"\\(message) - start\")\n\n        let result = try block()\n\n        let duration = Date().timeIntervalSince(start)\n        NSLog(\"\\(message) - duration \\(duration)\")\n\n        return result\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Extensions/StringExtensions.swift",
    "content": "import Foundation\n\n\nextension String {\n    func toUInt16() -> UInt16? {\n        return UInt16(self)\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Extensions/XCUIElement+Extensions.swift",
    "content": "import Foundation\nimport XCTest\n\nextension XCUIElement {\n    func setText(text: String, application: XCUIApplication) {\n        UIPasteboard.general.string = text\n        doubleTap()\n        application.menuItems[\"Paste\"].tap()\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/DeviceInfoHandler.swift",
    "content": "import Foundation\nimport FlyingFox\nimport os\nimport XCTest\nimport Network\n\n@MainActor\nstruct DeviceInfoHandler: HTTPHandler {\n    private let logger = Logger(\n        subsystem: Bundle.main.bundleIdentifier!,\n        category: String(describing: Self.self)\n    )\n\n    func handleRequest(_ request: HTTPRequest) async throws -> HTTPResponse {\n        do {\n            let (width, height, orientation) = try ScreenSizeHelper.actualScreenSize()\n            NSLog(\"Device orientation is \\(String(orientation.rawValue))\")\n\n            let deviceInfo = DeviceInfoResponse(\n                widthPoints: Int(width),\n                heightPoints: Int(height),\n                widthPixels: Int(CGFloat(width) * UIScreen.main.scale),\n                heightPixels: Int(CGFloat(height) * UIScreen.main.scale)\n            )\n\n            let responseBody = try JSONEncoder().encode(deviceInfo)\n            return HTTPResponse(statusCode: .ok, body: responseBody)\n        } catch let error {\n            return AppError(message: \"Getting device info call failed. Error \\(error.localizedDescription)\").httpResponse\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/EraseTextHandler.swift",
    "content": "import Foundation\nimport FlyingFox\nimport os\nimport XCTest\nimport Network\n\n@MainActor\nstruct EraseTextHandler: HTTPHandler {\n    private let logger = Logger(\n        subsystem: Bundle.main.bundleIdentifier!,\n        category: String(describing: Self.self)\n    )\n\n    func handleRequest(_ request: HTTPRequest) async throws -> HTTPResponse {\n        guard let requestBody = try? await JSONDecoder().decode(EraseTextRequest.self, from: request.bodyData) else {\n            return AppError(type: .precondition, message: \"incorrect request body for erase text request\").httpResponse\n        }\n        \n        do {\n            let start = Date()\n            \n            await TextInputHelper.waitUntilKeyboardIsPresented()\n\n            let deleteText = String(repeating: XCUIKeyboardKey.delete.rawValue, count: requestBody.charactersToErase)\n            \n            try await TextInputHelper.inputText(deleteText)\n            \n            let duration = Date().timeIntervalSince(start)\n            logger.info(\"Erase text duration took \\(duration)\")\n            return HTTPResponse(statusCode: .ok)\n        } catch let error {\n            logger.error(\"Error erasing text of \\(requestBody.charactersToErase) characters: \\(error)\")\n            return AppError(message: \"Failure in doing erase text, error: \\(error.localizedDescription)\").httpResponse\n        }\n    }\n    \n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/InputTextRouteHandler.swift",
    "content": "import FlyingFox\nimport XCTest\nimport os\n\n@MainActor\nstruct InputTextRouteHandler : HTTPHandler {\n    private let logger = Logger(\n        subsystem: Bundle.main.bundleIdentifier!,\n        category: String(describing: Self.self)\n    )\n\n    func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> FlyingFox.HTTPResponse {\n        guard let requestBody = try? await JSONDecoder().decode(InputTextRequest.self, from: request.bodyData) else {\n            return AppError(type: .precondition, message: \"incorrect request body provided for input text\").httpResponse\n        }\n\n        do {\n            let start = Date()\n            \n            await TextInputHelper.waitUntilKeyboardIsPresented()\n            \n            try await TextInputHelper.inputText(requestBody.text)\n\n            let duration = Date().timeIntervalSince(start)\n            logger.info(\"Text input duration took \\(duration)\")\n            return HTTPResponse(statusCode: .ok)\n        } catch {\n            return AppError(message: \"Error inputting text: \\(error.localizedDescription)\").httpResponse\n        }\n    }\n    \n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/KeyboardRouteHandler.swift",
    "content": "import Foundation\nimport XCTest\nimport FlyingFox\nimport os\n\n@MainActor\nstruct KeyboardRouteHandler: HTTPHandler {\n    \n    func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> FlyingFox.HTTPResponse {\n        guard let requestBody = try? await JSONDecoder().decode(KeyboardHandlerRequest.self, from: request.bodyData) else {\n            return AppError(type: .precondition, message: \"incorrect request body provided for input text\").httpResponse\n        }\n        \n        do {\n            let appId = RunningApp.getForegroundAppId(requestBody.appIds)\n            let keyboard = XCUIApplication(bundleIdentifier: appId).keyboards.firstMatch\n            let isKeyboardVisible = keyboard.exists\n            \n            let keyboardInfo = KeyboardHandlerResponse(isKeyboardVisible: isKeyboardVisible)\n            let responseBody = try JSONEncoder().encode(keyboardInfo)\n            return HTTPResponse(statusCode: .ok, body: responseBody)\n        } catch let error {\n            return AppError(message: \"Keyboard handler failed \\(error.localizedDescription)\").httpResponse\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/LaunchAppHandler.swift",
    "content": "import FlyingFox\nimport XCTest\nimport os\n\n@MainActor\nstruct LaunchAppHandler: HTTPHandler {\n    \n    private let logger = Logger(\n        subsystem: Bundle.main.bundleIdentifier!,\n        category: String(describing: Self.self)\n    )\n \n    func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> HTTPResponse {\n        guard let requestBody = try? await JSONDecoder().decode(LaunchAppRequest.self, from: request.bodyData) else {\n            return AppError(type: .precondition, message: \"incorrect request body provided\").httpResponse\n        }\n        \n        NSLog(\"[Start] Launching app with bundle ID: \\(requestBody.bundleId)\")\n        XCUIApplication(bundleIdentifier: requestBody.bundleId).activate()\n        NSLog(\"[Done] Launching app with bundle ID: \\(requestBody.bundleId)\")\n\n        \n        return HTTPResponse(statusCode: .ok)\n    }\n    \n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/PressButtonHandler.swift",
    "content": "import Foundation\nimport FlyingFox\nimport os\nimport XCTest\nimport Network\n\n@MainActor\nstruct PressButtonHandler: HTTPHandler {\n    private let logger = Logger(\n        subsystem: Bundle.main.bundleIdentifier!,\n        category: String(describing: Self.self)\n    )\n\n    func handleRequest(_ request: HTTPRequest) async throws -> HTTPResponse {\n        guard let requestBody = try? await JSONDecoder().decode(PressButtonRequest.self, from: request.bodyData) else {\n            return AppError(type: .precondition, message: \"Incorrect request body for PressButton Handler\").httpResponse\n        }\n        \n        switch requestBody.button {\n        case .home:\n            XCUIDevice.shared.press(.home)\n        case .lock:\n            XCUIDevice.shared.perform(NSSelectorFromString(\"pressLockButton\"))\n        }\n        return HTTPResponse(statusCode: .ok)\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/PressKeyHandler.swift",
    "content": "import Foundation\nimport FlyingFox\nimport os\nimport XCTest\n\n@MainActor\nstruct PressKeyHandler: HTTPHandler {\n    private let typingFrequency = 30\n\n    private let logger = Logger(\n        subsystem: Bundle.main.bundleIdentifier!,\n        category: String(describing: Self.self)\n    )\n\n    func handleRequest(_ request: HTTPRequest) async throws -> HTTPResponse {\n        guard let requestBody = try? await JSONDecoder().decode(PressKeyRequest.self, from: request.bodyData) else {\n            return AppError(type: .precondition, message: \"Incorrect request body for press key handler\").httpResponse\n        }\n\n        do {\n            var eventPath = PointerEventPath.pathForTextInput()\n            eventPath.type(text: requestBody.xctestKey, typingSpeed: typingFrequency)\n            let eventRecord = EventRecord(orientation: ScreenSizeHelper.currentInterfaceOrientation())\n            _ = eventRecord.add(eventPath)\n            try await RunnerDaemonProxy().synthesize(eventRecord: eventRecord)\n\n            return HTTPResponse(statusCode: .ok)\n        } catch let error {\n            return AppError(message: \"Press key handler failed, error: \\(error.localizedDescription)\").httpResponse\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/RunningAppRouteHandler.swift",
    "content": "import FlyingFox\nimport XCTest\nimport os\n\n@MainActor\nstruct RunningAppRouteHandler: HTTPHandler {\n    private let logger = Logger(\n        subsystem: Bundle.main.bundleIdentifier!,\n        category: String(describing: Self.self)\n    )\n    \n    private static let springboardBundleId = \"com.apple.springboard\"\n    \n    func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> FlyingFox.HTTPResponse {\n        guard let requestBody = try? await JSONDecoder().decode(RunningAppRequest.self, from: request.bodyData) else {\n            return AppError(type: .precondition, message: \"incorrect request body for getting running app id request\").httpResponse\n        }\n        \n        do {\n            let runningAppId = requestBody.appIds.first { appId in\n                let app = XCUIApplication(bundleIdentifier: appId)\n                \n                return app.state == .runningForeground\n            }\n            \n            let response = [\"runningAppBundleId\": runningAppId ?? RunningAppRouteHandler.springboardBundleId]\n            \n            let responseData = try JSONSerialization.data(\n                withJSONObject: response,\n                options: .prettyPrinted\n            )\n            return HTTPResponse(statusCode: .ok, body: responseData)\n        } catch let error {\n            return AppError(message: \"Failure in getting running app, error: \\(error.localizedDescription)\").httpResponse\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/ScreenDiffHandler.swift",
    "content": "import Foundation\nimport XCTest\nimport CryptoKit\nimport FlyingFox\nimport os\n\n@MainActor\nstruct IsScreenStaticHandler: HTTPHandler {\n    private let logger = Logger(\n        subsystem: Bundle.main.bundleIdentifier!,\n        category: String(describing: Self.self)\n    )\n    \n    func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> FlyingFox.HTTPResponse {\n        do {\n            let screenshot1 = XCUIScreen.main.screenshot()\n            let screenshot2 = XCUIScreen.main.screenshot()\n            let hash1 = SHA256.hash(data: screenshot1.pngRepresentation)\n            let hash2 = SHA256.hash(data: screenshot2.pngRepresentation)\n            \n            let isScreenStatic = hash1 == hash2\n            \n            let response = [\"isScreenStatic\" : isScreenStatic]\n            \n            let responseData = try JSONSerialization.data(\n                withJSONObject: response,\n                options: .prettyPrinted\n            )\n            return HTTPResponse(statusCode: .ok, body: responseData)\n        } catch let error {\n            return AppError(message: \"Detecting screen static request failed. Error \\(error.localizedDescription)\").httpResponse\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/ScreenshotHandler.swift",
    "content": "import FlyingFox\nimport XCTest\nimport os\n\n@MainActor\nstruct ScreenshotHandler: HTTPHandler {\n    private let logger = Logger(\n        subsystem: Bundle.main.bundleIdentifier!,\n        category: String(describing: Self.self)\n    )\n    \n    func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> FlyingFox.HTTPResponse {\n        let compressed = request.query[\"compressed\"] == \"true\"\n        \n        let fullScreenshot = XCUIScreen.main.screenshot()\n        let image = compressed ? fullScreenshot.image.jpegData(compressionQuality: 0.5) : fullScreenshot.pngRepresentation\n        \n        guard let image = image else {\n            return AppError(type: .precondition, message: \"incorrect request body received for screenshot request\").httpResponse\n        }\n        \n        return HTTPResponse(statusCode: .ok, body: image)\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/SetOrientationHandler.swift",
    "content": "import Foundation\nimport FlyingFox\nimport os\nimport XCTest\n\n@MainActor\nstruct SetOrientationHandler: HTTPHandler {\n    private let logger = Logger(\n        subsystem: Bundle.main.bundleIdentifier!,\n        category: String(describing: Self.self)\n    )\n    \n    func handleRequest(_ request: HTTPRequest) async throws -> HTTPResponse {\n        guard let requestBody = try? await JSONDecoder().decode(SetOrientationRequest.self, from: request.bodyData) else {\n            return AppError(type: .precondition, message: \"incorrect request body provided for set orientation\").httpResponse\n        }\n\n        XCUIDevice.shared.orientation = requestBody.orientation.uiDeviceOrientation\n        return HTTPResponse(statusCode: .ok)\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/SetPermissionsHandler.swift",
    "content": "import Foundation\nimport FlyingFox\nimport os\n\n@MainActor\nstruct SetPermissionsHandler: HTTPHandler {\n    private let logger = Logger(\n        subsystem: Bundle.main.bundleIdentifier!,\n        category: String(describing: Self.self)\n    )\n    \n    func handleRequest(_ request: HTTPRequest) async throws -> HTTPResponse {\n        guard let requestBody = try? await JSONDecoder().decode(SetPermissionsRequest.self, from: request.bodyData) else {\n            return AppError(type: .precondition, message: \"incorrect request body provided for set permissions\").httpResponse\n        }\n        \n        do {\n            let permissionsMap = try JSONEncoder().encode(requestBody.permissions)\n            UserDefaults.standard.set(permissionsMap, forKey: \"permissions\")\n            return HTTPResponse(statusCode: .ok)\n        } catch let error {\n            return AppError(message: \"Failure in setting permissions. Error: \\(error.localizedDescription)\").httpResponse\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/StatusHandler.swift",
    "content": "import FlyingFox\nimport XCTest\nimport os\n\n@MainActor\nstruct StatusHandler: HTTPHandler {\n    \n    private static let springboardBundleId = \"com.apple.springboard\"\n\n    private let logger = Logger(\n        subsystem: Bundle.main.bundleIdentifier!,\n        category: String(describing: Self.self)\n    )\n    \n    func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> HTTPResponse {\n        do {\n            let statusResponse = StatusResponse(status: String(describing: Status.ok))\n            let responseBody = try JSONEncoder().encode(statusResponse)\n            return HTTPResponse(statusCode: .ok, body: responseBody)\n        }\n        catch let error as AppError {\n           return error.httpResponse\n       } catch let error {\n           return AppError(message: \"Error in passing status \\(error.localizedDescription)\").httpResponse\n       }\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/SwipeRouteHandler.swift",
    "content": "import FlyingFox\nimport XCTest\nimport os\n\n@MainActor\nstruct SwipeRouteHandler: HTTPHandler {\n    private let logger = Logger(\n        subsystem: Bundle.main.bundleIdentifier!,\n        category: String(describing: Self.self)\n    )\n    \n    func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> FlyingFox.HTTPResponse {        \n        let requestBody: SwipeRequest\n        do {\n            requestBody = try await JSONDecoder().decode(SwipeRequest.self, from: request.bodyData)\n        } catch {\n            return AppError(\n                type: .precondition,\n                message: \"incorrect request body provided for swipe request: \\(error)\"\n            ).httpResponse\n        }\n        \n\n        do {\n            try await swipePrivateAPI(\n                start: requestBody.start,\n                end: requestBody.end,\n                duration: requestBody.duration)\n\n            return HTTPResponse(statusCode: .ok)\n        } catch let error {\n            return AppError(message: \"Swipe request failure. Error: \\(error.localizedDescription)\").httpResponse\n        }\n    }\n\n    func swipePrivateAPI(start: CGPoint, end: CGPoint, duration: Double) async throws {\n        logger.info(\"Swipe (v1) from \\(start.debugDescription) to \\(end.debugDescription) with \\(duration) duration\")\n\n        let eventRecord = EventRecord(orientation: ScreenSizeHelper.currentInterfaceOrientation())\n        _ = eventRecord.addSwipeEvent(start: start, end: end, duration: duration)\n\n        try await RunnerDaemonProxy().synthesize(eventRecord: eventRecord)\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/SwipeRouteHandlerV2.swift",
    "content": "import FlyingFox\nimport XCTest\nimport os\n\n@MainActor\nstruct SwipeRouteHandlerV2: HTTPHandler {\n    private let logger = Logger(\n        subsystem: Bundle.main.bundleIdentifier!,\n        category: String(describing: Self.self)\n    )\n    \n    func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> FlyingFox.HTTPResponse {\n        guard let requestBody = try? await JSONDecoder().decode(SwipeRequest.self, from: request.bodyData) else {\n            return AppError(type: .precondition, message: \"incorrect request body provided for swipe request v2\").httpResponse\n        }\n        \n        if (requestBody.duration < 0) {\n            return AppError(type: .precondition, message: \"swipe duration can not be negative\").httpResponse\n        }\n        \n        do {\n            try await swipePrivateAPI(requestBody)\n            return HTTPResponse(statusCode: .ok)\n        } catch let error {\n            return AppError(message: \"Swipe v2 request failure. Error: \\(error.localizedDescription)\").httpResponse\n        }\n    }\n\n    func swipePrivateAPI(_ request: SwipeRequest) async throws {\n        let (width, height) = ScreenSizeHelper.physicalScreenSize()\n        let startPoint = ScreenSizeHelper.orientationAwarePoint(\n            width: width,\n            height: height,\n            point: request.start\n        )\n        let endPoint = ScreenSizeHelper.orientationAwarePoint(\n            width: width,\n            height: height,\n            point: request.end\n        )\n        \n        let description = \"Swipe (v2) from \\(request.start) to \\(request.end) with \\(request.duration) duration\"\n        logger.info(\"\\(description)\")\n\n        let eventTarget = EventTarget()\n        try await eventTarget.dispatchEvent(description: description) {\n            EventRecord(orientation: ScreenSizeHelper.currentInterfaceOrientation())\n                .addSwipeEvent(\n                    start: startPoint,\n                    end: endPoint,\n                    duration: request.duration\n                )\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/TerminateAppHandler.swift",
    "content": "import Foundation\nimport XCTest\nimport FlyingFox\nimport os\n\n@MainActor\nstruct TerminateAppHandler: HTTPHandler {\n    \n    func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> FlyingFox.HTTPResponse {\n        guard let requestBody = try? await JSONDecoder().decode(TerminateAppRequest.self, from: request.bodyData) else {\n            return AppError(type: .precondition, message: \"incorrect request body provided for terminating app\").httpResponse\n        }\n        \n        NSLog(\"[Start] Terminating app \\(requestBody.appId)\")\n        XCUIApplication(bundleIdentifier: requestBody.appId).terminate()\n        NSLog(\"[End] Terminating app \\(requestBody.appId)\")\n\n        return HTTPResponse(statusCode: .ok)\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/TouchRouteHandler.swift",
    "content": "import FlyingFox\nimport XCTest\nimport os\n\n@MainActor\nstruct TouchRouteHandler: HTTPHandler {\n    private let logger = Logger(\n        subsystem: Bundle.main.bundleIdentifier!,\n        category: String(describing: Self.self)\n    )\n\n    func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> FlyingFox.HTTPResponse {\n        let decoder = JSONDecoder()\n      \n        guard let requestBody = try? await decoder.decode(TouchRequest.self, from: request.bodyData) else {\n            NSLog(\"Invalid request for tapping\")\n            return AppError(type: .precondition, message: \"incorrect request body provided for tap route\").httpResponse\n        }\n        \n        let (width, height) = ScreenSizeHelper.physicalScreenSize()\n        let point = ScreenSizeHelper.orientationAwarePoint(\n            width: width,\n            height: height,\n            point: CGPoint(x: CGFloat(requestBody.x), y: CGFloat(requestBody.y))\n        )\n        let (x, y) = (point.x, point.y)\n\n        if requestBody.duration != nil {\n            NSLog(\"Long pressing \\(x), \\(y) for \\(requestBody.duration!)s\")\n        } else {\n            NSLog(\"Tapping \\(x), \\(y)\")\n        }\n\n        do {\n            let eventRecord = EventRecord(orientation: ScreenSizeHelper.currentInterfaceOrientation())\n            _ = eventRecord.addPointerTouchEvent(\n                at: CGPoint(x: CGFloat(x), y: CGFloat(y)),\n                touchUpAfter: requestBody.duration\n            )\n            let start = Date()\n            try await RunnerDaemonProxy().synthesize(eventRecord: eventRecord)\n            let duration = Date().timeIntervalSince(start)\n            NSLog(\"Tapping took \\(duration)\")\n            return HTTPResponse(statusCode: .ok)\n        } catch {\n            NSLog(\"Error tapping: \\(error)\")\n            return AppError(message: \"Error tapping point: \\(error.localizedDescription)\").httpResponse\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/ViewHierarchyHandler.swift",
    "content": "import FlyingFox\nimport XCTest\nimport os\nimport MaestroDriverLib\n\n@MainActor\nstruct ViewHierarchyHandler: HTTPHandler {\n\n    private let springboardApplication = XCUIApplication(bundleIdentifier: \"com.apple.springboard\")\n    private let snapshotMaxDepth = 60\n\n    private let logger = Logger(\n        subsystem: Bundle.main.bundleIdentifier!,\n        category: String(describing: Self.self)\n    )\n\n    func handleRequest(_ request: FlyingFox.HTTPRequest) async throws -> HTTPResponse {\n        guard let requestBody = try? await JSONDecoder().decode(ViewHierarchyRequest.self, from: request.bodyData) else {\n            return AppError(type: .precondition, message: \"incorrect request body provided\").httpResponse\n        }\n\n        do {\n            let foregroundApp = RunningApp.getForegroundApp()\n            guard let foregroundApp = foregroundApp else {\n                NSLog(\"No foreground app found returning springboard app hierarchy\")\n                let springboardHierarchy = try elementHierarchy(xcuiElement: springboardApplication)\n                let springBoardViewHierarchy = ViewHierarchy.init(axElement: springboardHierarchy, depth: springboardHierarchy.depth())\n                let body = try JSONEncoder().encode(springBoardViewHierarchy)\n                return HTTPResponse(statusCode: .ok, body: body)\n            }\n            NSLog(\"[Start] View hierarchy snapshot for \\(foregroundApp)\")\n            let appViewHierarchy = try await getAppViewHierarchy(foregroundApp: foregroundApp, excludeKeyboardElements: requestBody.excludeKeyboardElements)\n            let viewHierarchy = ViewHierarchy.init(axElement: appViewHierarchy, depth: appViewHierarchy.depth())\n            \n            NSLog(\"[Done] View hierarchy snapshot for \\(foregroundApp) \")\n            let body = try JSONEncoder().encode(viewHierarchy)\n            return HTTPResponse(statusCode: .ok, body: body)\n        } catch let error as AppError {\n            NSLog(\"AppError in handleRequest, Error:\\(error)\");\n            return error.httpResponse\n        } catch let error {\n            NSLog(\"Error in handleRequest, Error:\\(error)\");\n            return AppError(message: \"Snapshot failure while getting view hierarchy. Error: \\(error.localizedDescription)\").httpResponse\n        }\n    }\n\n    func getAppViewHierarchy(foregroundApp: XCUIApplication, excludeKeyboardElements: Bool) async throws -> AXElement {\n        let appHierarchy = try getHierarchyWithFallback(foregroundApp)\n        await SystemPermissionHelper.handleSystemPermissionAlertIfNeeded(appHierarchy: appHierarchy, foregroundApp: foregroundApp)\n                \n        let statusBars = logger.measure(message: \"Fetch status bar hierarchy\") {\n            fullStatusBars(springboardApplication)\n        } ?? []\n        \n        // Fetch Safari WebView hierarchy for iOS 26+ (runs in separate SafariViewService process)\n        let safariWebViewHierarchy = logger.measure(message: \"Fetch Safari WebView hierarchy\") {\n            getSafariWebViewHierarchy()\n        }\n\n        let deviceFrame = springboardApplication.frame\n        let deviceAxFrame = [\n            \"X\": Double(deviceFrame.minX),\n            \"Y\": Double(deviceFrame.minY),\n            \"Width\": Double(deviceFrame.width),\n            \"Height\": Double(deviceFrame.height)\n        ]\n        let appFrame = appHierarchy.frame\n        \n        if deviceAxFrame != appFrame {\n            guard\n                let deviceWidth = deviceAxFrame[\"Width\"], deviceWidth > 0,\n                let deviceHeight = deviceAxFrame[\"Height\"], deviceHeight > 0,\n                let appWidth = appFrame[\"Width\"], appWidth > 0,\n                let appHeight = appFrame[\"Height\"], appHeight > 0\n            else {\n                return AXElement(children: [appHierarchy, AXElement(children: statusBars), safariWebViewHierarchy].compactMap { $0 })\n            }\n\n            // Springboard always reports its frame in portrait dimensions (e.g. 1024×1366),\n            // while a landscape app reports them swapped (1366×1024). Without this guard,\n            // the difference would be misinterpreted as a window offset, shifting every\n            // element's coordinates by hundreds of points in the wrong direction.\n            let isSameAreaDifferentOrientation =\n                abs(deviceWidth * deviceHeight - appWidth * appHeight) < 1.0\n                && abs(deviceWidth - appHeight) < 1.0\n                && abs(deviceHeight - appWidth) < 1.0\n\n            if isSameAreaDifferentOrientation {\n                NSLog(\"Skipping offset adjustment: device and app frames are same size but different orientation\")\n                return AXElement(children: [appHierarchy, AXElement(children: statusBars), safariWebViewHierarchy].compactMap { $0 })\n            }\n\n            let offsetX = deviceWidth - appWidth\n            let offsetY = deviceHeight - appHeight\n            let offset = WindowOffset(offsetX: offsetX, offsetY: offsetY)\n\n            NSLog(\"Adjusting view hierarchy with offset: \\(offset)\")\n\n            let adjustedAppHierarchy = expandElementSizes(appHierarchy, offset: offset)\n\n            return AXElement(children: [adjustedAppHierarchy, AXElement(children: statusBars), safariWebViewHierarchy].compactMap { $0 })\n        } else {\n            return AXElement(children: [appHierarchy, AXElement(children: statusBars), safariWebViewHierarchy].compactMap { $0 })\n        }\n    }\n    \n    func expandElementSizes(_ element: AXElement, offset: WindowOffset) -> AXElement {\n        let adjustedFrame: AXFrame = [\n            \"X\": (element.frame[\"X\"] ?? 0) + offset.offsetX,\n            \"Y\": (element.frame[\"Y\"] ?? 0) + offset.offsetY,\n            \"Width\": element.frame[\"Width\"] ?? 0,\n            \"Height\": element.frame[\"Height\"] ?? 0\n        ]\n        let adjustedChildren = element.children?.map { expandElementSizes($0, offset: offset) } ?? []\n        \n        return AXElement(\n            identifier: element.identifier,\n            frame: adjustedFrame,\n            value: element.value,\n            title: element.title,\n            label: element.label,\n            elementType: element.elementType,\n            enabled: element.enabled,\n            horizontalSizeClass: element.horizontalSizeClass,\n            verticalSizeClass: element.verticalSizeClass,\n            placeholderValue: element.placeholderValue,\n            selected: element.selected,\n            hasFocus: element.hasFocus,\n            displayID: element.displayID,\n            windowContextID: element.windowContextID,\n            children: adjustedChildren\n        )\n    }\n\n    func getHierarchyWithFallback(_ element: XCUIElement) throws -> AXElement {\n        logger.info(\"Starting getHierarchyWithFallback for element.\")\n\n        do {\n            var hierarchy = try elementHierarchy(xcuiElement: element)\n            logger.info(\"Successfully retrieved element hierarchy.\")\n\n            if hierarchy.depth() < snapshotMaxDepth {\n                return hierarchy\n            }\n            let count = try element.snapshot().children.count\n            var children: [AXElement] = []\n            for i in 0..<count {\n              let element = element.descendants(matching: .other).element(boundBy: i).firstMatch\n              children.append(try getHierarchyWithFallback(element))\n            }\n            hierarchy.children = children\n            return hierarchy\n        } catch let error {\n            guard isIllegalArgumentError(error) else {\n                NSLog(\"Snapshot failure, cannot return view hierarchy due to \\(error)\")\n                if let nsError = error as NSError?,\n                   nsError.domain == \"com.apple.dt.XCTest.XCTFuture\",\n                   nsError.code == 1000,\n                   nsError.localizedDescription.contains(\"Timed out while evaluating UI query\") {\n                    throw AppError(type: .timeout, message: error.localizedDescription)\n                } else if let nsError = error as NSError?,\n                           nsError.domain == \"com.apple.dt.xctest.automation-support.error\",\n                           nsError.code == 6,\n                           nsError.localizedDescription.contains(\"Unable to perform work on main run loop, process main thread busy for\") {\n                    throw AppError(type: .timeout, message: nsError.localizedDescription)\n                } else {\n                    throw AppError(message: error.localizedDescription)\n                }\n            }\n\n            NSLog(\"Snapshot failure, getting recovery element for fallback\")\n            AXClientSwizzler.overwriteDefaultParameters[\"maxDepth\"] = snapshotMaxDepth\n            // In apps with bigger view hierarchys, calling\n            // `XCUIApplication().snapshot().dictionaryRepresentation` or `XCUIApplication().allElementsBoundByIndex`\n            // throws \"Error kAXErrorIllegalArgument getting snapshot for element <AXUIElementRef 0x6000025eb660>\"\n            // We recover by selecting the first child of the app element,\n            // which should be the window, and continue from there.\n\n            let recoveryElement = try findRecoveryElement(element.children(matching: .any).firstMatch)\n            let hierarchy = try getHierarchyWithFallback(recoveryElement)\n\n            // When the application element is skipped, try to fetch\n            // the keyboard, alert and other custom element hierarchies separately.\n            if let element = element as? XCUIApplication {\n                let keyboard = logger.measure(message: \"Fetch keyboard hierarchy\") {\n                    keyboardHierarchy(element)\n                }\n\n                let alerts = logger.measure(message: \"Fetch alert hierarchy\") {\n                    fullScreenAlertHierarchy(element)\n                }\n\n                let other = try logger.measure(message: \"Fetch other custom element from window\") {\n                    try customWindowElements(element)\n                }\n                return AXElement(children: [\n                    other,\n                    keyboard,\n                    alerts,\n                    hierarchy\n                ].compactMap { $0 })\n            }\n\n            return hierarchy\n        }\n    }\n\n    private func isIllegalArgumentError(_ error: Error) -> Bool {\n        error.localizedDescription.contains(\"Error kAXErrorIllegalArgument getting snapshot for element\")\n    }\n\n    private func keyboardHierarchy(_ element: XCUIApplication) -> AXElement? {\n        guard element.keyboards.firstMatch.exists else {\n            return nil\n        }\n        \n        let keyboard = element.keyboards.firstMatch\n        return try? elementHierarchy(xcuiElement: keyboard)\n    }\n    \n    private func customWindowElements(_ element: XCUIApplication) throws -> AXElement? {\n        let windowElement = element.children(matching: .any).firstMatch\n        if try windowElement.snapshot().children.count > 1 {\n            return nil\n        }\n        return try? elementHierarchy(xcuiElement: windowElement)\n    }\n\n    func fullScreenAlertHierarchy(_ element: XCUIApplication) -> AXElement? {\n        guard element.alerts.firstMatch.exists else {\n            return nil\n        }\n        \n        let alert = element.alerts.firstMatch\n        return try? elementHierarchy(xcuiElement: alert)\n    }\n    \n    func fullStatusBars(_ element: XCUIApplication) -> [AXElement]? {\n        guard element.statusBars.firstMatch.exists else {\n            return nil\n        }\n        \n        let snapshots = try? element.statusBars.allElementsBoundByIndex.compactMap{ (statusBar) in\n            try elementHierarchy(xcuiElement: statusBar)\n        }\n        \n        return snapshots\n    }\n    \n    /// Fetches the Safari WebView hierarchy for iOS 26+ where SFSafariViewController\n    /// runs in a separate process (com.apple.SafariViewService).\n    /// Returns nil if not on iOS 26+, Safari service is not running, or no webviews exist.\n    private func getSafariWebViewHierarchy() -> AXElement? {\n        let systemVersion = ProcessInfo.processInfo.operatingSystemVersion\n        guard systemVersion.majorVersion >= 26 else {\n            return nil\n        }\n        \n        let safariWebService = XCUIApplication(bundleIdentifier: \"com.apple.SafariViewService\")\n        \n        let isRunning = safariWebService.state == .runningForeground || safariWebService.state == .runningBackground\n        guard isRunning else {\n            return nil\n        }\n        \n        let webViewCount = safariWebService.webViews.count\n        guard webViewCount > 0 else {\n            return nil\n        }\n        \n        NSLog(\"[Start] Fetching Safari WebView hierarchy (\\(webViewCount) webview(s) detected)\")\n        \n        do {\n            AXClientSwizzler.overwriteDefaultParameters[\"maxDepth\"] = snapshotMaxDepth\n            let safariHierarchy = try elementHierarchy(xcuiElement: safariWebService)\n            NSLog(\"[Done] Safari WebView hierarchy fetched successfully\")\n            return safariHierarchy\n        } catch {\n            NSLog(\"[Error] Failed to fetch Safari WebView hierarchy: \\(error.localizedDescription)\")\n            return nil\n        }\n    }\n\n    private func findRecoveryElement(_ element: XCUIElement) throws -> XCUIElement {\n        if try element.snapshot().children.count > 1 {\n            return element\n        }\n        let firstOtherElement = element.children(matching: .other).firstMatch\n        if (firstOtherElement.exists) {\n            return try findRecoveryElement(firstOtherElement)\n        } else {\n            return element\n        }\n    }\n\n    private func elementHierarchy(xcuiElement: XCUIElement) throws -> AXElement {\n        let snapshotDictionary = try xcuiElement.snapshot().dictionaryRepresentation\n        return AXElement(snapshotDictionary)\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Helpers/AppError.swift",
    "content": "\nimport Foundation\nimport FlyingFox\n\nenum AppErrorType: String, Codable {\n    case `internal`\n    case precondition\n    case timeout\n}\n\nstruct AppError: Error, Codable {\n    let type: AppErrorType\n    let message: String\n\n    private var statusCode: HTTPStatusCode {\n        switch type {\n        case .internal: return .internalServerError\n        case .precondition: return .badRequest\n        case .timeout: return .requestTimeout\n        }\n    }\n\n    var httpResponse: HTTPResponse {\n        let body = try? JSONEncoder().encode(self)\n        return HTTPResponse(statusCode: statusCode, body: body ?? Data())\n    }\n\n    init(type: AppErrorType = .internal, message: String) {\n        self.type = type\n        self.message = message\n    }\n\n    private enum CodingKeys : String, CodingKey {\n        case type = \"code\"\n        case message = \"errorMessage\"\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Helpers/ScreenSizeHelper.swift",
    "content": "import XCTest\nimport MaestroDriverLib\n\nstruct ScreenSizeHelper {\n\n    private static var cachedSize: (Float, Float)?\n    private static var lastAppBundleId: String?\n    private static var lastOrientation: UIDeviceOrientation?\n\n    static func physicalScreenSize() -> (Float, Float) {\n        let springboardBundleId = \"com.apple.springboard\"\n\n        let app = RunningApp.getForegroundApp() ?? XCUIApplication(bundleIdentifier: springboardBundleId)\n\n        do {\n            let currentAppBundleId = app.bundleID\n            let currentOrientation = XCUIDevice.shared.orientation\n\n            if let cached = cachedSize,\n                currentAppBundleId == lastAppBundleId,\n                currentOrientation == lastOrientation\n            {\n                NSLog(\"Returning cached screen size\")\n                return cached\n            }\n\n            let dict = try app.snapshot().dictionaryRepresentation\n            let axFrame = AXElement(dict).frame\n\n            // Safely unwrap width/height\n            guard let width = axFrame[\"Width\"], let height = axFrame[\"Height\"] else {\n                NSLog(\"Frame keys missing, falling back to SpringBoard.\")\n                let springboard = XCUIApplication(bundleIdentifier: springboardBundleId)\n                let size = springboard.frame.size\n                return (Float(size.width), Float(size.height))\n            }\n\n            let screenSize = CGSize(width: width, height: height)\n            let size = (Float(screenSize.width), Float(screenSize.height))\n\n            // Cache results\n            cachedSize = size\n            lastAppBundleId = currentAppBundleId\n            lastOrientation = currentOrientation\n\n            return size\n        } catch let error {\n            NSLog(\"Failure while getting screen size: \\(error), falling back to get springboard size.\")\n            let application = XCUIApplication(\n                bundleIdentifier: springboardBundleId)\n            let screenSize = application.frame.size\n            return (Float(screenSize.width), Float(screenSize.height))\n        }\n    }\n\n    private static func actualOrientation() -> UIDeviceOrientation {\n        let orientation = XCUIDevice.shared.orientation\n        if orientation == .unknown {\n            // If orientation is \"unknown\", we assume it is \"portrait\" to\n            // work around https://stackoverflow.com/q/78932288/7009800\n            return UIDeviceOrientation.portrait\n        }\n\n        return orientation\n    }\n\n    /// Returns the current UIInterfaceOrientation derived from the device's UIDeviceOrientation.\n    ///\n    /// Per Apple convention, landscape values are swapped between the two enums:\n    /// - UIDeviceOrientation describes the hardware tilt (e.g. `.landscapeLeft` = device rotated left)\n    /// - UIInterfaceOrientation describes the UI's compensating rotation (`.landscapeRight` = UI rotated right)\n    /// The UI always rotates opposite to the device to keep content upright.\n    static func currentInterfaceOrientation() -> UIInterfaceOrientation {\n        let orientation = actualOrientation()\n        return switch orientation {\n        case .landscapeLeft:      .landscapeRight\n        case .landscapeRight:     .landscapeLeft\n        case .portrait:           .portrait\n        case .portraitUpsideDown: .portraitUpsideDown\n        default:                  .portrait\n        }\n    }\n\n    /// Takes device orientation into account.\n    static func actualScreenSize() throws -> (Float, Float, UIDeviceOrientation)\n    {\n        let orientation = actualOrientation()\n\n        let (width, height) = physicalScreenSize()\n        let isLandscape = orientation == .landscapeLeft || orientation == .landscapeRight\n        let dimsAlreadyMatchOrientation = isLandscape ? (width > height) : (width <= height)\n\n        let (actualWidth, actualHeight) =\n            switch orientation {\n            case .portrait, .portraitUpsideDown: (width, height)\n            case .landscapeLeft, .landscapeRight:\n                dimsAlreadyMatchOrientation ? (width, height) : (height, width)\n            case .faceDown, .faceUp: (width, height)\n            case .unknown:\n                throw AppError(\n                    message: \"Unsupported orientation: \\(orientation)\")\n            @unknown default:\n                throw AppError(\n                    message: \"Unsupported orientation: \\(orientation)\")\n            }\n\n        return (actualWidth, actualHeight, orientation)\n    }\n\n    static func orientationAwarePoint(\n        width: Float, height: Float, point: CGPoint\n    ) -> CGPoint {\n        let orientation = actualOrientation()\n        let isLandscape = orientation == .landscapeLeft || orientation == .landscapeRight\n        let dimsAlreadyMatchOrientation = isLandscape && (width > height)\n\n        // When physicalScreenSize() already returns landscape-correct dims,\n        // use the short side as height for the rotation transform.\n        let effectiveWidth = dimsAlreadyMatchOrientation ? height : width\n        let effectiveHeight = dimsAlreadyMatchOrientation ? width : height\n\n        return switch orientation {\n        case .portrait: point\n        case .landscapeLeft:\n            CGPoint(x: CGFloat(effectiveWidth) - point.y, y: CGFloat(point.x))\n        case .landscapeRight:\n            CGPoint(x: CGFloat(point.y), y: CGFloat(effectiveHeight) - point.x)\n        default: fatalError(\"Not implemented yet\")\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Helpers/SystemPermissionHelper.swift",
    "content": "import XCTest\nimport MaestroDriverLib\n\n@MainActor\nfinal class SystemPermissionHelper {\n\n    private static let buttonFinder = PermissionButtonFinder()\n\n    static func handleSystemPermissionAlertIfNeeded(appHierarchy: AXElement, foregroundApp: XCUIApplication) async {\n        guard let data = UserDefaults.standard.object(forKey: \"permissions\") as? Data,\n              let permissions = try? JSONDecoder().decode([String : PermissionValue].self, from: data),\n              let notificationsPermission = permissions.first(where: { $0.key == \"notifications\" }) else {\n            return\n        }\n\n        if foregroundApp.bundleID != \"com.apple.springboard\" {\n            NSLog(\"Foreground app is not springboard skipping auto tapping on permissions\")\n            return\n        }\n\n        NSLog(\"[Start] Foreground app is springboard attempting to tap on permissions dialog\")\n\n        let result = buttonFinder.findButtonToTap(for: notificationsPermission.value, in: appHierarchy)\n\n        switch result {\n        case .found(let frame):\n            NSLog(\"Found button at frame: \\(frame)\")\n            await tapAtCenter(of: frame, in: foregroundApp)\n        case .noButtonsFound:\n            NSLog(\"No buttons found in hierarchy\")\n        case .noActionRequired:\n            NSLog(\"No action required for permission value\")\n        @unknown default:\n            NSLog(\"Unknown permission button result: \\(result)\")\n        }\n\n        NSLog(\"[Done] Foreground app is springboard attempting to tap on permissions dialog\")\n    }\n\n    /// Tap at the center of an element's frame\n    private static func tapAtCenter(of frame: AXFrame, in app: XCUIApplication) async {\n        let x = frame.centerX\n        let y = frame.centerY\n\n        NSLog(\"Tapping at coordinates: (\\(x), \\(y))\")\n\n        let (width, height) = ScreenSizeHelper.physicalScreenSize()\n        let point = ScreenSizeHelper.orientationAwarePoint(\n            width: width,\n            height: height,\n            point: CGPoint(x: CGFloat(x), y: CGFloat(y))\n        )\n\n        let eventRecord = EventRecord(orientation: .portrait)\n        _ = eventRecord.addPointerTouchEvent(\n            at: point,\n            touchUpAfter: nil\n        )\n\n        do {\n            try await RunnerDaemonProxy().synthesize(eventRecord: eventRecord)\n        } catch {\n            NSLog(\"Error tapping permission button: \\(error)\")\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Helpers/TextInputHelper.swift",
    "content": "import Foundation\nimport os\nimport XCTest\n\n@MainActor\nstruct TextInputHelper {\n    private static let logger = Logger(\n        subsystem: Bundle.main.bundleIdentifier!,\n        category: String(describing: Self.self)\n    )\n    \n    private enum Constants {\n        static let typingFrequency = 30\n        static let slowInputCharactersCount = 1\n    }\n    \n    static func waitUntilKeyboardIsPresented() async {\n        let deadline = Date().addingTimeInterval(1.0)\n        while Date() < deadline {\n            let app = RunningApp.getForegroundApp() ?? XCUIApplication(bundleIdentifier: RunningApp.springboardBundleId)\n            if app.keyboards.firstMatch.exists { return }\n            try? await Task.sleep(nanoseconds: 200_000_000)\n        }\n    }\n\n    static func inputText(_ text: String) async throws {\n        // due to different keyboard input listener events (i.e. autocorrection or hardware keyboard connection)\n        // characters after the first on are often skipped, so we'll input it with lower typing frequency\n        let firstCharacter = String(text.prefix(Constants.slowInputCharactersCount))\n        logger.info(\"first character: \\(firstCharacter)\")\n        var eventPath = PointerEventPath.pathForTextInput()\n        eventPath.type(text: firstCharacter, typingSpeed: 1)\n        let eventRecord = EventRecord(orientation: ScreenSizeHelper.currentInterfaceOrientation())\n        _ = eventRecord.add(eventPath)\n        try await RunnerDaemonProxy().synthesize(eventRecord: eventRecord)\n        \n        // wait 500 ms before dispatching next input text request to avoid iOS dropping characters\n        try await Task.sleep(nanoseconds: UInt64(1_000_000_000 * 0.5))\n        \n        if (text.count > Constants.slowInputCharactersCount) {\n            let remainingText = String(text.suffix(text.count - Constants.slowInputCharactersCount))\n            logger.info(\"remaining text: \\(remainingText)\")\n            var eventPath2 = PointerEventPath.pathForTextInput()\n            eventPath2.type(text: remainingText, typingSpeed: Constants.typingFrequency)\n            let eventRecord2 = EventRecord(orientation: ScreenSizeHelper.currentInterfaceOrientation())\n            _ = eventRecord2.add(eventPath2)\n            try await RunnerDaemonProxy().synthesize(eventRecord: eventRecord2)\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Models/AXElement.swift",
    "content": "\nimport Foundation\nimport XCTest\nimport MaestroDriverLib\n\nstruct ViewHierarchy : Codable {\n    let axElement: AXElement\n    let depth: Int\n}\n\nstruct WindowOffset: Codable {\n    let offsetX: Double\n    let offsetY: Double\n}\n\n// MARK: - XCTest-specific AXElement Extension\n\nextension AXElement {\n    init(_ dict: [XCUIElement.AttributeName: Any]) {\n        func valueFor(_ name: String) -> Any {\n            dict[XCUIElement.AttributeName(rawValue: name)] as Any\n        }\n\n        let label = valueFor(\"label\") as? String ?? \"\"\n        let elementType = valueFor(\"elementType\") as? Int ?? 0\n        let identifier = valueFor(\"identifier\") as? String ?? \"\"\n        let horizontalSizeClass = valueFor(\"horizontalSizeClass\") as? Int ?? 0\n        let windowContextID = valueFor(\"windowContextID\") as? Double ?? 0\n        let verticalSizeClass = valueFor(\"verticalSizeClass\") as? Int ?? 0\n        let selected = valueFor(\"selected\") as? Bool ?? false\n        let displayID = valueFor(\"displayID\") as? Int ?? 0\n        let hasFocus = valueFor(\"hasFocus\") as? Bool ?? false\n        let placeholderValue = valueFor(\"placeholderValue\") as? String\n        let value = valueFor(\"value\") as? String\n        let frame = valueFor(\"frame\") as? AXFrame ?? .zero\n        let enabled = valueFor(\"enabled\") as? Bool ?? false\n        let title = valueFor(\"title\") as? String\n        let childrenDictionaries = valueFor(\"children\") as? [[XCUIElement.AttributeName: Any]]\n        let children = childrenDictionaries?.map { AXElement($0) } ?? []\n\n        self.init(\n            identifier: identifier,\n            frame: frame,\n            value: value,\n            title: title,\n            label: label,\n            elementType: elementType,\n            enabled: enabled,\n            horizontalSizeClass: horizontalSizeClass,\n            verticalSizeClass: verticalSizeClass,\n            placeholderValue: placeholderValue,\n            selected: selected,\n            hasFocus: hasFocus,\n            displayID: displayID,\n            windowContextID: windowContextID,\n            children: children\n        )\n    }\n}"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Models/DeviceInfoResponse.swift",
    "content": "import Foundation\n\nstruct DeviceInfoResponse: Codable {\n    let widthPoints: Int\n    let heightPoints: Int\n    let widthPixels: Int\n    let heightPixels: Int\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Models/EraseTextRequest.swift",
    "content": "\nimport Foundation\n\nstruct EraseTextRequest: Codable {\n    let charactersToErase: Int\n    let appIds: [String]\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Models/GetRunningAppRequest.swift",
    "content": "struct RunningAppRequest: Codable {\n    let appIds: [String]\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Models/InputTextRequest.swift",
    "content": "struct InputTextRequest: Codable {\n    let text: String\n    let appIds: [String]\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Models/KeyboardHandlerRequest.swift",
    "content": "import Foundation\n\nstruct KeyboardHandlerRequest: Codable {\n    let appIds: [String]\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Models/KeyboardHandlerResponse.swift",
    "content": "import Foundation\n\nstruct KeyboardHandlerResponse: Codable {\n    let isKeyboardVisible: Bool\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Models/LaunchAppRequest.swift",
    "content": "import Foundation\n\nstruct LaunchAppRequest : Codable {\n    let bundleId: String\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Models/PressButtonRequest.swift",
    "content": "\nimport Foundation\nimport XCTest\n\nstruct PressButtonRequest: Codable {\n    enum Button: String, Codable {\n        case home\n        case lock\n    }\n\n    let button: Button\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Models/PressKeyRequest.swift",
    "content": "import Foundation\nimport XCTest\n\nstruct PressKeyRequest: Codable {\n    enum Key: String, Codable {\n        case delete\n        case `return`\n        case enter\n        case tab\n        case space\n        case escape\n    }\n\n    let key: Key\n\n    var xctestKey: String {\n        // Note: XCUIKeyboardKey().rawValue is the ascii representation of that key,\n        // not the enum value name.\n        switch key {\n        case .delete: return XCUIKeyboardKey.delete.rawValue\n        case .return: return XCUIKeyboardKey.return.rawValue\n        case .enter: return XCUIKeyboardKey.enter.rawValue\n        case .tab: return XCUIKeyboardKey.tab.rawValue\n        case .space: return XCUIKeyboardKey.space.rawValue\n        case .escape: return XCUIKeyboardKey.escape.rawValue\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Models/SetOrientationRequest.swift",
    "content": "import Foundation\nimport UIKit\n\nstruct SetOrientationRequest: Codable {\n    let orientation: Orientation\n\n    enum Orientation: String, Codable {\n        case portrait\n        case landscapeLeft\n        case landscapeRight\n        case upsideDown\n\n        var uiDeviceOrientation: UIDeviceOrientation {\n            switch self {\n            case .portrait:\n                return .portrait\n            case .landscapeLeft:\n                return .landscapeLeft\n            case .landscapeRight:\n                return .landscapeRight\n            case .upsideDown:\n                return .portraitUpsideDown\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Models/SetPermissionsRequest.swift",
    "content": "import Foundation\nimport MaestroDriverLib\n\nstruct SetPermissionsRequest: Codable {\n    let permissions: [String : PermissionValue]\n}"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Models/StatusResponse.swift",
    "content": "import Foundation\n\nstruct StatusResponse: Codable {\n    let status: String\n}\n\nenum Status: Codable {\n    case ok\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Models/SwipeRequest.swift",
    "content": "import Foundation\n\nstruct SwipeRequest: Decodable {\n\n    enum CodingKeys: String, CodingKey {\n        case appId, startX, startY, endX, endY, duration, appIds\n    }\n\n    let appId: String?\n    let start: CGPoint\n    let end: CGPoint\n    let duration: TimeInterval\n    let appIds: [String]?\n\n    init(appId: String?, start: CGPoint, end: CGPoint, duration: Double, appIds: [String]?) {\n        self.appId = appId\n        self.start = start\n        self.end = end\n        self.duration = duration\n        self.appIds = appIds\n    }\n\n    init(from decoder: Decoder) throws {\n        let container = try decoder.container(keyedBy: CodingKeys.self)\n        appId = try container.decodeIfPresent(String.self, forKey: .appId)\n        start = CGPoint(\n            x: try container.decode(Double.self, forKey: .startX),\n            y: try container.decode(Double.self, forKey: .startY)\n        )\n        end = CGPoint(\n            x: try container.decode(Double.self, forKey: .endX),\n            y: try container.decode(Double.self, forKey: .endY)\n        )\n        duration = try container.decode(Double.self, forKey: .duration)\n        appIds = try container.decodeIfPresent([String].self, forKey: .appIds)\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Models/TerminateAppRequest.swift",
    "content": "struct TerminateAppRequest: Codable {\n    let appId: String\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Models/TouchRequest.swift",
    "content": "import Foundation\n\nstruct TouchRequest : Codable {\n    let x: Float\n    let y: Float\n    let duration: TimeInterval?\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Models/ViewHierarchyRequest.swift",
    "content": "import Foundation\n\nstruct ViewHierarchyRequest: Codable {\n    let appIds: [String]\n    let excludeKeyboardElements: Bool\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/RouteHandlerFactory.swift",
    "content": "import Foundation\nimport FlyingFox\n\nclass RouteHandlerFactory {\n    @MainActor class func createRouteHandler(route: Route) -> HTTPHandler {\n        switch route {\n        case .runningApp:\n            return RunningAppRouteHandler()\n        case .swipe:\n            return SwipeRouteHandler()\n        case .swipeV2:\n            return SwipeRouteHandlerV2()\n        case .inputText:\n            return InputTextRouteHandler()\n        case .touch:\n            return TouchRouteHandler()\n        case .screenshot:\n            return ScreenshotHandler()\n        case .isScreenStatic:\n            return IsScreenStaticHandler()\n        case .pressKey:\n            return PressKeyHandler()\n        case .pressButton:\n            return PressButtonHandler()\n        case .eraseText:\n            return EraseTextHandler()\n        case .deviceInfo:\n            return DeviceInfoHandler()\n        case .setOrientation:\n            return SetOrientationHandler()\n        case .setPermissions:\n            return SetPermissionsHandler()\n        case .viewHierarchy:\n            return ViewHierarchyHandler()\n        case .status:\n            return StatusHandler()\n        case .keyboard:\n            return KeyboardRouteHandler()\n        case .terminateApp:\n            return TerminateAppHandler()\n        case .launchApp:\n             return LaunchAppHandler()\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/XCTest/AXClientSwizzler.swift",
    "content": "import Foundation\nimport XCTest\n\n// Use a global to pass to the swizzled implementation:\n// Instance variables are not accessible after swizzling.\nprivate var _overwriteDefaultParameters = [String: Int]()\n\nstruct AXClientSwizzler {\n    fileprivate static let proxy = AXClientiOS_Standin()\n\n    // Make this type not-initializable\n    private init() {}\n\n    static var overwriteDefaultParameters: [String: Int] {\n        get { _overwriteDefaultParameters }\n        set { setup; _overwriteDefaultParameters = newValue }\n    }\n\n    static let setup: Void = {\n        let axClientiOSClass: AnyClass = objc_getClass(\"XCAXClient_iOS\") as! AnyClass\n        let defaultParametersSelector = Selector((\"defaultParameters\"))\n        let original = class_getInstanceMethod(axClientiOSClass, defaultParametersSelector)!\n\n        let replaced = class_getInstanceMethod(\n            AXClientiOS_Standin.self,\n            #selector(AXClientiOS_Standin.swizzledDefaultParameters))!\n        \n        method_exchangeImplementations(original, replaced)\n    }()\n}\n\n@objc private class AXClientiOS_Standin: NSObject {\n    func originalDefaultParameters() -> NSDictionary {\n        let selector = Selector((\"defaultParameters\"))\n        let swizzeledSelector = #selector(swizzledDefaultParameters)\n        let imp = class_getMethodImplementation(AXClientiOS_Standin.self, swizzeledSelector)\n        typealias Method = @convention(c) (NSObject, Selector) -> NSDictionary\n        let method = unsafeBitCast(imp, to: Method.self)\n        return method(self, selector)\n    }\n\n    @objc func swizzledDefaultParameters() -> NSDictionary {\n        let defaultParameters = originalDefaultParameters().mutableCopy() as! NSMutableDictionary\n\n        for (key, value) in _overwriteDefaultParameters {\n            defaultParameters[key] = value\n        }\n\n        return defaultParameters\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/XCTest/EventRecord.swift",
    "content": "import Foundation\nimport UIKit\n\n@objc\nfinal class EventRecord: NSObject {\n    let eventRecord: NSObject\n    static let defaultTapDuration = 0.1\n\n    enum Style: String {\n        case singeFinger = \"Single-Finger Touch Action\"\n        case multiFinger = \"Multi-Finger Touch Action\"\n    }\n\n    init(orientation: UIInterfaceOrientation, style: Style = .singeFinger) {\n        eventRecord = objc_lookUpClass(\"XCSynthesizedEventRecord\")?.alloc()\n            .perform(\n                NSSelectorFromString(\"initWithName:interfaceOrientation:\"),\n                with: style.rawValue,\n                with: orientation\n            )\n            .takeUnretainedValue() as! NSObject\n    }\n\n    func addPointerTouchEvent(at point: CGPoint, touchUpAfter: TimeInterval?) -> Self {\n        var path = PointerEventPath.pathForTouch(at: point)\n        path.offset += touchUpAfter ?? Self.defaultTapDuration\n        path.liftUp()\n        return add(path)\n    }\n\n    func addSwipeEvent(start: CGPoint, end: CGPoint, duration: TimeInterval) -> Self {\n        var path = PointerEventPath.pathForTouch(at: start)\n        path.offset += Self.defaultTapDuration\n        path.moveTo(point: end)\n        path.offset += duration\n        path.liftUp()\n        return add(path)\n    }\n\n    func add(_ path: PointerEventPath) -> Self {\n        let selector = NSSelectorFromString(\"addPointerEventPath:\")\n        let imp = eventRecord.method(for: selector)\n        typealias Method = @convention(c) (NSObject, Selector, NSObject) -> ()\n        let method = unsafeBitCast(imp, to: Method.self)\n        method(eventRecord, selector, path.path)\n        return self\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/XCTest/EventTarget.swift",
    "content": "import Foundation\nimport XCTest\n\n@MainActor\nstruct EventTarget {\n    \n    let eventTarget: NSObject\n    \n    init() {\n        let application = RunningApp.getForegroundApp() ?? XCUIApplication(bundleIdentifier: RunningApp.springboardBundleId)\n        \n        eventTarget = application.children(matching: .any).firstMatch\n            .perform(NSSelectorFromString(\"eventTarget\"))\n            .takeUnretainedValue() as! NSObject\n    }\n\n    typealias EventBuilder = @convention(block) () -> EventRecord\n    func dispatchEvent(description: String, builder: EventBuilder) async throws {\n        let selector = NSSelectorFromString(\"dispatchEventWithDescription:eventBuilder:error:\")\n        let imp = eventTarget.method(for: selector)\n\n        typealias EventBuilderObjc = @convention(block) () -> NSObject\n        typealias Method = @convention(c) (NSObject, Selector, String, EventBuilderObjc, AutoreleasingUnsafeMutablePointer<NSError?>) -> Bool\n        var error: NSError?\n        let method = unsafeBitCast(imp, to: Method.self)\n\n        _ = method(\n            eventTarget,\n            selector,\n            description,\n            { builder().eventRecord },\n            &error\n        )\n\n        if let error = error {\n            throw error\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/XCTest/KeyModifierFlags.swift",
    "content": "\nstruct KeyModifierFlags: OptionSet {\n    let rawValue: UInt64\n    static let capsLock =   KeyModifierFlags(rawValue: 1 << 0)\n    static let shift =      KeyModifierFlags(rawValue: 1 << 1)\n    static let control =    KeyModifierFlags(rawValue: 1 << 2)\n    static let option =     KeyModifierFlags(rawValue: 1 << 3)\n    static let command =    KeyModifierFlags(rawValue: 1 << 4)\n    static let function =   KeyModifierFlags(rawValue: 1 << 5)\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/XCTest/PointerEventPath.swift",
    "content": "import Foundation\n\nstruct PointerEventPath {\n\n    static func pathForTouch(at point: CGPoint, offset: TimeInterval = 0) -> Self {\n        let alloced = objc_lookUpClass(\"XCPointerEventPath\")!.alloc() as! NSObject\n        let selector = NSSelectorFromString(\"initForTouchAtPoint:offset:\")\n        let imp = alloced.method(for: selector)\n        typealias Method = @convention(c) (NSObject, Selector, CGPoint, TimeInterval) -> NSObject\n        let method = unsafeBitCast(imp, to: Method.self)\n        let path = method(alloced, selector, point, offset)\n        return Self(path: path, offset: offset)\n    }\n\n    static func pathForTextInput(offset: TimeInterval = 0) -> Self {\n        let alloced = objc_lookUpClass(\"XCPointerEventPath\")!.alloc() as! NSObject\n        let selector = NSSelectorFromString(\"initForTextInput\")\n        let imp = alloced.method(for: selector)\n        typealias Method = @convention(c) (NSObject, Selector) -> NSObject\n        let method = unsafeBitCast(imp, to: Method.self)\n        let path = method(alloced, selector)\n        return Self(path: path, offset: offset)\n    }\n\n    let path: NSObject\n    var offset: TimeInterval\n\n    private init(path: NSObject, offset: TimeInterval) {\n        self.path = path\n        self.offset = offset\n    }\n\n    mutating func liftUp() {\n        let selector = NSSelectorFromString(\"liftUpAtOffset:\")\n        let imp = path.method(for: selector)\n        typealias Method = @convention(c) (NSObject, Selector, TimeInterval) -> ()\n        let method = unsafeBitCast(imp, to: Method.self)\n        method(path, selector, offset)\n    }\n\n    mutating func moveTo(point: CGPoint) {\n        let selector = NSSelectorFromString(\"moveToPoint:atOffset:\")\n        let imp = path.method(for: selector)\n        typealias Method = @convention(c) (NSObject, Selector, CGPoint, TimeInterval) -> ()\n        let method = unsafeBitCast(imp, to: Method.self)\n        method(path, selector, point, offset)\n    }\n\n    mutating func type(text: String, typingSpeed: Int, shouldRedact: Bool = false) {\n        let selector = NSSelectorFromString(\"typeText:atOffset:typingSpeed:shouldRedact:\")\n        let imp = path.method(for: selector)\n        typealias Method = @convention(c) (NSObject, Selector, NSString, TimeInterval, UInt64, Bool) -> ()\n        let method = unsafeBitCast(imp, to: Method.self)\n        method(path, selector, text as NSString, offset, UInt64(typingSpeed), shouldRedact)\n    }\n\n    mutating func set(modifiers: KeyModifierFlags = []) {\n        let selector = NSSelectorFromString(\"setModifiers:mergeWithCurrentModifierFlags:atOffset:\")\n        let imp = path.method(for: selector)\n        typealias Method = @convention(c) (NSObject, Selector, UInt64, Bool, TimeInterval) -> ()\n        let method = unsafeBitCast(imp, to: Method.self)\n        method(path, selector, modifiers.rawValue, false, offset)\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/XCTest/RunnerDaemonProxy.swift",
    "content": "import Foundation\n\n@MainActor\nclass RunnerDaemonProxy {\n    private let proxy: NSObject\n    \n    init() {\n        let clazz: AnyClass = NSClassFromString(\"XCTRunnerDaemonSession\")!\n        let selector = NSSelectorFromString(\"sharedSession\")\n        let imp = clazz.method(for: selector)\n        typealias Method = @convention(c) (AnyClass, Selector) -> NSObject\n        let method = unsafeBitCast(imp, to: Method.self)\n        let session = method(clazz, selector)\n\n        proxy = session\n            .perform(NSSelectorFromString(\"daemonProxy\"))\n            .takeUnretainedValue() as! NSObject\n    }\n\n    func send(string: String, typingFrequency: Int = 10) async throws {\n        let selector = NSSelectorFromString(\"_XCT_sendString:maximumFrequency:completion:\")\n        let imp = proxy.method(for: selector)\n        typealias Method = @convention(c) (NSObject, Selector, NSString, Int, @escaping (Error?) -> ()) -> ()\n        let method = unsafeBitCast(imp, to: Method.self)\n        return try await withCheckedThrowingContinuation { continuation in\n            method(proxy, selector, string as NSString, typingFrequency, { error in\n                if let error = error {\n                    continuation.resume(with: .failure(error))\n                } else {\n                    continuation.resume(with: .success(()))\n                }\n            })\n        }\n    }\n\n    func synthesize(eventRecord: EventRecord) async throws {\n        let selector = NSSelectorFromString(\"_XCT_synthesizeEvent:completion:\")\n        let imp = proxy.method(for: selector)\n        typealias Method = @convention(c) (NSObject, Selector, NSObject, @escaping (Error?) -> ()) -> ()\n        let method = unsafeBitCast(imp, to: Method.self)\n        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in\n            method(proxy, selector, eventRecord.eventRecord, { error in\n                if let error = error {\n                    continuation.resume(with: .failure(error))\n                } else {\n                    continuation.resume(with: .success(()))\n                }\n            })\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/XCTest/RunningApp.swift",
    "content": "import Foundation\nimport XCTest\nimport os\n\nstruct RunningApp {\n    \n    static let springboardBundleId = \"com.apple.springboard\"\n    private static let logger = Logger(\n        subsystem: Bundle.main.bundleIdentifier!,\n        category: String(describing: Self.self)\n    )\n    private init() {}\n    \n    static func getForegroundAppId(_ appIds: [String]) -> String {\n        if appIds.isEmpty {\n            logger.info(\"Empty installed apps found\")\n            return \"\"\n        }\n        \n        return appIds.first { appId in\n            let app = XCUIApplication(bundleIdentifier: appId)\n            \n            return app.state == .runningForeground\n        } ?? RunningApp.springboardBundleId\n    }\n    \n    static func getForegroundApp() -> XCUIApplication? {\n        let runningAppIds = XCUIApplication.activeAppsInfo().compactMap { $0[\"bundleId\"] as? String }\n        \n        NSLog(\"Detected running apps: \\(runningAppIds)\")\n\n        if runningAppIds.count == 1, let bundleId = runningAppIds.first {\n            return XCUIApplication(bundleIdentifier: bundleId)\n        } else {\n            return runningAppIds\n                .map { XCUIApplication(bundleIdentifier: $0) }\n                .first { $0.state == .runningForeground }\n        }\n    }\n    \n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/XCTestHTTPServer.swift",
    "content": "import FlyingFox\nimport Foundation\n\nenum Route: String, CaseIterable {\n    case runningApp\n    case swipe\n    case swipeV2\n    case inputText\n    case touch\n    case screenshot\n    case isScreenStatic\n    case pressKey\n    case pressButton\n    case eraseText\n    case deviceInfo\n    case setOrientation\n    case setPermissions\n    case viewHierarchy\n    case status\n    case keyboard\n    case launchApp\n    case terminateApp\n\n    func toHTTPRoute() -> HTTPRoute {\n        return HTTPRoute(rawValue)\n    }\n}\n\nstruct XCTestHTTPServer {\n    func start() async throws {\n        let port = ProcessInfo.processInfo.environment[\"PORT\"]?.toUInt16()\n        let server = HTTPServer(address: try .inet(ip4: \"127.0.0.1\", port: port ?? 22087), timeout: 100)\n        \n        for route in Route.allCases {\n            let handler = await RouteHandlerFactory.createRouteHandler(route: route)\n            await server.appendRoute(route.toHTTPRoute(), to: handler)\n        }\n        \n        try await server.run()\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Utilities/AXClientProxy.h",
    "content": "#import <XCTest/XCTest.h>\n#import \"XCAccessibilityElement.h\"\n\n@interface AXClientProxy : NSObject\n\n+ (instancetype)sharedClient;\n\n- (NSArray<id<XCAccessibilityElement>> *)activeApplications;\n\n- (NSDictionary *)defaultParameters;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Utilities/AXClientProxy.m",
    "content": "#import \"AXClientProxy.h\"\n#import \"XCAccessibilityElement.h\"\n#import \"XCUIDevice.h\"\n\nstatic id AXClient = nil;\n\n@implementation AXClientProxy\n\n+ (instancetype)sharedClient\n{\n    static AXClientProxy *instance = nil;\n    static dispatch_once_t onceToken;\n    dispatch_once(&onceToken, ^{\n        instance = [[self alloc] init];\n        AXClient = [XCUIDevice.sharedDevice accessibilityInterface];\n    });\n    return instance;\n}\n\n- (NSArray<id<XCAccessibilityElement>> *)activeApplications\n{\n    return [AXClient activeApplications];\n}\n\n- (NSDictionary *)defaultParameters {\n    return [AXClient defaultParameters];\n}\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Utilities/FBConfiguration.h",
    "content": "/**\n * Copyright (c) 2015-present, Facebook, Inc.\n * All rights reserved.\n *\n * This source code is licensed under the BSD-style license found in the\n * LICENSE file in the root directory of this source tree. An additional grant\n * of patent rights can be found in the PATENTS file in the same directory.\n */\n\n#import <Foundation/Foundation.h>\n\nNS_ASSUME_NONNULL_BEGIN\n\n\n@interface FBConfiguration : NSObject\n\n/**\n * Set the idling timeout. If the timeout expires then WDA\n * tries to interact with the application even if it is not idling.\n * Setting it to zero disables idling checks.\n * The default timeout is set to 10 seconds.\n *\n * @param timeout The actual timeout value in float seconds\n */\n+ (void)setWaitForIdleTimeout:(NSTimeInterval)timeout;\n+ (NSTimeInterval)waitForIdleTimeout;\n\n/**\n * Set the idling timeout for different actions, for example events synthesis, rotation change,\n * etc. If the timeout expires then WDA tries to interact with the application even if it is not idling.\n * Setting it to zero disables idling checks.\n * The default timeout is set to 2 seconds.\n *\n * @param timeout The actual timeout value in float seconds\n */\n+ (void)setAnimationCoolOffTimeout:(NSTimeInterval)timeout;\n+ (NSTimeInterval)animationCoolOffTimeout;\n\n@end\n\nNS_ASSUME_NONNULL_END\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Utilities/FBConfiguration.m",
    "content": "/**\n * Copyright (c) 2015-present, Facebook, Inc.\n * All rights reserved.\n *\n * This source code is licensed under the BSD-style license found in the\n * LICENSE file in the root directory of this source tree. An additional grant\n * of patent rights can be found in the PATENTS file in the same directory.\n */\n\n#import \"FBConfiguration.h\"\n\n#include \"TargetConditionals.h\"\n#import \"XCTestConfiguration.h\"\n\nstatic NSTimeInterval FBWaitForIdleTimeout;\nstatic NSTimeInterval FBAnimationCoolOffTimeout;\n\n@implementation FBConfiguration\n\n#pragma mark Public\n\n+ (NSTimeInterval)waitForIdleTimeout\n{\n  return FBWaitForIdleTimeout;\n}\n\n+ (void)setWaitForIdleTimeout:(NSTimeInterval)timeout\n{\n  FBWaitForIdleTimeout = timeout;\n}\n\n+ (NSTimeInterval)animationCoolOffTimeout\n{\n  return FBAnimationCoolOffTimeout;\n}\n\n+ (void)setAnimationCoolOffTimeout:(NSTimeInterval)timeout\n{\n  FBAnimationCoolOffTimeout = timeout;\n}\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Utilities/FBLogger.h",
    "content": "/**\n * Copyright (c) 2015-present, Facebook, Inc.\n * All rights reserved.\n *\n * This source code is licensed under the BSD-style license found in the\n * LICENSE file in the root directory of this source tree. An additional grant\n * of patent rights can be found in the PATENTS file in the same directory.\n */\n\n#import <Foundation/Foundation.h>\n\nNS_ASSUME_NONNULL_BEGIN\n\n/**\n A Global Logger object that understands log levels\n */\n@interface FBLogger : NSObject\n\n/**\n Log to stdout.\n */\n+ (void)log:(NSString *)message;\n+ (void)logFmt:(NSString *)format, ... NS_FORMAT_FUNCTION(1,2);\n\n/**\n Log to stdout, only if WDA is Verbose\n */\n+ (void)verboseLog:(NSString *)message;\n+ (void)verboseLogFmt:(NSString *)format, ... NS_FORMAT_FUNCTION(1,2);\n\n@end\n\nNS_ASSUME_NONNULL_END\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Utilities/FBLogger.m",
    "content": "/**\n * Copyright (c) 2015-present, Facebook, Inc.\n * All rights reserved.\n *\n * This source code is licensed under the BSD-style license found in the\n * LICENSE file in the root directory of this source tree. An additional grant\n * of patent rights can be found in the PATENTS file in the same directory.\n */\n\n#import \"FBLogger.h\"\n\n@implementation FBLogger\n\n+ (void)log:(NSString *)message\n{\n  NSLog(@\"%@\", message);\n}\n\n+ (void)logFmt:(NSString *)format, ...\n{\n  va_list args;\n  va_start(args, format);\n  NSLogv(format, args);\n  va_end(args);\n}\n\n+ (void)verboseLog:(NSString *)message\n{\n  [self log:message];\n}\n\n+ (void)verboseLogFmt:(NSString *)format, ...\n{\n  va_list args;\n  va_start(args, format);\n  NSLogv(format, args);\n  va_end(args);\n}\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Utilities/XCAccessibilityElement.h",
    "content": "#import <XCTest/XCTest.h>\n\n@protocol XCAccessibilityElement <NSObject>\n\n@property(readonly) id payload; // @synthesize payload=_payload;\n@property(readonly) int processIdentifier; // @synthesize processIdentifier=_processIdentifier;\n@property(readonly) const struct __AXUIElement *AXUIElement; // @synthesize AXUIElement=_axElement;\n@property(readonly, getter=isNative) BOOL native;\n\n+ (id)elementWithAXUIElement:(struct __AXUIElement *)arg1;\n+ (id)elementWithProcessIdentifier:(int)arg1;\n+ (id)deviceElement;\n+ (id)mockElementWithProcessIdentifier:(int)arg1 payload:(id)arg2;\n+ (id)mockElementWithProcessIdentifier:(int)arg1;\n\n- (id)initWithMockProcessIdentifier:(int)arg1 payload:(id)arg2;\n- (id)initWithAXUIElement:(struct __AXUIElement *)arg1;\n- (id)init;\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Utilities/XCTestDaemonsProxy.h",
    "content": "#import <XCTest/XCTest.h>\n#import \"XCSynthesizedEventRecord.h\"\n\n\n@protocol XCTestManager_ManagerInterface;\n\n@interface XCTestDaemonsProxy : NSObject\n\n+ (id<XCTestManager_ManagerInterface>)testRunnerProxy;\n\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/Utilities/XCTestDaemonsProxy.m",
    "content": "#import \"XCTestDaemonsProxy.h\"\n#import \"FBLogger.h\"\n#import \"XCTRunnerDaemonSession.h\"\n\n@implementation XCTestDaemonsProxy\n\n+ (id<XCTestManager_ManagerInterface>)testRunnerProxy\n{\n    static id<XCTestManager_ManagerInterface> proxy = nil;\n    static dispatch_once_t onceToken;\n    dispatch_once(&onceToken, ^{\n        [FBLogger logFmt:@\"Using singleton test manager\"];\n        proxy = [self.class retrieveTestRunnerProxy];\n    });\n    return proxy;\n}\n\n+ (id<XCTestManager_ManagerInterface>)retrieveTestRunnerProxy\n{\n    return ((XCTRunnerDaemonSession *)[XCTRunnerDaemonSession sharedSession]).daemonProxy;\n}\n\n\n@end\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/maestro_driver_iosUITests.swift",
    "content": "import XCTest\nimport FlyingFox\nimport os\n\nfinal class maestro_driver_iosUITests: XCTestCase {\n   \n    private static let logger = Logger(\n        subsystem: Bundle.main.bundleIdentifier!,\n        category: \"maestro_driver_iosUITests\"\n    )\n\n    private static var swizzledOutIdle = false\n\n    override func setUpWithError() throws {\n        // XCTest internals sometimes use XCTAssert* instead of exceptions.\n        // Setting `continueAfterFailure` so that the xctest runner does not stop\n        // when an XCTest internal error happes (eg: when using .allElementsBoundByIndex\n        // on a ReactNative app)\n        continueAfterFailure = true\n    }\n\n    override class func setUp() {\n        logger.trace(\"setUp\")\n    }\n\n    func testHttpServer() async throws {\n        let server = XCTestHTTPServer()\n        maestro_driver_iosUITests.logger.info(\"Will start HTTP server\")\n        try await server.start()\n    }\n\n    override class func tearDown() {\n        logger.trace(\"tearDown\")\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/maestro-driver-iosUITests/maestro_driver_iosUITestsLaunchTests.swift",
    "content": "//\n//  maestro_driver_iosUITestsLaunchTests.swift\n//  maestro-driver-iosUITests\n//\n//  Created by Amanjeet Singh on 28/11/22.\n//\n\nimport XCTest\n\nclass maestro_driver_iosUITestsLaunchTests: XCTestCase {\n\n    override class var runsForEachTargetApplicationUIConfiguration: Bool {\n        true\n    }\n\n    override func setUpWithError() throws {\n        continueAfterFailure = false\n    }\n\n    func testLaunch() throws {\n        let app = XCUIApplication()\n        app.launch()\n\n        // Insert steps here to perform after app launch but before taking a screenshot,\n        // such as logging into a test account or navigating somewhere in the app\n\n        let attachment = XCTAttachment(screenshot: app.screenshot())\n        attachment.name = \"Launch Screen\"\n        attachment.lifetime = .keepAlways\n        add(attachment)\n    }\n}\n"
  },
  {
    "path": "maestro-ios-xctest-runner/run-maestro-ios-runner.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nif [ \"$(basename \"$PWD\")\" != \"maestro\" ]; then\n\techo \"This script must be run from the maestro root directory\"\n\texit 1\nfi\n\nDEVICE=\"${1:-}\"\nif [ -z \"$DEVICE\" ]; then\n\tDEVICE=\"iPhone 15\"\n\techo \"No device passed, will default to $DEVICE\"\nfi\n\nxctestrun_file=\"$(find ./build/Products -maxdepth 1 -name '*.xctestrun' -print)\"\nfile_count=\"$(echo \"$xctestrun_file\" | wc -l | tr -d '[:blank:]')\"\nif [ \"$file_count\" = 1 ]; then\n\techo \"xctestrun file found: $xctestrun_file\"\nelif [ \"$file_count\" = 0 ]; then\n\techo \"xctestrun file not found in ./build/Products. Did you build the runner?\"\n\texit 1\nelse\n\techo \"Multiple ($file_count) xctestrun files found in ./build/Products. Only 1 can be present.\"\n\texit 1\nfi\n\nxcodebuild test-without-building \\\n\t-xctestrun \"$xctestrun_file\" \\\n\t-destination \"platform=iOS Simulator,name=$DEVICE\" \\\n\t-destination-timeout 1\n"
  },
  {
    "path": "maestro-ios-xctest-runner/test-maestro-ios-runner.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nif [ \"$(basename \"$PWD\")\" != \"maestro\" ]; then\n\techo \"This script must be run from the maestro root directory\"\n\texit 1\nfi\n\nif [ ! -d ./build/Products/Debug-iphonesimulator/maestro-driver-iosUITests-Runner.app ]; then\n  echo \"XCTest runner app not found in ./build/Products/Debug-iphonesimulator/maestro-driver-iosUITests-Runner.app\"\n  exit 1\nfi\n\nif [ ! -d ./build/Products/Debug-iphonesimulator/maestro-driver-ios.app ]; then\n  echo \"Dummy test app not found in ./build/Products/Debug-iphonesimulator/maestro-driver-ios.app\"\n  exit 1\nfi\n\nif [ -z \"$(ls ./build/Products/*.xctestrun 2>/dev/null)\" ]; then\n  echo \"xctestrun file not found in ./build/Products/\"\n  exit 1\nfi\n\necho \"Will run the XCTest runner in the background and redirect its output\"\nmkfifo pipe\ntrap 'rm -f pipe' EXIT\n\nwhile IFS= read -r line; do\n\t  printf \"==> XCTestRunner: %s\\n\" \"$line\"\ndone < pipe &\n\n./maestro-ios-xctest-runner/run-maestro-ios-runner.sh 1>pipe 2>&1 &\necho \"XCTest runner started in background, PID: $!\"\n\nsleep 5\n\nrequest_successful=false\nwhile [ \"$request_successful\" = false ]; do\n  echo \"Will curl the /deviceInfo endpoint to check if the XCTest runner is ready\"\n  if ! test_upload_response=\"$(curl --fail-with-body -sS -X GET \"http://localhost:22087/deviceInfo\")\"; then\n\t  echo \"Error: failed to GET /deviceInfo endpoint\"\n\t  echo \"$test_upload_response\"\n\t  echo \"Will wait 5 seconds and try again\"\n\t  sleep 5\n\telse\n\t    request_successful=true\n\t    echo \"GET /deviceInfo endpoint successful\"\n  fi\ndone\n"
  },
  {
    "path": "maestro-orchestra/build.gradle.kts",
    "content": "import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask\n\nplugins {\n    id(\"maven-publish\")\n    alias(libs.plugins.kotlin.jvm)\n    alias(libs.plugins.kotlin.serialization)\n    alias(libs.plugins.mavenPublish)\n}\n\ndependencies {\n    api(project(\":maestro-orchestra-models\"))\n    implementation(project(\":maestro-client\"))\n    api(project(\":maestro-ai\"))\n    api(project(\":maestro-utils\"))\n\n    api(libs.square.okio)\n    api(libs.jackson.core.databind)\n    api(libs.jackson.module.kotlin)\n    api(libs.jackson.dataformat.yaml)\n    implementation(libs.kotlinx.coroutines.core)\n    implementation(libs.datafaker)\n    implementation(libs.kotlin.result)\n    implementation(libs.dd.plist)\n\n    testImplementation(libs.junit.jupiter.api)\n    testImplementation(libs.junit.jupiter.params)\n    testRuntimeOnly(libs.junit.jupiter.engine)\n\n    testImplementation(libs.google.truth)\n    testImplementation(libs.mockk)\n}\n\njava {\n    sourceCompatibility = JavaVersion.VERSION_17\n    targetCompatibility = JavaVersion.VERSION_17\n}\n\nkotlin {\n    jvmToolchain {\n        languageVersion.set(JavaLanguageVersion.of(17))\n    }\n}\n\ntasks.named(\"compileKotlin\", KotlinCompilationTask::class.java) {\n    compilerOptions {\n        freeCompilerArgs.addAll(\"-Xjdk-release=17\")\n    }\n}\n\nmavenPublishing {\n    publishToMavenCentral(true)\n    signAllPublications()\n}\n\ntasks.named<Test>(\"test\") {\n    useJUnitPlatform()\n    environment.put(\"PROJECT_DIR\", projectDir.absolutePath)\n}\n"
  },
  {
    "path": "maestro-orchestra/gradle.properties",
    "content": "POM_NAME=Maestro Orchestra\nPOM_ARTIFACT_ID=maestro-orchestra\nPOM_PACKAGING=jar"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/Orchestra.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.orchestra\n\nimport kotlinx.coroutines.currentCoroutineContext\nimport kotlinx.coroutines.isActive\nimport maestro.Driver\nimport maestro.ElementFilter\nimport maestro.Filters\nimport com.github.romankh3.image.comparison.ImageComparison\nimport com.github.romankh3.image.comparison.model.ImageComparisonState\nimport io.grpc.Status\nimport maestro.*\nimport maestro.Filters.asFilter\nimport maestro.FindElementResult\nimport maestro.Maestro\nimport maestro.MaestroException\nimport maestro.Point\nimport maestro.ScreenRecording\nimport maestro.UiElement\nimport maestro.ViewHierarchy\nimport maestro.ai.cloud.Defect\nimport maestro.ai.CloudAIPredictionEngine\nimport maestro.ai.AIPredictionEngine\nimport maestro.js.GraalJsEngine\nimport maestro.js.JsEngine\nimport maestro.js.RhinoJsEngine\nimport maestro.orchestra.error.UnicodeNotSupportedError\nimport maestro.orchestra.filter.FilterWithDescription\nimport maestro.orchestra.filter.TraitFilters\nimport maestro.orchestra.geo.Traveller\nimport maestro.orchestra.util.calculateElementRelativePoint\nimport maestro.orchestra.util.Env.evaluateScripts\nimport maestro.orchestra.yaml.YamlCommandReader\nimport maestro.toSwipeDirection\nimport maestro.utils.Insight\nimport maestro.utils.Insights\nimport maestro.utils.MaestroTimer\nimport maestro.utils.NoopInsights\nimport maestro.utils.StringUtils.toRegexSafe\nimport okhttp3.OkHttpClient\nimport okio.Buffer\nimport okio.BufferedSink\nimport okio.Sink\nimport okio.buffer\nimport okio.sink\nimport org.slf4j.LoggerFactory\nimport java.awt.image.BufferedImage\nimport java.io.File\nimport java.io.IOException\nimport java.lang.Long.max\nimport java.nio.file.Path\nimport java.nio.file.Files\nimport java.nio.file.Paths\nimport java.util.logging.Filter\nimport kotlin.coroutines.coroutineContext\nimport java.time.LocalDateTime\nimport java.time.format.DateTimeFormatter\nimport javax.imageio.ImageIO\n\n// TODO(bartkepacia): Use this in onCommandGeneratedOutput.\n//  Caveat:\n//    Large files should not be held in memory, instead they should be directly written to a Buffer\n//    that is streamed to disk.\n//  Idea:\n//    Orchestra should expose a callback like \"onResourceRequested: (Command, CommandOutputType)\"\n\ninterface FlowController {\n    suspend fun waitIfPaused()\n    fun pause()\n    fun resume()\n    val isPaused: Boolean\n}\n\nclass DefaultFlowController : FlowController {\n    private var _isPaused = false\n\n    override suspend fun waitIfPaused() {\n        while (_isPaused) {\n            if (!currentCoroutineContext().isActive) {\n                break\n            }\n            Thread.sleep(500)\n        }\n    }\n\n    override fun pause() {\n        _isPaused = true\n    }\n\n    override fun resume() {\n        _isPaused = false\n    }\n\n    override val isPaused: Boolean get() = _isPaused\n}\n\n/**\n * Orchestra translates high-level Maestro commands into method calls on the [Maestro] object.\n * It's the glue between the CLI and platform-specific [Driver]s (encapsulated in the [Maestro] object).\n * It's one of the core classes in this codebase.\n *\n * Orchestra should not know about:\n *  - Specific platforms where tests can be executed, such as Android, iOS, or the web.\n *  - File systems. It should instead write to [Sink]s that it requests from the caller.\n */\nclass Orchestra(\n    private val maestro: Maestro,\n    private val screenshotsDir: Path? = null, // TODO(bartekpacia): Orchestra shouldn't interact with files directly.\n    private val lookupTimeoutMs: Long = 17000L,\n    private val optionalLookupTimeoutMs: Long = 7000L,\n    private val httpClient: OkHttpClient? = null,\n    private val insights: Insights = NoopInsights,\n    private val onFlowStart: (List<MaestroCommand>) -> Unit = {},\n    private val onCommandStart: (Int, MaestroCommand) -> Unit = { _, _ -> },\n    private val onCommandComplete: (Int, MaestroCommand) -> Unit = { _, _ -> },\n    private val onCommandFailed: (Int, MaestroCommand, Throwable) -> ErrorResolution = { _, _, e -> throw e },\n    private val onCommandWarned: (Int, MaestroCommand) -> Unit = { _, _ -> },\n    private val onCommandSkipped: (Int, MaestroCommand) -> Unit = { _, _ -> },\n    private val onCommandReset: (MaestroCommand) -> Unit = {},\n    private val onCommandMetadataUpdate: (MaestroCommand, CommandMetadata) -> Unit = { _, _ -> },\n    private val onCommandGeneratedOutput: (command: Command, defects: List<Defect>, screenshot: Buffer) -> Unit = { _, _, _ -> },\n    private val apiKey: String? = null,\n    private val AIPredictionEngine: AIPredictionEngine? = apiKey?.let { CloudAIPredictionEngine(it) },\n    private val flowController: FlowController = DefaultFlowController(),\n    internal val jsEngineFactory: (MaestroConfig?) -> JsEngine = { config ->\n        val isRhino = config?.ext?.get(\"jsEngine\") == \"rhino\"\n        val platform = maestro.cachedDeviceInfo.platform.toString().lowercase()\n        if (isRhino) {\n            httpClient?.let { RhinoJsEngine(it, platform) } ?: RhinoJsEngine(platform = platform)\n        } else {\n            httpClient?.let { GraalJsEngine(it, platform) } ?: GraalJsEngine(platform = platform)\n        }\n    },\n) {\n\n    private lateinit var jsEngine: JsEngine\n\n    private var copiedText: String? = null\n\n    private var timeMsOfLastInteraction = System.currentTimeMillis()\n\n    private var screenRecording: ScreenRecording? = null\n\n    private val rawCommandToMetadata = mutableMapOf<MaestroCommand, CommandMetadata>()\n\n    suspend fun runFlow(commands: List<MaestroCommand>): Boolean {\n        timeMsOfLastInteraction = System.currentTimeMillis()\n\n        val config = YamlCommandReader.getConfig(commands)\n\n        initJsEngine(config)\n        initAndroidChromeDevTools(config)\n\n        onFlowStart(commands)\n\n        executeDefineVariablesCommands(commands, config)\n        // filter out DefineVariablesCommand to not execute it twice\n        val filteredCommands = commands.filter { it.asCommand() !is DefineVariablesCommand }\n\n        var flowSuccess = false\n        var exception: Throwable? = null\n        try {\n            val onStartSuccess = config?.onFlowStart?.commands?.let {\n                executeCommands(\n                    commands = it,\n                    config = config,\n                    shouldReinitJsEngine = false,\n                )\n            } ?: true\n\n            if (onStartSuccess) {\n                flowSuccess = executeCommands(\n                    commands = filteredCommands,\n                    config = config,\n                    shouldReinitJsEngine = false,\n                ).also {\n                    // close existing screen recording, if left open.\n                    screenRecording?.close()\n                }\n            }\n        } catch (e: Throwable) {\n            exception = e\n        } finally {\n            val onCompleteSuccess = config?.onFlowComplete?.commands?.let {\n                executeCommands(\n                    commands = it,\n                    config = config,\n                    shouldReinitJsEngine = false,\n                )\n            } ?: true\n\n            jsEngine.close()\n\n            exception?.let { throw it }\n\n            return onCompleteSuccess && flowSuccess\n        }\n    }\n\n    private suspend fun executeCommands(\n        commands: List<MaestroCommand>,\n        config: MaestroConfig? = null,\n        shouldReinitJsEngine: Boolean = true,\n    ): Boolean {\n        if (shouldReinitJsEngine) {\n            initJsEngine(config)\n        }\n\n        if (!currentCoroutineContext().isActive) {\n            logger.info(\"Flow cancelled, skipping initAndroidChromeDevTools...\")\n        } else {\n            initAndroidChromeDevTools(config)\n        }\n\n        commands\n            .forEachIndexed { index, command ->\n                if (!currentCoroutineContext().isActive) {\n                    logger.info(\"[Command execution] Command skipped due to cancellation: $command\")\n                    onCommandSkipped(index, command)\n                    return@forEachIndexed\n                }\n\n                // Check for pause before executing each command\n                flowController.waitIfPaused()\n\n                onCommandStart(index, command)\n\n                jsEngine.onLogMessage { msg ->\n                    val metadata = getMetadata(command)\n                    updateMetadata(\n                        command,\n                        metadata.copy(logMessages = metadata.logMessages + msg)\n                    )\n                    logger.info(\"JsConsole: $msg\")\n                }\n\n                val evaluatedCommand = command.evaluateScripts(jsEngine)\n                val metadata = getMetadata(command)\n                    .copy(\n                        evaluatedCommand = evaluatedCommand,\n                    )\n                updateMetadata(command, metadata)\n\n                val callback: (Insight) -> Unit = { insight ->\n                    updateMetadata(\n                        command,\n                        getMetadata(command).copy(\n                            insight = insight\n                        )\n                    )\n                }\n                insights.onInsightsUpdated(callback)\n\n                try {\n                    try {\n                        executeCommand(evaluatedCommand, config)\n                        onCommandComplete(index, command)\n                    } catch (e: MaestroException) {\n                        val isOptional =\n                            command.asCommand()?.optional == true || command.elementSelector()?.optional == true\n                        if (isOptional) throw CommandWarned(e.message)\n                        else throw e\n                    }\n                } catch (ignored: CommandWarned) {\n                    logger.info(\"[Command execution] CommandWarned: ${ignored.message}\")\n                    // Swallow exception, but add a warning as an insight\n                    insights.report(Insight(message = ignored.message, level = Insight.Level.WARNING))\n                    onCommandWarned(index, command)\n                } catch (ignored: CommandSkipped) {\n                    logger.info(\"[Command execution] CommandSkipped: ${ignored.message}\")\n                    // Swallow exception\n                    onCommandSkipped(index, command)\n                } catch (e: Throwable) {\n                    logger.error(\"[Command execution] CommandFailed: ${e.message}\")\n                    val errorResolution = onCommandFailed(index, command, e)\n                    when (errorResolution) {\n                        ErrorResolution.FAIL -> return false\n                        ErrorResolution.CONTINUE -> {} // Do nothing\n                    }\n                } finally {\n                    insights.unregisterListener(callback)\n                }\n            }\n        return true\n    }\n\n    @Synchronized\n    private fun initJsEngine(config: MaestroConfig?) {\n        if (this::jsEngine.isInitialized) {\n            jsEngine.close()\n        }\n        jsEngine = jsEngineFactory(config)\n    }\n\n    private fun initAndroidChromeDevTools(config: MaestroConfig?) {\n        if (config == null) return\n        val shouldEnableAndroidChromeDevTools = config.ext[\"androidWebViewHierarchy\"] == \"devtools\"\n        maestro.setAndroidChromeDevToolsEnabled(shouldEnableAndroidChromeDevTools)\n    }\n\n    /**\n     * Returns true if the command mutated device state (i.e. interacted with the device), false otherwise.\n     */\n    private suspend fun executeCommand(maestroCommand: MaestroCommand, config: MaestroConfig?): Boolean {\n        val command = maestroCommand.asCommand()\n\n        if (!currentCoroutineContext().isActive) {\n            throw CommandSkipped\n        }\n\n        flowController.waitIfPaused()\n\n        return when (command) {\n            is TapOnElementCommand -> {\n                tapOnElement(\n                    command = command,\n                    retryIfNoChange = command.retryIfNoChange ?: false,\n                    waitUntilVisible = command.waitUntilVisible ?: false,\n                    config = config\n                )\n            }\n\n            is TapOnPointCommand -> tapOnPoint(command, command.retryIfNoChange ?: false)\n            is TapOnPointV2Command -> tapOnPointV2Command(command)\n            is BackPressCommand -> backPressCommand()\n            is HideKeyboardCommand -> hideKeyboardCommand()\n            is ScrollCommand -> scrollVerticalCommand()\n            is CopyTextFromCommand -> copyTextFromCommand(command)\n            is SetClipboardCommand -> setClipboardCommand(command)\n            is ScrollUntilVisibleCommand -> scrollUntilVisible(command)\n            is PasteTextCommand -> pasteText()\n            is SwipeCommand -> swipeCommand(command)\n            is AssertCommand -> assertCommand(command)\n            is AssertScreenshotCommand -> assertScreenshotCommand(command)\n            is AssertConditionCommand -> assertConditionCommand(command)\n            is AssertNoDefectsWithAICommand -> assertNoDefectsWithAICommand(command, maestroCommand)\n            is AssertWithAICommand -> assertWithAICommand(command, maestroCommand)\n            is ExtractTextWithAICommand -> extractTextWithAICommand(command, maestroCommand)\n            is InputTextCommand -> inputTextCommand(command)\n            is InputRandomCommand -> inputTextRandomCommand(command)\n            is LaunchAppCommand -> launchAppCommand(command)\n            is SetPermissionsCommand -> setPermissionsCommand(command)\n            is OpenLinkCommand -> openLinkCommand(command, config)\n            is PressKeyCommand -> pressKeyCommand(command)\n            is EraseTextCommand -> eraseTextCommand(command)\n            is TakeScreenshotCommand -> takeScreenshotCommand(command)\n            is StopAppCommand -> stopAppCommand(command)\n            is KillAppCommand -> killAppCommand(command)\n            is ClearStateCommand -> clearAppStateCommand(command)\n            is ClearKeychainCommand -> clearKeychainCommand()\n            is RunFlowCommand -> runFlowCommand(command, config)\n            is SetLocationCommand -> setLocationCommand(command)\n            is SetOrientationCommand -> setOrientationCommand(command)\n            is RepeatCommand -> repeatCommand(command, maestroCommand, config)\n            is DefineVariablesCommand -> defineVariablesCommand(command)\n            is RunScriptCommand -> runScriptCommand(command)\n            is EvalScriptCommand -> evalScriptCommand(command)\n            is ApplyConfigurationCommand -> false\n            is WaitForAnimationToEndCommand -> waitForAnimationToEndCommand(command)\n            is TravelCommand -> travelCommand(command)\n            is StartRecordingCommand -> startRecordingCommand(command)\n            is StopRecordingCommand -> stopRecordingCommand()\n            is AddMediaCommand -> addMediaCommand(command.mediaPaths)\n            is SetAirplaneModeCommand -> setAirplaneMode(command)\n            is ToggleAirplaneModeCommand -> toggleAirplaneMode()\n            is RetryCommand -> retryCommand(command, config)\n            else -> true\n        }.also { mutating ->\n            if (mutating) {\n                timeMsOfLastInteraction = System.currentTimeMillis()\n            }\n        }\n    }\n\n    private fun setAirplaneMode(command: SetAirplaneModeCommand): Boolean {\n        when (command.value) {\n            AirplaneValue.Enable -> maestro.setAirplaneModeState(true)\n            AirplaneValue.Disable -> maestro.setAirplaneModeState(false)\n        }\n\n        return true\n    }\n\n    private fun toggleAirplaneMode(): Boolean {\n        maestro.setAirplaneModeState(!maestro.isAirplaneModeEnabled())\n        return true\n    }\n\n    private fun travelCommand(command: TravelCommand): Boolean {\n        Traveller.travel(\n            maestro = maestro,\n            points = command.points,\n            speedMPS = command.speedMPS ?: 4.0,\n        )\n\n        return true\n    }\n\n    private fun addMediaCommand(mediaPaths: List<String>): Boolean {\n        maestro.addMedia(mediaPaths)\n        return true\n    }\n\n    private fun assertConditionCommand(command: AssertConditionCommand): Boolean {\n        val timeout = (command.timeoutMs() ?: lookupTimeoutMs)\n        val debugMessage = \"\"\"\n            Assertion '${command.condition.description()}' failed. Check the UI hierarchy in debug artifacts to verify the element state and properties.\n            \n            Possible causes:\n            - Element selector may be incorrect - check if there are similar elements with slightly different names/properties.\n            - Element may be temporarily unavailable due to loading state\n            - This could be a real regression that needs to be addressed\n        \"\"\".trimIndent()\n        if (!evaluateCondition(command.condition, timeoutMs = timeout, commandOptional = command.optional)) {\n            throw MaestroException.AssertionFailure(\n                message = \"Assertion is false: ${command.condition.description()}\",\n                hierarchyRoot = maestro.viewHierarchy().root,\n                debugMessage = debugMessage\n            )\n        }\n\n        return false\n    }\n\n    private suspend fun assertNoDefectsWithAICommand(\n        command: AssertNoDefectsWithAICommand,\n        maestroCommand: MaestroCommand\n    ): Boolean {\n        if (AIPredictionEngine == null) {\n            throw MaestroException.CloudApiKeyNotAvailable(\"`MAESTRO_CLOUD_API_KEY` is not available. Did you export MAESTRO_CLOUD_API_KEY?\")\n        }\n\n        val metadata = getMetadata(maestroCommand)\n\n        val imageData = Buffer()\n        maestro.takeScreenshot(imageData, compressed = false)\n\n        val defects = AIPredictionEngine.findDefects(\n            screen = imageData.copy().readByteArray(),\n        )\n\n        if (defects.isNotEmpty()) {\n            onCommandGeneratedOutput(command, defects, imageData)\n\n            val word = if (defects.size == 1) \"defect\" else \"defects\"\n            val reasoning =\n                \"Found ${defects.size} possible $word:\\n${defects.joinToString(\"\\n\") { \"- ${it.reasoning}\" }}\"\n\n            updateMetadata(maestroCommand, metadata.copy(aiReasoning = reasoning))\n\n\n            throw MaestroException.AssertionFailure(\n                message = \"\"\"\n                    |$reasoning\n                    |\n                    \"\"\".trimMargin(),\n                hierarchyRoot = maestro.viewHierarchy().root,\n                debugMessage = \"AI-powered visual defect detection failed. Check the UI and screenshots in debug artifacts to verify if there are actual visual issues that were missed or if the AI detection needs adjustment.\"\n            )\n        }\n\n        return false\n    }\n\n    private suspend fun assertWithAICommand(command: AssertWithAICommand, maestroCommand: MaestroCommand): Boolean {\n        if (AIPredictionEngine == null) {\n            throw MaestroException.CloudApiKeyNotAvailable(\"`MAESTRO_CLOUD_API_KEY` is not available. Did you export MAESTRO_CLOUD_API_KEY?\")\n        }\n\n        val metadata = getMetadata(maestroCommand)\n\n        val imageData = Buffer()\n        maestro.takeScreenshot(imageData, compressed = false)\n        val defect = AIPredictionEngine.performAssertion(\n            screen = imageData.copy().readByteArray(),\n            assertion = command.assertion,\n        )\n\n        if (defect != null) {\n            onCommandGeneratedOutput(command, listOf(defect), imageData)\n\n            val reasoning = \"Assertion \\\"${command.assertion}\\\" failed:\\n${defect.reasoning}\"\n            updateMetadata(maestroCommand, metadata.copy(aiReasoning = reasoning))\n\n            throw MaestroException.AssertionFailure(\n                message = \"\"\"\n                    |$reasoning\n                    \"\"\".trimMargin(),\n                hierarchyRoot = maestro.viewHierarchy().root,\n            debugMessage = \"AI-powered assertion failed. Check the UI and screenshots in debug artifacts to verify if there are actual visual issues that were missed or if the AI detection needs adjustment.\")\n        }\n\n        return false\n    }\n\n    private suspend fun extractTextWithAICommand(\n        command: ExtractTextWithAICommand,\n        maestroCommand: MaestroCommand\n    ): Boolean {\n        if (AIPredictionEngine == null) {\n            throw MaestroException.CloudApiKeyNotAvailable(\"`MAESTRO_CLOUD_API_KEY` is not available. Did you export MAESTRO_CLOUD_API_KEY?\")\n        }\n\n        val metadata = getMetadata(maestroCommand)\n\n        val imageData = Buffer()\n        maestro.takeScreenshot(imageData, compressed = false)\n        val text = AIPredictionEngine.extractText(\n            screen = imageData.copy().readByteArray(),\n            query = command.query,\n        )\n\n        updateMetadata(\n            maestroCommand, metadata.copy(\n                aiReasoning = \"Query: \\\"${command.query}\\\"\\nExtracted text: $text\"\n            )\n        )\n        jsEngine.putEnv(command.outputVariable, text)\n\n        return false\n    }\n\n    private fun normalizeScreenshotPath(path: String): String {\n        val imageExtensions = listOf(\".png\", \".jpg\", \".jpeg\", \".gif\", \".bmp\", \".tiff\", \".wbmp\", \".heic\", \".heif\")\n        return if (imageExtensions.any { path.endsWith(it, ignoreCase = true) }) path else \"$path.png\"\n    }\n\n    private fun assertScreenshotCommand(command: AssertScreenshotCommand): Boolean {\n        val path = normalizeScreenshotPath(command.path)\n        val thresholdDifferencePercentage = (100 - command.thresholdPercentage)\n\n        val candidates = buildList {\n            command.flowPath?.let { add(it.resolve(path).toFile()) }\n            screenshotsDir?.let { add(it.resolve(path).toFile()) }\n            add(File(path))\n        }.distinctBy { it.canonicalPath }\n\n        val expectedFile = candidates.firstOrNull { it.exists() }\n            ?: throw MaestroException.AssertionFailure(\n                message = \"Screenshot file not found: $path. Searched in:\\n\" +\n                    candidates.joinToString(\"\\n\") { \"  - ${it.absolutePath}\" },\n                hierarchyRoot = maestro.viewHierarchy().root,\n                debugMessage = \"The assertScreenshot command requires a pre-existing reference screenshot. \" +\n                    \"Create it at one of the searched locations above.\"\n            )\n\n        expectedFile.parentFile?.mkdirs()\n\n        // Temp file is always PNG since maestro.takeScreenshot produces PNG\n        val actualScreenshotFile = File\n            .createTempFile(\"screenshot-${System.currentTimeMillis()}\", \".png\")\n            .also { it.deleteOnExit() }\n\n        val cropOn = command.cropOn\n        if (cropOn != null) {\n            val elementResult = findElement(cropOn, optional = command.optional)\n            val bounds = elementResult.element.bounds\n            if (bounds.width <= 0 || bounds.height <= 0) {\n                throw MaestroException.AssertionFailure(\n                    message = \"Cannot crop screenshot: element '${cropOn.description()}' has invalid dimensions (width: ${bounds.width}, height: ${bounds.height}). The element must have positive width and height to crop the screenshot.\",\n                    hierarchyRoot = maestro.viewHierarchy().root,\n                    debugMessage = \"The assertScreenshot command with cropOn requires an element with positive dimensions. The found element has bounds: x=${bounds.x}, y=${bounds.y}, width=${bounds.width}, height=${bounds.height}.\"\n                )\n            }\n            maestro.takeScreenshot(actualScreenshotFile.sink(), false, bounds)\n        } else {\n            maestro.takeScreenshot(actualScreenshotFile.sink(), false)\n        }\n\n        val actualImage: BufferedImage = ImageIO.read(actualScreenshotFile)\n\n        val expectedImage: BufferedImage = ImageIO.read(expectedFile) ?: throw MaestroException.AssertionFailure(\n            message = \"Failed to read image file: ${expectedFile.absolutePath}. Unsupported image format or file could not be read.\",\n            hierarchyRoot = maestro.viewHierarchy().root,\n            debugMessage = \"The assertScreenshot command requires a valid image file. Supported formats include PNG, JPEG, GIF, BMP, TIFF, and WBMP. The file at ${expectedFile.absolutePath} could not be read.\"\n        )\n\n        val baseName = if (path.contains('.')) {\n            path.substringBeforeLast('.')\n        } else {\n            path\n        }\n        val diffFileName = \"${baseName}_diff.png\"\n        val diffFile = expectedFile.parentFile?.resolve(diffFileName) ?: File(diffFileName)\n\n        val comparison =\n            ImageComparison(expectedImage, actualImage, diffFile)\n\n        comparison.apply {\n            allowingPercentOfDifferentPixels = thresholdDifferencePercentage\n            rectangleLineWidth = 10\n            pixelToleranceLevel = 0.1 \n            minimalRectangleSize = 40\n        }\n\n        val comparisonState = comparison.compareImages()\n\n        when (comparisonState.imageComparisonState) {\n            ImageComparisonState.MATCH -> return true\n            ImageComparisonState.SIZE_MISMATCH -> throw MaestroException.AssertionFailure(\n                message = \"Screenshot size mismatch: ${command.description()} - expected ${expectedImage.width}x${expectedImage.height}, actual ${actualImage.width}x${actualImage.height}. Screenshots must have the same dimensions to compare.\",\n                hierarchyRoot = maestro.viewHierarchy().root,\n                debugMessage = \"The assertScreenshot command requires the actual screenshot to have the same dimensions as the reference. Expected: ${expectedImage.width}x${expectedImage.height}, got: ${actualImage.width}x${actualImage.height}. Use the same device/emulator or cropOn to align dimensions.\"\n            )\n            ImageComparisonState.MISMATCH -> throw MaestroException.AssertionFailure(\n                message = \"Comparison error: ${command.description()} - threshold not met, current: ${100 - comparisonState.differencePercent}%\",\n                hierarchyRoot = maestro.viewHierarchy().root,\n                debugMessage = \"Screenshot comparison failed. Check the diff image at ${diffFile.absolutePath} to see the differences. Adjust the thresholdPercentage if the differences are acceptable.\"\n            )\n            else -> throw MaestroException.AssertionFailure(\n                message = \"Screenshot comparison failed: ${command.description()} - unexpected comparison state ${comparisonState.imageComparisonState}.\",\n                hierarchyRoot = maestro.viewHierarchy().root,\n                debugMessage = \"The assertScreenshot command encountered an unexpected result from the image comparison. State: ${comparisonState.imageComparisonState}\"\n            )\n        }\n    }\n\n\n    private fun evalScriptCommand(command: EvalScriptCommand): Boolean {\n        command.scriptString.evaluateScripts(jsEngine)\n\n        // We do not actually know if there were any mutations, but we assume there were\n        return true\n    }\n\n    private fun runScriptCommand(command: RunScriptCommand): Boolean {\n        return if (evaluateCondition(command.condition, commandOptional = command.optional)) {\n            jsEngine.evaluateScript(\n                script = command.script,\n                env = command.env,\n                sourceName = command.sourceDescription,\n                runInSubScope = true,\n            )\n\n            // We do not actually know if there were any mutations, but we assume there were\n            true\n        } else {\n            throw CommandSkipped\n        }\n    }\n\n    private fun waitForAnimationToEndCommand(command: WaitForAnimationToEndCommand): Boolean {\n        maestro.waitForAnimationToEnd(command.timeout)\n\n        return true\n    }\n\n    private fun defineVariablesCommand(command: DefineVariablesCommand): Boolean {\n        command.env.forEach { (name, value) ->\n            jsEngine.putEnv(name, value)\n        }\n\n        return false\n    }\n\n    private fun setLocationCommand(command: SetLocationCommand): Boolean {\n        maestro.setLocation(command.latitude, command.longitude)\n\n        return true\n    }\n\n    private fun setOrientationCommand(command: SetOrientationCommand): Boolean {\n        maestro.setOrientation(command.resolvedOrientation())\n\n        return true\n    }\n\n    private fun clearAppStateCommand(command: ClearStateCommand): Boolean {\n        maestro.clearAppState(command.appId)\n        // Android's clear command also resets permissions\n        // Reset all permissions to unset so both platforms behave the same\n        maestro.setPermissions(command.appId, mapOf(\"all\" to \"unset\"))\n\n        return true\n    }\n\n    private fun stopAppCommand(command: StopAppCommand): Boolean {\n        maestro.stopApp(command.appId)\n\n        return true\n    }\n\n    private fun killAppCommand(command: KillAppCommand): Boolean {\n        maestro.killApp(command.appId)\n\n        return true\n    }\n\n    private fun scrollVerticalCommand(): Boolean {\n        maestro.scrollVertical()\n        return true\n    }\n\n    private fun scrollUntilVisible(command: ScrollUntilVisibleCommand): Boolean {\n        val endTime = System.currentTimeMillis() + command.timeout.toLong()\n        val direction = command.direction.toSwipeDirection()\n        val deviceInfo = maestro.deviceInfo()\n\n        var retryCenterCount = 0\n        val maxRetryCenterCount = 4 // for when the list is no longer scrollable (last element) but the element is visible\n\n        do {\n            try {\n                val element = findElement(command.selector, command.optional, 500).element\n                val visibility = element.getVisiblePercentage(deviceInfo.widthGrid, deviceInfo.heightGrid)\n\n                logger.info(\"Scrolling try count: $retryCenterCount, DeviceWidth: ${deviceInfo.widthGrid}, DeviceWidth: ${deviceInfo.heightGrid}\")\n                logger.info(\"Element bounds: ${element.bounds}\")\n                logger.info(\"Visibility Percent: $visibility\")\n                logger.info(\"Command centerElement: $command.centerElement\")\n                logger.info(\"visibilityPercentageNormalized: ${command.visibilityPercentageNormalized}\")\n\n                if (command.centerElement && visibility > 0.1 && retryCenterCount <= maxRetryCenterCount) {\n                    if (element.isElementNearScreenCenter(direction, deviceInfo.widthGrid, deviceInfo.heightGrid)) {\n                        return true\n                    }\n                    retryCenterCount++\n                } else if (visibility >= command.visibilityPercentageNormalized) {\n                    return true\n                }\n            } catch (ignored: MaestroException.ElementNotFound) {\n                logger.warn(\"Error: $ignored\")\n            }\n            maestro.swipeFromCenter(\n                direction,\n                durationMs = command.scrollDuration.toLong(),\n                waitToSettleTimeoutMs = command.waitToSettleTimeoutMs\n            )\n        } while (System.currentTimeMillis() < endTime)\n\n        val debugMessage = buildString {\n            appendLine(\"Could not find a visible element matching selector: ${command.selector.description()}\")\n            appendLine(\"Tip: Try adjusting the following settings to improve detection:\")\n            appendLine(\"- `timeout`: current = ${command.timeout}ms → Increase if you need more time to find the element\")\n            val originalSpeed = command.originalSpeedValue?.toIntOrNull()\n            val speedAdvice = if (originalSpeed != null && originalSpeed > 50) {\n                \"Reduce for slower, more precise scrolling to avoid overshooting elements\"\n            } else {\n                \"Increase for faster scrolling if element is far away\"\n            }\n            appendLine(\"- `speed`: current = ${command.originalSpeedValue} (0-100 scale) → $speedAdvice\")\n            val waitSettleAdvice = if (command.waitToSettleTimeoutMs == null) {\n                \"Set this value (e.g., 500ms) if your UI updates frequently between scrolls\"\n            } else {\n                \"Increase if your UI needs more time to update between scrolls\"\n            }\n            val waitToTimeSettleMessage = if (command.waitToSettleTimeoutMs != null) {\n                \"${command.waitToSettleTimeoutMs}ms\"\n            } else {\n                \"Not defined\"\n            }\n            appendLine(\"- `waitToSettleTimeoutMs`: current = $waitToTimeSettleMessage → $waitSettleAdvice\")\n            appendLine(\"- `visibilityPercentage`: current = ${command.visibilityPercentage}% → Lower this value if you want to detect partially visible elements\")\n            val centerAdvice = if (command.centerElement) {\n                \"Disable if you don't need the element to be centered after finding it\"\n            } else {\n                \"Enable if you want the element to be centered after finding it\"\n            }\n            appendLine(\"- `centerElement`: current = ${command.centerElement} → $centerAdvice\")\n        }\n        throw MaestroException.ElementNotFound(\n            message = \"No visible element found: ${command.selector.description()}\",\n            maestro.viewHierarchy().root,\n            debugMessage = debugMessage\n        )\n    }\n\n    private fun hideKeyboardCommand(): Boolean {\n        maestro.hideKeyboard()\n\n        // Throw error in case keyboard is still visible\n        if (maestro.isKeyboardVisible()) {\n            throw MaestroException.HideKeyboardFailure(\n                \"Couldn't hide the keyboard. This can happen if the app uses a custom input or doesn't expose a standard dismiss action.\",\n                debugMessage = \"\"\"\n                    Instead of hideKeyboard, try tapping on non-interactive element to hide keyboard. Example:\n \n                    - tapOn: \n                        text: 'Static Text on your screen'\n                \"\"\".trimIndent()\n            )\n        }\n\n        return true\n    }\n\n    private fun backPressCommand(): Boolean {\n        maestro.backPress()\n        return true\n    }\n\n    private suspend fun repeatCommand(command: RepeatCommand, maestroCommand: MaestroCommand, config: MaestroConfig?): Boolean {\n        val maxRuns = command.times?.toDoubleOrNull()?.toInt() ?: Int.MAX_VALUE\n\n        var counter = 0\n        var metadata = getMetadata(maestroCommand)\n        metadata = metadata.copy(\n            numberOfRuns = 0,\n        )\n\n        var mutating = false\n\n        val checkCondition: () -> Boolean = {\n            command.condition\n                ?.evaluateScripts(jsEngine)\n                ?.let { evaluateCondition(it, commandOptional = command.optional) } != false\n        }\n\n        while (checkCondition() && counter < maxRuns && currentCoroutineContext().isActive) {\n            if (counter > 0) {\n                command.commands.forEach { resetCommand(it) }\n            }\n\n            val mutated = runSubFlow(command.commands, config, null)\n            mutating = mutating || mutated\n            counter++\n\n            metadata = metadata.copy(\n                numberOfRuns = counter,\n            )\n            updateMetadata(maestroCommand, metadata)\n        }\n\n        if (counter == 0) {\n            throw CommandSkipped\n        }\n\n        return mutating\n    }\n\n    private suspend fun retryCommand(command: RetryCommand, config: MaestroConfig?): Boolean {\n        val maxRetries = (command.maxRetries?.toIntOrNull() ?: 1).coerceAtMost(MAX_RETRIES_ALLOWED)\n\n        var attempt = 0\n        while (attempt <= maxRetries) {\n            try {\n                return runSubFlow(command.commands, config, command.config)\n            } catch (exception: Throwable) {\n                if (attempt == maxRetries) {\n                    logger.error(\"Max retries ($maxRetries) reached. Commands failed.\", exception)\n                    throw exception\n                }\n\n                val message =\n                    \"Retrying the commands due to an error: ${exception.message} while execution (Attempt ${attempt + 1})\"\n                logger.error(\"Attempt ${attempt + 1} failed for retry command\", exception)\n                insights.report(Insight(message = message, Insight.Level.WARNING))\n            }\n            attempt++\n        }\n\n        return false\n    }\n\n    private fun updateMetadata(rawCommand: MaestroCommand, metadata: CommandMetadata) {\n        rawCommandToMetadata[rawCommand] = metadata\n        onCommandMetadataUpdate(rawCommand, metadata)\n    }\n\n    private fun getMetadata(rawCommand: MaestroCommand) = rawCommandToMetadata.getOrPut(rawCommand) {\n        CommandMetadata()\n    }\n\n    private fun resetCommand(command: MaestroCommand) {\n        onCommandReset(command)\n\n        (command.asCommand() as? CompositeCommand)?.let {\n            it.subCommands().forEach { command ->\n                resetCommand(command)\n            }\n        }\n    }\n\n    private suspend fun runFlowCommand(command: RunFlowCommand, config: MaestroConfig?): Boolean {\n        return if (evaluateCondition(command.condition, command.optional)) {\n            runSubFlow(command.commands, config, command.config)\n        } else {\n            throw CommandSkipped\n        }\n    }\n\n    private fun evaluateCondition(\n        condition: Condition?,\n        commandOptional: Boolean,\n        timeoutMs: Long? = null,\n    ): Boolean {\n        if (condition == null) {\n            return true\n        }\n\n        condition.platform?.let {\n            if (it != maestro.cachedDeviceInfo.platform) {\n                return false\n            }\n        }\n\n        condition.scriptCondition?.let { value ->\n            // Note that script should have been already evaluated by this point\n\n            if (value.isBlank()) {\n                return false\n            }\n\n            if (value.equals(\"false\", ignoreCase = true)) {\n                return false\n            }\n\n            if (value == \"undefined\") {\n                return false\n            }\n\n            if (value == \"null\") {\n                return false\n            }\n\n            if (value.toDoubleOrNull() == 0.0) {\n                return false\n            }\n        }\n\n        condition.visible?.let {\n            try {\n                findElement(\n                    selector = it,\n                    timeoutMs = adjustedToLatestInteraction(timeoutMs ?: optionalLookupTimeoutMs),\n                    optional = commandOptional,\n                )\n            } catch (_: MaestroException.ElementNotFound) {\n                return false\n            }\n        }\n\n        condition.notVisible?.let {\n            val result = MaestroTimer.withTimeout(adjustedToLatestInteraction(timeoutMs ?: optionalLookupTimeoutMs)) {\n                try {\n                    findElement(\n                        selector = it,\n                        timeoutMs = 500L,\n                        optional = commandOptional,\n                    )\n\n                    // If we got to that point, the element is still visible.\n                    // Returning null to keep waiting.\n                    null\n                } catch (ignored: MaestroException.ElementNotFound) {\n                    // Element was not visible, as we expected\n                    true\n                }\n            }\n\n            // Element was actually visible\n            if (result != true) {\n                return false\n            }\n        }\n\n        return true\n    }\n\n    private suspend fun executeSubflowCommands(commands: List<MaestroCommand>, config: MaestroConfig?): Boolean {\n        jsEngine.enterScope()\n\n        return try {\n            commands\n                .mapIndexed { index, command ->\n                    onCommandStart(index, command)\n\n                    val evaluatedCommand = command.evaluateScripts(jsEngine)\n                    val metadata = getMetadata(command)\n                        .copy(\n                            evaluatedCommand = evaluatedCommand,\n                        )\n                    updateMetadata(command, metadata)\n\n                    return@mapIndexed try {\n                        try {\n                            executeCommand(evaluatedCommand, config)\n                                .also {\n                                    onCommandComplete(index, command)\n                                }\n                        } catch (exception: MaestroException) {\n                            val isOptional =\n                                command.asCommand()?.optional == true || command.elementSelector()?.optional == true\n                            if (isOptional) throw CommandWarned(exception.message)\n                            else throw exception\n                        }\n                    } catch (ignored: CommandWarned) {\n                        // Swallow exception, but add a warning as an insight\n                        logger.info(\"[Command execution subflow] CommandWarned: ${ignored.message}\")\n                        insights.report(Insight(message = ignored.message, level = Insight.Level.WARNING))\n                        onCommandWarned(index, command)\n                        false\n                    } catch (ignored: CommandSkipped) {\n                        // Swallow exception\n                        logger.info(\"[Command execution subflow] CommandSkipped: ${ignored.message}\")\n                        onCommandSkipped(index, command)\n                        false\n                    } catch (e: Throwable) {\n                        when (onCommandFailed(index, command, e)) {\n                            ErrorResolution.FAIL -> throw e\n                            ErrorResolution.CONTINUE -> {\n                                // Do nothing\n                                false\n                            }\n                        }\n                    }\n                }\n                .any { it }\n        } finally {\n            jsEngine.leaveScope()\n        }\n    }\n\n    private suspend fun runSubFlow(\n        commands: List<MaestroCommand>,\n        config: MaestroConfig?,\n        subflowConfig: MaestroConfig?,\n    ): Boolean {\n        // Enter environment scope to isolate environment variables for this subflow\n        jsEngine.enterEnvScope()\n        return try {\n            executeDefineVariablesCommands(commands, config)\n            // filter out DefineVariablesCommand to not execute it twice\n            val filteredCommands = commands.filter { it.asCommand() !is DefineVariablesCommand }\n\n            var flowSuccess = false\n            val onCompleteSuccess: Boolean\n            try {\n                val onStartSuccess = subflowConfig?.onFlowStart?.commands?.let {\n                    executeSubflowCommands(it, config)\n                } ?: true\n\n                if (onStartSuccess) {\n                    flowSuccess = executeSubflowCommands(filteredCommands, config)\n                }\n            } catch (e: Throwable) {\n                throw e\n            } finally {\n                onCompleteSuccess = subflowConfig?.onFlowComplete?.commands?.let {\n                    executeSubflowCommands(it, config)\n                } ?: true\n            }\n            onCompleteSuccess && flowSuccess\n        } finally {\n            jsEngine.leaveEnvScope()\n        }\n    }\n\n    private fun takeScreenshotCommand(command: TakeScreenshotCommand): Boolean {\n        val pathStr = command.path + \".png\"\n        val fileSink = getFileSink(screenshotsDir, pathStr)\n\n        val cropOn = command.cropOn\n        if (cropOn == null) {\n            maestro.takeScreenshot(fileSink, false)\n        } else {\n            val elementResult = findElement(cropOn, optional = command.optional)\n            val bounds = elementResult.element.bounds\n            if (bounds.width <= 0 || bounds.height <= 0) {\n                throw MaestroException.AssertionFailure(\n                    message = \"Cannot crop screenshot: element '${cropOn.description()}' has invalid dimensions (width: ${bounds.width}, height: ${bounds.height}). The element must have positive width and height to crop the screenshot.\",\n                    hierarchyRoot = maestro.viewHierarchy().root,\n                    debugMessage = \"The takeScreenshot command with cropOn requires an element with positive dimensions. The found element has bounds: x=${bounds.x}, y=${bounds.y}, width=${bounds.width}, height=${bounds.height}.\"\n                )\n            }\n            maestro.takeScreenshot(fileSink, false, bounds)\n        }\n        return false\n    }\n\n    private fun startRecordingCommand(command: StartRecordingCommand): Boolean {\n        val pathStr = command.path + \".mp4\"\n        val fileSink = getFileSink(screenshotsDir, pathStr)\n        screenRecording = maestro.startScreenRecording(fileSink)\n        return false\n    }\n\n    private fun stopRecordingCommand(): Boolean {\n        screenRecording?.close()\n        return false\n    }\n\n    private fun eraseTextCommand(command: EraseTextCommand): Boolean {\n        val charactersToErase = command.charactersToErase\n        maestro.eraseText(charactersToErase ?: MAX_ERASE_CHARACTERS)\n        maestro.waitForAppToSettle()\n\n        return true\n    }\n\n    private fun pressKeyCommand(command: PressKeyCommand): Boolean {\n        maestro.pressKey(command.code)\n\n        return true\n    }\n\n    private fun openLinkCommand(command: OpenLinkCommand, config: MaestroConfig?): Boolean {\n        maestro.openLink(command.link, config?.appId, command.autoVerify ?: false, command.browser ?: false)\n\n        return true\n    }\n\n    private fun launchAppCommand(command: LaunchAppCommand): Boolean {\n        try {\n            if (command.clearKeychain == true) {\n                maestro.clearKeychain()\n            }\n            if (command.clearState == true) {\n                maestro.clearAppState(command.appId)\n            }\n        } catch (e: Exception) {\n            logger.error(\"Failed to clear state\", e)\n            throw MaestroException.UnableToClearState(\"Unable to clear state for app ${command.appId}: ${e.message}\", e)\n        }\n\n        try {\n            // For testing convenience, default to allow all on app launch\n            val permissions = command.permissions ?: mapOf(\"all\" to \"allow\")\n            maestro.setPermissions(command.appId, permissions)\n        } catch (e: Exception) {\n            logger.error(\"Failed to set permissions\", e)\n            throw MaestroException.UnableToSetPermissions(\"Unable to set permissions for app ${command.appId}: ${e.message}\", e)\n        }\n\n        try {\n            maestro.launchApp(\n                appId = command.appId,\n                launchArguments = command.launchArguments ?: emptyMap(),\n                stopIfRunning = command.stopApp ?: true\n            )\n        } catch (e: Exception) {\n            logger.error(\"Failed to launch app\", e)\n            throw MaestroException.UnableToLaunchApp(\"Unable to launch app ${command.appId}\", cause = e)\n        }\n\n        return true\n    }\n\n    private fun setPermissionsCommand(command: SetPermissionsCommand): Boolean {\n        try {\n            maestro.setPermissions(command.appId, command.permissions)\n        } catch (e: Exception) {\n            throw MaestroException.UnableToSetPermissions(\"Unable to set permissions for app ${command.appId}: ${e.message}\", e)\n        }\n\n        return true\n    }\n\n    private fun clearKeychainCommand(): Boolean {\n        maestro.clearKeychain()\n\n        return true\n    }\n\n    private fun inputTextCommand(command: InputTextCommand): Boolean {\n        if (!maestro.isUnicodeInputSupported()) {\n            val isAscii = Charsets.US_ASCII.newEncoder()\n                .canEncode(command.text)\n\n            if (!isAscii) {\n                throw UnicodeNotSupportedError(command.text)\n            }\n        }\n\n        maestro.inputText(command.text)\n\n        return true\n    }\n\n    private fun inputTextRandomCommand(command: InputRandomCommand): Boolean {\n        inputTextCommand(InputTextCommand(text = command.genRandomString()))\n\n        return true\n    }\n\n    private fun assertCommand(command: AssertCommand): Boolean {\n        return assertConditionCommand(\n            command.toAssertConditionCommand()\n        )\n    }\n\n    private fun tapOnElement(\n        command: TapOnElementCommand,\n        retryIfNoChange: Boolean,\n        waitUntilVisible: Boolean,\n        config: MaestroConfig?,\n    ): Boolean {\n        val result = findElement(command.selector, optional = command.optional)\n\n\n        // Handle element-relative tap if specified\n        val relativePoint = command.relativePoint\n        if (relativePoint != null) {\n            val tapPoint = calculateElementRelativePoint(result.element, relativePoint)      \n                  \n            maestro.tap(\n                x = tapPoint.x,\n                y = tapPoint.y,\n                retryIfNoChange = retryIfNoChange,\n                longPress = command.longPress ?: false,\n                tapRepeat = command.repeat,\n                waitToSettleTimeoutMs = command.waitToSettleTimeoutMs,\n            )\n        } else {\n            // Default behavior: tap at element center\n            maestro.tap(\n                element = result.element,\n                initialHierarchy = result.hierarchy,\n                retryIfNoChange = retryIfNoChange,\n                waitUntilVisible = waitUntilVisible,\n                longPress = command.longPress ?: false,\n                appId = config?.appId,\n                tapRepeat = command.repeat,\n                waitToSettleTimeoutMs = command.waitToSettleTimeoutMs,\n            )\n        }\n\n        return true\n    }\n\n    private fun tapOnPoint(\n        command: TapOnPointCommand,\n        retryIfNoChange: Boolean,\n    ): Boolean {\n        maestro.tap(\n            x = command.x,\n            y = command.y,\n            retryIfNoChange = retryIfNoChange,\n            longPress = command.longPress ?: false,\n            tapRepeat = command.repeat,\n        )\n\n        return true\n    }\n\n    private fun tapOnPointV2Command(\n        command: TapOnPointV2Command,\n    ): Boolean {\n        val point = command.point\n\n        if (point.contains(\"%\")) {\n            val (percentX, percentY) = point\n                .replace(\"%\", \"\")\n                .split(\",\")\n                .map { it.trim().toInt() }\n\n            if (percentX !in 0..100 || percentY !in 0..100) {\n                throw MaestroException.InvalidCommand(\"Invalid point: $point\")\n            }\n\n            maestro.tapOnRelative(\n                percentX = percentX,\n                percentY = percentY,\n                retryIfNoChange = command.retryIfNoChange ?: false,\n                longPress = command.longPress ?: false,\n                tapRepeat = command.repeat,\n                waitToSettleTimeoutMs = command.waitToSettleTimeoutMs\n            )\n        } else {\n            val (x, y) = point.split(\",\")\n                .map {\n                    it.trim().toInt()\n                }\n\n            maestro.tap(\n                x = x,\n                y = y,\n                retryIfNoChange = command.retryIfNoChange ?: false,\n                longPress = command.longPress ?: false,\n                tapRepeat = command.repeat,\n                waitToSettleTimeoutMs = command.waitToSettleTimeoutMs\n            )\n        }\n\n        return true\n    }\n\n\n    private fun findElement(\n        selector: ElementSelector,\n        optional: Boolean,\n        timeoutMs: Long? = null,\n    ): FindElementResult {\n        val timeout =\n            timeoutMs ?: adjustedToLatestInteraction(\n                if (optional) optionalLookupTimeoutMs\n                else lookupTimeoutMs,\n            )\n\n        val (description, filterFunc) = buildFilter(selector = selector)\n        val debugMessage = \"\"\"\n            Element with $description not found. Check the UI hierarchy in debug artifacts to verify if the element exists.\n            \n            Possible causes:\n            - Element selector may be incorrect - check if there are similar elements with slightly different names/properties.\n            - Element may be temporarily unavailable due to loading state.\n            - This could be a real regression that needs to be addressed.\n        \"\"\".trimIndent()\n        if (selector.childOf != null) {\n            val parentViewHierarchy = findElementViewHierarchy(\n                selector.childOf,\n                timeout\n            )\n            return maestro.findElementWithTimeout(\n                timeout,\n                filterFunc,\n                parentViewHierarchy\n            ) ?: throw MaestroException.ElementNotFound(\n                \"Element not found: $description\",\n                parentViewHierarchy.root,\n                debugMessage = debugMessage\n            )\n        }\n\n\n        val exceptionDebugMessage = \"\"\"\n            Element with $description not found. Check the UI hierarchy in debug artifacts to verify if the element exists.\n            \n            Possible causes:\n            - Element selector may be incorrect - check if there are similar elements with slightly different names/properties.\n            - Element may be temporarily unavailable due to loading state.\n            - This could be a real regression that needs to be addressed.\n        \"\"\".trimIndent()\n        return maestro.findElementWithTimeout(\n            timeoutMs = timeout,\n            filter = filterFunc\n        ) ?: throw MaestroException.ElementNotFound(\n            \"Element not found: $description\",\n            maestro.viewHierarchy().root,\n            debugMessage = exceptionDebugMessage\n        )\n    }\n\n    private fun findElementViewHierarchy(\n        selector: ElementSelector?,\n        timeout: Long\n    ): ViewHierarchy {\n        if (selector == null) {\n            return maestro.viewHierarchy()\n        }\n        val parentViewHierarchy = findElementViewHierarchy(selector.childOf, timeout)\n        val (description, filterFunc) = buildFilter(selector = selector)\n        val debugMessage = \"\"\"\n            Element with $description not found. Check the UI hierarchy in debug artifacts to verify if the element exists.\n            \n            Possible causes:\n            - Element selector may be incorrect - check if there are similar elements with slightly different names/properties.\n            - Element may be temporarily unavailable due to loading state.\n            - This could be a real regression that needs to be addressed.\n        \"\"\".trimIndent()\n        return maestro.findElementWithTimeout(\n            timeout,\n            filterFunc,\n            parentViewHierarchy\n        )?.hierarchy ?: throw MaestroException.ElementNotFound(\n            \"Element not found: $description\",\n            parentViewHierarchy.root,\n            debugMessage = debugMessage\n        )\n    }\n\n    private fun buildFilter(\n        selector: ElementSelector,\n    ): FilterWithDescription {\n        val basicFilters = mutableListOf<ElementFilter>()\n        val relativeFilters = mutableListOf<ElementFilter>()\n        val descriptions = mutableListOf<String>()\n\n        selector.textRegex\n            ?.let {\n                descriptions += \"Text matching regex: $it\"\n                basicFilters += Filters.textMatches(it.toRegexSafe(REGEX_OPTIONS))\n            }\n\n        selector.idRegex\n            ?.let {\n                descriptions += \"Id matching regex: $it\"\n                basicFilters += Filters.idMatches(it.toRegexSafe(REGEX_OPTIONS))\n            }\n        selector.size\n            ?.let {\n                descriptions += \"Size: $it\"\n                basicFilters += Filters.sizeMatches(\n                    width = it.width,\n                    height = it.height,\n                    tolerance = it.tolerance,\n                ).asFilter()\n            }\n\n        selector.below\n            ?.let {\n                descriptions += \"Below: ${it.description()}\"\n                relativeFilters += Filters.below(buildFilter(it).filterFunc)\n            }\n\n        selector.above\n            ?.let {\n                descriptions += \"Above: ${it.description()}\"\n                relativeFilters += Filters.above(buildFilter(it).filterFunc)\n            }\n\n        selector.leftOf\n            ?.let {\n                descriptions += \"Left of: ${it.description()}\"\n                relativeFilters += Filters.leftOf(buildFilter(it).filterFunc)\n            }\n\n        selector.rightOf\n            ?.let {\n                descriptions += \"Right of: ${it.description()}\"\n                relativeFilters += Filters.rightOf(buildFilter(it).filterFunc)\n            }\n\n        selector.containsChild\n            ?.let {\n                descriptions += \"Contains child: ${it.description()}\"\n                relativeFilters += Filters.containsChild(findElement(it, optional = false).element).asFilter()\n            }\n\n        selector.containsDescendants\n            ?.let { descendantSelectors ->\n                val descendantDescriptions = descendantSelectors.joinToString(\"; \") { it.description() }\n                descriptions += \"Contains descendants: $descendantDescriptions\"\n                relativeFilters += Filters.containsDescendants(descendantSelectors.map { buildFilter(it).filterFunc })\n            }\n\n        selector.traits\n            ?.map {\n                TraitFilters.buildFilter(it)\n            }\n            ?.forEach { (description, filter) ->\n                descriptions += description\n                basicFilters += filter\n            }\n\n        selector.enabled\n            ?.let {\n                descriptions += if (it) {\n                    \"Enabled\"\n                } else {\n                    \"Disabled\"\n                }\n                basicFilters += Filters.enabled(it)\n            }\n\n        selector.selected\n            ?.let {\n                descriptions += if (it) {\n                    \"Selected\"\n                } else {\n                    \"Not selected\"\n                }\n                basicFilters += Filters.selected(it)\n            }\n\n        selector.checked\n            ?.let {\n                descriptions += if (it) {\n                    \"Checked\"\n                } else {\n                    \"Not checked\"\n                }\n                basicFilters += Filters.checked(it)\n            }\n\n        selector.focused\n            ?.let {\n                descriptions += if (it) {\n                    \"Focused\"\n                } else {\n                    \"Not focused\"\n                }\n                basicFilters += Filters.focused(it)\n            }\n\n        selector.css\n            ?.let {\n                descriptions += \"CSS: $it\"\n                basicFilters += Filters.css(maestro, it)\n            }\n\n        // Apply deepestMatchingElement only to basic filters, then intersect with relative filters\n        val basicFilter = if (basicFilters.isNotEmpty()) {\n            Filters.deepestMatchingElement(Filters.intersect(basicFilters))\n        } else {\n            { nodes -> nodes } // Identity filter if no basic filters\n        }\n        \n        val allFilters = listOf(basicFilter) + relativeFilters\n        var resultFilter = Filters.intersect(allFilters)\n\n        resultFilter = selector.index\n            ?.toDouble()\n            ?.toInt()\n            ?.let {\n                Filters.compose(\n                    resultFilter,\n                    Filters.index(it)\n                )\n            } ?: Filters.compose(\n            resultFilter,\n            Filters.clickableFirst()\n        )\n\n        return FilterWithDescription(\n            descriptions.joinToString(\", \"),\n            resultFilter,\n        )\n    }\n\n    private fun swipeCommand(command: SwipeCommand): Boolean {\n        val elementSelector = command.elementSelector\n        val direction = command.direction\n        val startRelative = command.startRelative\n        val endRelative = command.endRelative\n        val start = command.startPoint\n        val end = command.endPoint\n        when {\n            elementSelector != null && direction != null -> {\n                val uiElement = findElement(elementSelector, optional = command.optional)\n                maestro.swipe(\n                    direction,\n                    uiElement.element,\n                    command.duration,\n                    waitToSettleTimeoutMs = command.waitToSettleTimeoutMs\n                )\n            }\n\n            startRelative != null && endRelative != null -> {\n                maestro.swipe(\n                    startRelative = startRelative,\n                    endRelative = endRelative,\n                    duration = command.duration,\n                    waitToSettleTimeoutMs = command.waitToSettleTimeoutMs\n                )\n            }\n\n            direction != null -> maestro.swipe(\n                swipeDirection = direction,\n                duration = command.duration,\n                waitToSettleTimeoutMs = command.waitToSettleTimeoutMs\n            )\n\n            start != null && end != null -> maestro.swipe(\n                startPoint = start,\n                endPoint = end,\n                duration = command.duration,\n                waitToSettleTimeoutMs = command.waitToSettleTimeoutMs\n            )\n\n            else -> error(\"Illegal arguments for swiping\")\n        }\n        return true\n    }\n\n    private fun adjustedToLatestInteraction(timeMs: Long) = max(\n        0,\n        timeMs - (System.currentTimeMillis() - timeMsOfLastInteraction),\n    )\n\n    private fun copyTextFromCommand(command: CopyTextFromCommand): Boolean {\n        val result = findElement(command.selector, optional = command.optional)\n        copiedText = resolveText(result.element.treeNode.attributes)\n            ?: throw MaestroException.UnableToCopyTextFromElement(\"Element does not contain text to copy: ${result.element}\")\n\n        jsEngine.setCopiedText(copiedText)\n\n        return true\n    }\n\n    private fun setClipboardCommand(command: SetClipboardCommand): Boolean {\n        copiedText = command.text\n        jsEngine.setCopiedText(copiedText)\n\n        return true\n    }\n\n    private fun resolveText(attributes: MutableMap<String, String>): String? {\n        return if (!attributes[\"text\"].isNullOrEmpty()) {\n            attributes[\"text\"]\n        } else if (!attributes[\"hintText\"].isNullOrEmpty()) {\n            attributes[\"hintText\"]\n        } else {\n            attributes[\"accessibilityText\"]\n        }\n    }\n\n    private fun pasteText(): Boolean {\n        copiedText?.let { maestro.inputText(it) }\n        return true\n    }\n\n    private fun getFileSink(parentPath: Path?, filePathStr: String): BufferedSink {\n        // Work out relative v absolute input\n        val resolvedFile = parentPath?.resolve(filePathStr)?.toFile() ?: File(filePathStr)\n        val absoluteFile = resolvedFile.absoluteFile\n\n        if(absoluteFile.parentFile.exists() || absoluteFile.parentFile.mkdirs()) {\n            return resolvedFile\n                .sink()\n                .buffer()\n        } else {\n            throw MaestroException.DestinationIsNotWritable(\n                \"Unable to create directory for file: ${absoluteFile.parentFile.absolutePath}\"\n            )\n        }\n    }\n\n    private suspend fun executeDefineVariablesCommands(commands: List<MaestroCommand>, config: MaestroConfig?) {\n        commands.filter { it.asCommand() is DefineVariablesCommand }.takeIf { it.isNotEmpty() }?.let {\n            executeCommands(\n                commands = it,\n                config = config,\n                shouldReinitJsEngine = false\n            )\n        }\n    }\n\n    private object CommandSkipped : Exception()\n\n    class CommandWarned(override val message: String) : Exception(message)\n\n    data class CommandMetadata(\n        val numberOfRuns: Int? = null,\n        val evaluatedCommand: MaestroCommand? = null,\n        val logMessages: List<String> = emptyList(),\n        val insight: Insight = Insight(\"\", Insight.Level.NONE),\n        val aiReasoning: String? = null,\n        val labeledCommand: String? = null\n    )\n\n    enum class ErrorResolution {\n        CONTINUE,\n        FAIL\n    }\n\n    companion object {\n\n        val REGEX_OPTIONS = setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL, RegexOption.MULTILINE)\n\n        private const val MAX_ERASE_CHARACTERS = 50\n        private const val MAX_RETRIES_ALLOWED = 3\n        private val logger = LoggerFactory.getLogger(Orchestra::class.java)\n    }\n\n    // Remove pause/resume functions that were storing/restoring engine\n    fun pause() {\n        flowController.pause()\n    }\n\n    fun resume() {\n        flowController.resume()\n    }\n\n    val isPaused: Boolean\n        get() = flowController.isPaused\n}\n\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/error/InvalidFlowFile.kt",
    "content": "package maestro.orchestra.error\n\nimport java.nio.file.Path\n\nclass InvalidFlowFile(\n    override val message: String,\n    val flowPath: Path\n) : RuntimeException()"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/error/MediaFileNotFound.kt",
    "content": "package maestro.orchestra.error\n\nimport java.nio.file.Path\n\nclass MediaFileNotFound(\n    override val message: String,\n    val mediaPath: Path\n): ValidationError(message)"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/error/NoInputException.kt",
    "content": "package maestro.orchestra.error\n\nobject NoInputException : RuntimeException()"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/error/SyntaxError.kt",
    "content": "package maestro.orchestra.error\n\nclass SyntaxError(override val message: String, cause: Throwable? = null) : ValidationError(message, cause)"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/error/UnicodeNotSupportedError.kt",
    "content": "package maestro.orchestra.error\n\ndata class UnicodeNotSupportedError(\n    val text: String,\n) : RuntimeException(\"Unicode not supported: $text\")"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/error/ValidationError.kt",
    "content": "package maestro.orchestra.error\n\nopen class ValidationError(override val message: String, cause: Throwable? = null) : RuntimeException(message, cause)\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/filter/FilterWithDescription.kt",
    "content": "package maestro.orchestra.filter\n\nimport maestro.ElementFilter\n\ndata class FilterWithDescription(\n    val description: String,\n    val filterFunc: ElementFilter,\n)\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/filter/LaunchArguments.kt",
    "content": "package maestro.orchestra.filter\n\nimport maestro.MaestroException\n\nobject LaunchArguments {\n\n    private const val MAX_LAUNCH_ARGUMENT_PAIRS_ALLOWED = 1\n\n    fun List<String>?.toSanitizedLaunchArguments(appId: String): List<String> {\n        if (isNullOrEmpty()) return emptyList()\n\n        return map {\n            val allowedPairs = it.filter { char -> char == '=' }.length\n\n            val message = \"Unable to launch app $appId, launch arguments can either have one key/value \" +\n                \"pair or a boolean key. Look documentation for more details.\"\n            if (allowedPairs > MAX_LAUNCH_ARGUMENT_PAIRS_ALLOWED) throw MaestroException.UnableToLaunchApp(message)\n\n            return@map if (it.contains(\"=\")) {\n                it.split(\"=\").joinToString(separator = \"=\") { param -> param.trim() }\n            } else {\n                it\n            }\n        }\n    }\n}"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/filter/TraitFilters.kt",
    "content": "package maestro.orchestra.filter\n\nimport maestro.Filters\nimport maestro.Filters.asFilter\nimport maestro.orchestra.ElementTrait\n\nobject TraitFilters {\n\n    fun buildFilter(\n        trait: ElementTrait,\n    ): FilterWithDescription {\n        return when (trait) {\n            ElementTrait.TEXT -> FilterWithDescription(\n                trait.description,\n                Filters.hasText().asFilter()\n            )\n            ElementTrait.SQUARE -> FilterWithDescription(\n                trait.description,\n                Filters.isSquare().asFilter()\n            )\n            ElementTrait.LONG_TEXT -> FilterWithDescription(\n                trait.description,\n                Filters.hasLongText().asFilter()\n            )\n        }\n    }\n\n}"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/geo/Traveller.kt",
    "content": "package maestro.orchestra.geo\n\nimport maestro.Maestro\nimport maestro.orchestra.TravelCommand\nimport java.util.LinkedList\n\nobject Traveller {\n\n    fun travel(\n        maestro: Maestro,\n        points: List<TravelCommand.GeoPoint>,\n        speedMPS: Double,\n    ) {\n        if (points.isEmpty()) {\n            return\n        }\n\n        val pointsQueue = LinkedList(points)\n\n        var start = pointsQueue.poll()\n        maestro.setLocation(start.latitude, start.longitude)\n\n        do {\n            val next = pointsQueue.poll() ?: return\n\n            travel(maestro, start, next, speedMPS)\n            start = next\n        } while (pointsQueue.isNotEmpty())\n    }\n\n    private fun travel(\n        maestro: Maestro,\n        start: TravelCommand.GeoPoint,\n        end: TravelCommand.GeoPoint,\n        speedMPS: Double,\n    ) {\n        val steps = 50\n\n        val distance = start.getDistanceInMeters(end)\n\n        val timeToTravel = distance / speedMPS\n        val timeToTravelInMilliseconds = (timeToTravel * 1000).toLong()\n\n        val timeToSleep = timeToTravelInMilliseconds / steps\n\n        val sLat = start.latitude.toDouble()\n        val sLon = start.longitude.toDouble()\n\n        val eLat = end.latitude.toDouble()\n        val eLon = end.longitude.toDouble()\n\n        val latitudeStep = (eLat - sLat) / steps\n        val longitudeStep = (eLon - sLon) / steps\n\n        for (i in 1..steps) {\n            val latitude = sLat + (latitudeStep * i)\n            val longitude = sLon + (longitudeStep * i)\n\n            maestro.setLocation(latitude.toString(), longitude.toString())\n            Thread.sleep(timeToSleep)\n        }\n    }\n\n}"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/util/AppMetadataAnalyzer.kt",
    "content": "package maestro.orchestra.validation\n\nimport com.dd.plist.NSDictionary\nimport com.dd.plist.PropertyListParser\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport com.fasterxml.jackson.module.kotlin.readValue\nimport maestro.device.Platform\nimport net.dongliu.apk.parser.ApkFile\nimport java.io.File\nimport java.io.IOException\nimport java.util.zip.ZipEntry\nimport java.util.zip.ZipFile\n\nobject AppMetadataAnalyzer {\n\n    fun validateAppFile(file: File): AppMetadata? {\n        getWebMetadata(file)?.let {\n            return it\n        }\n        getIosAppMetadata(file)?.let {\n            require(it.platformName == \"iphonesimulator\") {\n                \"App build target '${it.platformName}' not supported, set build target to 'iphonesimulator'\"\n            }\n            return it\n        }\n        getAndroidAppMetadata(file)?.let {\n            require(it.supportedArchitectures.isEmpty() || \"arm64-v8a\" in it.supportedArchitectures) {\n                \"APK does not support arm64-v8a architecture. Found: ${it.supportedArchitectures}\"\n            }\n            return it\n        }\n        return null\n    }\n\n    private val watchInfoBundleRegex = Regex(\".*/Watch/.+\\\\.app/Info\\\\.plist$\", RegexOption.IGNORE_CASE)\n\n    fun getIosAppMetadata(appFile: File): IosAppMetadata? {\n        try {\n            val zipFile = ZipFile(appFile)\n            val entries = zipFile.entries()\n            var bestEntry: ZipEntry? = null\n            var bestDepth = Int.MAX_VALUE\n            while (entries.hasMoreElements()) {\n                val entry = entries.nextElement()\n                val isWatch = watchInfoBundleRegex.matches(entry.name)\n                if (!isWatch && entry.name.lowercase().endsWith(\".app/info.plist\")) {\n                    val depth = entry.name.split(\"/\").size\n                    if (depth < bestDepth) { bestEntry = entry; bestDepth = depth }\n                }\n            }\n            if (bestEntry != null) {\n                zipFile.getInputStream(bestEntry).use { input ->\n                    val root = PropertyListParser.parse(input) as NSDictionary\n                    val name = root.objectForKey(\"CFBundleName\")?.toString()\n                        ?: root.objectForKey(\"CFBundleDisplayName\")?.toString()\n                        ?: throw NullPointerException(\"Unable to find app name\")\n                    return IosAppMetadata(\n                        name = name,\n                        bundleId = root.objectForKey(\"CFBundleIdentifier\")?.toString()\n                            ?: throw NullPointerException(\"Unable to find bundleId\"),\n                        platformName = root.objectForKey(\"DTPlatformName\")?.toString()\n                            ?: throw NullPointerException(\"Unable to find DTPlatformName\"),\n                        minimumOSVersion = root.objectForKey(\"MinimumOSVersion\")?.toString()\n                            ?: throw NullPointerException(\"Unable to find MinimumOSVersion\"),\n                        appVersion = root.objectForKey(\"CFBundleShortVersionString\")?.toString() ?: \"\",\n                        bundleVersion = root.objectForKey(\"CFBundleVersion\")?.toString() ?: \"\",\n                    )\n                }\n            }\n        } catch (_: IOException) {\n            return null\n        }\n        return null\n    }\n\n    fun getAndroidAppMetadata(appFile: File): AndroidAppMetadata? {\n        try {\n            ApkFile(appFile).use { apk ->\n                val meta = apk.apkMeta\n                val archs = try {\n                    ZipFile(appFile).use { zip ->\n                        zip.entries().asSequence()\n                            .map { it.name }\n                            .filter { it.startsWith(\"lib/\") && it.count { c -> c == '/' } == 2 }\n                            .map { it.substringAfter(\"lib/\").substringBefore(\"/\") }\n                            .distinct().toList()\n                    }\n                } catch (_: Exception) { emptyList() }\n                return AndroidAppMetadata(\n                    name = meta.name ?: \"\",\n                    packageId = meta.packageName,\n                    supportedArchitectures = archs,\n                    versionName = meta.versionName ?: \"\",\n                    versionCode = meta.versionCode ?: 0L,\n                )\n            }\n        } catch (_: IOException) {\n            return null\n        }\n    }\n\n    fun getWebMetadata(appFile: File): WebAppMetadata? {\n        return try {\n            jacksonObjectMapper().readValue<WebAppMetadata>(appFile)\n        } catch (_: IOException) { null }\n    }\n}\n\nsealed class AppMetadata(\n    open val name: String,\n    val appIdentifier: String,\n    val platform: Platform,\n    val internalVersion: String,\n    val version: String,\n)\n\ndata class IosAppMetadata(\n    override val name: String,\n    val bundleId: String,\n    val platformName: String,\n    val minimumOSVersion: String,\n    val appVersion: String,\n    val bundleVersion: String,\n) : AppMetadata(\n    name = name,\n    appIdentifier = bundleId,\n    platform = Platform.IOS,\n    internalVersion = bundleVersion,\n    version = appVersion,\n)\n\ndata class AndroidAppMetadata(\n    override val name: String,\n    val packageId: String,\n    val supportedArchitectures: List<String>,\n    val versionName: String,\n    val versionCode: Long,\n) : AppMetadata(\n    name = name,\n    appIdentifier = packageId,\n    platform = Platform.ANDROID,\n    internalVersion = versionCode.toString(),\n    version = versionName,\n)\n\ndata class WebAppMetadata(\n    val url: String,\n) : AppMetadata(\n    name = url,\n    appIdentifier = url,\n    platform = Platform.WEB,\n    internalVersion = \"\",\n    version = \"\",\n)\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/util/ElementCoordinateUtil.kt",
    "content": "package maestro.orchestra.util\n\nimport maestro.MaestroException\nimport maestro.Point\nimport maestro.UiElement\n\n/**\n * Calculates the absolute screen coordinates for a point relative to an element's bounds.\n * \n * @param element The UI element to calculate coordinates relative to\n * @param point The relative point as a string (e.g., \"50%, 90%\" or \"25, 40\")\n * @return The absolute screen coordinates as a Point\n * @throws MaestroException.InvalidCommand if the point is invalid\n */\ninternal fun calculateElementRelativePoint(element: UiElement, point: String): Point {\n    val bounds = element.bounds\n    \n    return if (point.contains(\"%\")) {\n        // Percentage-based coordinates within element bounds\n        val (percentX, percentY) = point\n            .replace(\"%\", \"\")\n            .split(\",\")\n            .map { it.trim().toInt() }\n\n        if (percentX !in 0..100 || percentY !in 0..100) {\n            throw MaestroException.InvalidCommand(\"Invalid element-relative point: $point. Percentages must be between 0 and 100.\")\n        }\n\n        val x = bounds.x + (bounds.width * percentX / 100)\n        val y = bounds.y + (bounds.height * percentY / 100)\n        Point(x, y)\n    } else {\n        // Absolute coordinates within element bounds\n        val (x, y) = point.split(\",\")\n            .map { it.trim().toInt() }\n\n        if (x < 0 || y < 0 || x >= bounds.width || y >= bounds.height) {\n            throw MaestroException.InvalidCommand(\"Invalid element-relative point: $point. Coordinates must be within element bounds (0,0) to (${bounds.width-1},${bounds.height-1}).\")\n        }\n\n        Point(bounds.x + x, bounds.y + y)\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/validation/AppValidationException.kt",
    "content": "package maestro.orchestra.validation\n\nsealed class AppValidationException(message: String) : RuntimeException(message) {\n    class MissingAppSource : AppValidationException(\"Missing required parameter for option '--app-file' or '--app-binary-id'\")\n    class UnrecognizedAppFile : AppValidationException(\"Could not determine platform. Provide a valid --app-file or --app-binary-id.\")\n    class AppBinaryNotFound(val appBinaryId: String) : AppValidationException(\"App binary '$appBinaryId' not found. Check your --app-binary-id.\")\n    class UnsupportedPlatform(val platform: String) : AppValidationException(\"Unsupported platform '$platform' returned by server. Please update your CLI.\")\n    class AppBinaryFetchError(val statusCode: Int?) : AppValidationException(\"Failed to fetch app binary info. Status code: $statusCode\")\n    class IncompatibleIOSVersion(val appMinVersion: String, val deviceOsVersion: Int) : AppValidationException(\"App requires iOS $appMinVersion but device is configured for iOS $deviceOsVersion. Set --device-os to a compatible version.\")\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/validation/AppValidator.kt",
    "content": "package maestro.orchestra.validation\n\nimport maestro.device.Platform\nimport java.io.File\nimport maestro.device.DeviceSpec\n\n/**\n * Validates and resolves app metadata from a local file, a remote binary ID, or a web manifest.\n *\n * Dependencies are injected as functions so this class stays free of CLI/API-specific types.\n *\n * @param appFileValidator validates a local app file and returns its metadata, or null if unrecognized\n * @param appBinaryInfoProvider fetches app binary info from a remote server by binary ID. Returns a Triple of (appBinaryId, platform, appId).\n * @param webManifestProvider provides a web manifest file for web flows\n * @param iosMinOSVersionProvider extracts the minimum OS version from an iOS app binary file\n */\nclass AppValidator(\n    private val appFileValidator: (File) -> AppMetadata?,\n    private val appBinaryInfoProvider: ((String) -> AppBinaryInfoResult)? = null,\n    private val webManifestProvider: (() -> File?)? = null,\n    private val iosMinOSVersionProvider: ((File) -> IosMinOSVersion?)? = null,\n) {\n\n    data class AppBinaryInfoResult(\n        val appBinaryId: String,\n        val platform: String,\n        val appId: String,\n    )\n\n    data class IosMinOSVersion(val major: Int, val full: String)\n\n    fun validate(appFile: File?, appBinaryId: String?): AppMetadata {\n        return when {\n            appFile != null -> validateLocalAppFile(appFile)\n            appBinaryId != null -> validateAppBinaryId(appBinaryId)\n            webManifestProvider != null -> validateWebManifest()\n            else -> throw AppValidationException.MissingAppSource()\n        }\n    }\n\n    private fun validateLocalAppFile(appFile: File): AppMetadata {\n        return appFileValidator(appFile)\n            ?: throw AppValidationException.UnrecognizedAppFile()\n    }\n\n    private fun validateAppBinaryId(appBinaryId: String): AppMetadata {\n        val provider = appBinaryInfoProvider\n            ?: throw AppValidationException.MissingAppSource()\n\n        val info = provider(appBinaryId)\n\n        val platform = try {\n            Platform.fromString(info.platform)\n        } catch (e: IllegalArgumentException) {\n            throw AppValidationException.UnsupportedPlatform(info.platform)\n        }\n\n        return AppBinaryResponse(\n            appBinaryId = appBinaryId,\n            appId = info.appId,\n            remotePlatform = platform,\n        )\n    }\n\n    private fun validateWebManifest(): AppMetadata {\n        val manifest = webManifestProvider?.invoke()\n        return manifest?.let { appFileValidator(it) }\n            ?: throw AppValidationException.UnrecognizedAppFile()\n    }\n\n    fun validateDeviceCompatibility(\n      appFile: File?,\n      deviceSpec: DeviceSpec,\n    ) {\n        when (deviceSpec.platform) {\n            Platform.IOS -> {\n                if (appFile == null) return\n                val minOSVersion = iosMinOSVersionProvider?.invoke(appFile) ?: return\n                if (minOSVersion.major > deviceSpec.osVersion) {\n                    throw AppValidationException.IncompatibleIOSVersion(\n                        appMinVersion = minOSVersion.full,\n                        deviceOsVersion = deviceSpec.osVersion,\n                    )\n                }\n            }\n            Platform.ANDROID -> return\n            Platform.WEB -> return\n        }\n    }\n}\n\ndata class AppBinaryResponse(\n    val appBinaryId: String,\n    val appId: String,\n    val remotePlatform: Platform,\n) : AppMetadata(\n    name = appId,\n    appIdentifier = appId,\n    platform = remotePlatform,\n    internalVersion = \"\",\n    version = \"\",\n)\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/validation/WorkspaceValidationException.kt",
    "content": "package maestro.orchestra.validation\n\nimport maestro.orchestra.workspace.WorkspaceValidationError\n\nclass WorkspaceValidationException(message: String, val error: WorkspaceValidationError) : RuntimeException(message)\n\nfun WorkspaceValidationError.toException(): WorkspaceValidationException = WorkspaceValidationException(\n    message = when (this) {\n        is WorkspaceValidationError.NoFlowsMatchingAppId ->\n            \"No flows in workspace match app ID '${appId}'. Found app IDs: ${foundIds.ifEmpty { setOf(\"none\") }.joinToString()}\"\n        is WorkspaceValidationError.NameConflict ->\n            \"Duplicate flow name '${name}' in workspace. Each flow must have a unique name.\"\n        is WorkspaceValidationError.SyntaxError ->\n            \"Workspace syntax error: ${detail}\"\n        is WorkspaceValidationError.InvalidFlowFile ->\n            detail\n        WorkspaceValidationError.EmptyWorkspace ->\n            \"Workspace contains no flows.\"\n        is WorkspaceValidationError.MissingLaunchApp ->\n            \"Flows ${flowNames.joinToString()} are missing a launchApp command. Each flow must start with a launchApp command.\"\n        WorkspaceValidationError.InvalidWorkspaceFile ->\n            \"Workspace is not a valid zip archive.\"\n        is WorkspaceValidationError.GenericError ->\n            detail\n    },\n    error = this,\n)\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/validation/WorkspaceValidator.kt",
    "content": "package maestro.orchestra.validation\n\nimport com.github.michaelbull.result.getOrElse\nimport maestro.orchestra.workspace.WorkspaceValidationResult\nimport java.io.File\nimport maestro.orchestra.workspace.WorkspaceValidator as OrchestraWorkspaceValidator\n\nclass WorkspaceValidator {\n\n    fun validate(\n        workspace: File,\n        appId: String,\n        env: Map<String, String>,\n        includeTags: List<String>,\n        excludeTags: List<String>,\n    ): WorkspaceValidationResult {\n        return OrchestraWorkspaceValidator.validate(\n            workspace = workspace,\n            appId = appId,\n            envParameters = env,\n            includeTags = includeTags,\n            excludeTags = excludeTags,\n        ).getOrElse { error -> throw error.toException() }\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/workspace/ExecutionOrderPlanner.kt",
    "content": "package maestro.orchestra.workspace\n\nimport java.nio.file.Path\n\nobject ExecutionOrderPlanner {\n\n    fun getFlowsToRunInSequence(\n        paths: Map<String, Path>,\n        flowOrder: List<String>,\n    ): List<Path> {\n        if (flowOrder.isEmpty()) return emptyList()\n\n        val orderSet = flowOrder.toSet()\n\n        val namesInOrder = paths.keys.filter { it in orderSet }\n        if (namesInOrder.isEmpty()) return emptyList()\n\n        val result = orderSet.takeWhile { it in namesInOrder }\n\n        return if (result.isEmpty()) {\n            error(\"Could not find flows needed for execution in order: ${(orderSet - namesInOrder.toSet()).joinToString()}\")\n        } else if (flowOrder.slice(result.indices) == result) {\n            result.map { paths[it]!! }\n        } else {\n            emptyList()\n        }\n    }\n\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/workspace/Filters.kt",
    "content": "package maestro.orchestra.workspace\n\nimport java.nio.file.Path\nimport kotlin.io.path.absolutePathString\nimport kotlin.io.path.extension\nimport kotlin.io.path.isRegularFile\nimport kotlin.io.path.nameWithoutExtension\n\nfun isFlowFile(path: Path, config: Path?): Boolean {\n    if (!path.isRegularFile()) return false // Not a file\n    if (path.absolutePathString() == config?.absolutePathString()) return false // Config file\n    val extension = path.extension\n    if (extension != \"yaml\" && extension != \"yml\") return false // Not YAML\n    if (path.nameWithoutExtension == \"config\") return false // Config file\n    return true\n}"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/workspace/WorkspaceExecutionPlanner.kt",
    "content": "package maestro.orchestra.workspace\n\nimport maestro.orchestra.MaestroCommand\nimport maestro.orchestra.WorkspaceConfig\nimport maestro.orchestra.error.ValidationError\nimport maestro.orchestra.workspace.ExecutionOrderPlanner.getFlowsToRunInSequence\nimport maestro.orchestra.yaml.YamlCommandReader\nimport org.slf4j.LoggerFactory\nimport java.nio.file.Files\nimport java.nio.file.Path\nimport kotlin.io.path.*\nimport kotlin.streams.toList\nimport maestro.utils.isRegularFile\n\nobject WorkspaceExecutionPlanner {\n\n    private val logger = LoggerFactory.getLogger(WorkspaceExecutionPlanner::class.java)\n\n    fun plan(\n        input: Set<Path>,\n        includeTags: List<String>,\n        excludeTags: List<String>,\n        config: Path?,\n    ): ExecutionPlan {\n        if (input.any { it.notExists() }) {\n            throw ValidationError(\"\"\"\n                Flow path does not exist: ${input.find { it.notExists() }?.absolutePathString()}\n            \"\"\".trimIndent())\n        }\n\n        if (input.isRegularFile) {\n            validateFlowFile(input.first())\n            val workspaceConfig = if (config != null) {\n                YamlCommandReader.readWorkspaceConfig(config.absolute())\n            } else {\n                WorkspaceConfig()\n            }\n            return ExecutionPlan(\n                flowsToRun = input.toList(),\n                sequence = FlowSequence(emptyList()),\n                workspaceConfig = workspaceConfig\n            )\n        }\n\n        // retrieve all Flow files\n\n        val (files, directories) = input.partition { it.isRegularFile() }\n\n        val flowFiles = files.filter { isFlowFile(it, config) }\n        val flowFilesInDirs: List<Path> = directories.flatMap { dir -> Files\n            .walk(dir)\n            .filter { isFlowFile(it, config) }\n            .toList()\n        }\n        if (flowFilesInDirs.isEmpty() && flowFiles.isEmpty()) {\n            throw ValidationError(\"\"\"\n                Flow directories do not contain any Flow files: ${directories.joinToString(\", \") { it.absolutePathString() }}\n            \"\"\".trimIndent())\n        }\n\n        // Filter flows based on flows config\n\n        val workspaceConfig =\n            if (config != null) YamlCommandReader.readWorkspaceConfig(config.absolute())\n            else directories.firstNotNullOfOrNull { findConfigFile(it) }\n                ?.let { YamlCommandReader.readWorkspaceConfig(it) }\n                ?: WorkspaceConfig()\n\n        val globs = workspaceConfig.flows ?: listOf(\"*\")\n\n        val matchers = globs.flatMap { glob ->\n            directories.map { it.fileSystem.getPathMatcher(escapeSlashesForWindows(\"glob:${it.pathString}${it.fileSystem.separator}$glob\")) }\n        }\n\n        val unsortedFlowFiles = flowFiles + flowFilesInDirs.filter { path ->\n            matchers.any { matcher -> matcher.matches(path) }\n        }.toList()\n\n        if (unsortedFlowFiles.isEmpty()) {\n            if (\"*\" == globs.singleOrNull()) {\n                val message = \"\"\"\n                    Top-level directories do not contain any Flows: ${directories.joinToString(\", \") { it.absolutePathString() }}\n                    To configure Maestro to run Flows in subdirectories, check out the following resources:\n                      * https://maestro.mobile.dev/cli/test-suites-and-reports#inclusion-patterns\n                      * https://blog.mobile.dev/maestro-best-practices-structuring-your-test-suite-54ec390c5c82\n                \"\"\".trimIndent()\n                throw ValidationError(message)\n            } else {\n                val message = \"\"\"\n                    |Flow inclusion pattern(s) did not match any Flow files:\n                    |${toYamlListString(globs)}\n                    \"\"\".trimMargin()\n                throw ValidationError(message)\n            }\n        }\n\n        // Filter flows based on tags\n\n        val configPerFlowFile = unsortedFlowFiles.associateWith {\n            val commands = validateFlowFile(it)\n            YamlCommandReader.getConfig(commands)\n        }\n\n        val allIncludeTags = includeTags + (workspaceConfig.includeTags?.toList() ?: emptyList())\n        val allExcludeTags = excludeTags + (workspaceConfig.excludeTags?.toList() ?: emptyList())\n        val allFlows = unsortedFlowFiles.filter {\n            val config = configPerFlowFile[it]\n            val tags = config?.tags ?: emptyList()\n\n            (allIncludeTags.isEmpty() || tags.any(allIncludeTags::contains))\n                && (allExcludeTags.isEmpty() || !tags.any(allExcludeTags::contains))\n        }\n\n        if (allFlows.isEmpty()) {\n            val message = \"\"\"\n                |Include / Exclude tags did not match any Flows:\n                |\n                |Include Tags:\n                |${toYamlListString(allIncludeTags)}\n                |\n                |Exclude Tags:\n                |${toYamlListString(allExcludeTags)}\n                \"\"\".trimMargin()\n            throw ValidationError(message)\n        }\n\n        // Handle sequential execution\n\n        val pathsByName = allFlows.associateBy {\n            val config = configPerFlowFile[it]\n            (config?.name ?: parseFileName(it))\n        }\n        val flowsToRunInSequence = workspaceConfig.executionOrder?.flowsOrder?.let {\n            getFlowsToRunInSequence(pathsByName, it)\n        } ?: emptyList()\n        var normalFlows = allFlows - flowsToRunInSequence.toSet()\n\n        // validation of media files for add media command\n        allFlows.forEach {\n            val commands = YamlCommandReader\n                .readCommands(it)\n                .mapNotNull { maestroCommand -> maestroCommand.addMediaCommand }\n            val mediaPaths = commands.flatMap { addMediaCommand -> addMediaCommand.mediaPaths }\n            YamlCommandsPathValidator.validatePathsExistInWorkspace(input, it, mediaPaths)\n        }\n\n        val executionPlan = ExecutionPlan(\n            flowsToRun = normalFlows,\n            sequence = FlowSequence(\n                flowsToRunInSequence,\n                workspaceConfig.executionOrder?.continueOnFailure\n            ),\n            workspaceConfig = workspaceConfig,\n        )\n\n        logger.info(\"Created execution plan: $executionPlan\")\n\n        return executionPlan\n    }\n\n    private fun validateFlowFile(topLevelFlowPath: Path): List<MaestroCommand> {\n        return YamlCommandReader.readCommands(topLevelFlowPath)\n    }\n\n    private fun findConfigFile(input: Path): Path? {\n        return input.resolve(\"config.yaml\")\n            .takeIf { it.exists() }\n            ?: input.resolve(\"config.yml\")\n                .takeIf { it.exists() }\n    }\n\n    private fun toYamlListString(strings: List<String>): String {\n        return strings.joinToString(\"\\n\") { \"- $it\" }\n    }\n\n    private fun parseFileName(file: Path): String {\n        return file.fileName.toString().substringBeforeLast(\".\")\n    }\n\n    private fun escapeSlashesForWindows(pathString: String): String {\n        return pathString.replace(\"\\\\\",\"\\\\\\\\\")\n    }\n\n    data class FlowSequence(\n        val flows: List<Path>,\n        val continueOnFailure: Boolean? = true,\n    )\n\n    data class ExecutionPlan(\n        val flowsToRun: List<Path>,\n        val sequence: FlowSequence,\n        val workspaceConfig: WorkspaceConfig = WorkspaceConfig(),\n    )\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/workspace/WorkspaceValidator.kt",
    "content": "package maestro.orchestra.workspace\n\nimport com.github.michaelbull.result.Err\nimport com.github.michaelbull.result.Ok\nimport com.github.michaelbull.result.Result\nimport maestro.orchestra.CompositeCommand\nimport maestro.orchestra.MaestroCommand\nimport maestro.orchestra.WorkspaceConfig\nimport maestro.js.GraalJsEngine\nimport maestro.js.RhinoJsEngine\nimport maestro.orchestra.error.InvalidFlowFile\nimport maestro.orchestra.error.SyntaxError as OrchestraSyntaxError\nimport maestro.orchestra.error.ValidationError\nimport maestro.orchestra.util.Env.withEnv\nimport maestro.orchestra.yaml.YamlCommandReader\nimport java.io.File\nimport java.nio.file.FileSystems\nimport java.util.zip.ZipError\nimport java.util.zip.ZipException\nimport kotlin.io.path.exists\nimport kotlin.io.path.name\n\ndata class ValidatedFlow(\n    val filePath: String,\n    val name: String,\n    val commands: List<MaestroCommand>,\n    val appId: String?,\n)\n\ndata class WorkspaceValidationResult(\n    val workspaceConfig: WorkspaceConfig,\n    val flows: List<ValidatedFlow>,\n)\n\nsealed class WorkspaceValidationError(message: String) : RuntimeException(message) {\n    object InvalidWorkspaceFile : WorkspaceValidationError(\"Workspace must be a zip archive\")\n    object EmptyWorkspace : WorkspaceValidationError(\"Workspace has no flows\")\n    data class NoFlowsMatchingAppId(val appId: String, val foundIds: Set<String>) :\n        WorkspaceValidationError(\"No flows match appId=$appId; found: $foundIds\")\n    data class NameConflict(val name: String) : WorkspaceValidationError(\"Duplicate flow name: $name\")\n    data class SyntaxError(val detail: String) : WorkspaceValidationError(\"Syntax error: $detail\")\n    data class InvalidFlowFile(val detail: String) : WorkspaceValidationError(detail)\n    data class MissingLaunchApp(val flowNames: List<String>) :\n        WorkspaceValidationError(\"Flows missing launchApp: ${flowNames.joinToString(\", \")}\")\n    data class GenericError(val detail: String) : WorkspaceValidationError(detail)\n}\n\nobject WorkspaceValidator {\n\n    fun validate(\n        workspace: File,\n        appId: String,\n        envParameters: Map<String, String>,\n        includeTags: List<String>,\n        excludeTags: List<String>,\n    ): Result<WorkspaceValidationResult, WorkspaceValidationError> {\n        return try {\n            val allFlows = mutableListOf<ValidatedFlow>()\n\n            val workspaceConfig = FileSystems.newFileSystem(workspace.toPath()).use { fs ->\n                val configPath = fs.getPath(\"/config.yaml\").takeIf { it.exists() }\n                    ?: fs.getPath(\"/config.yml\").takeIf { it.exists() }\n                WorkspaceExecutionPlanner.plan(\n                    input = setOf(fs.getPath(\".\")),\n                    includeTags = includeTags,\n                    excludeTags = excludeTags,\n                    config = configPath,\n                ).workspaceConfig\n            }\n\n            FileSystems.newFileSystem(workspace.toPath()).use { fs ->\n                val configPath = fs.getPath(\"/config.yaml\").takeIf { it.exists() }\n                    ?: fs.getPath(\"/config.yml\").takeIf { it.exists() }\n                val plan = WorkspaceExecutionPlanner.plan(\n                    input = setOf(fs.getPath(\".\")),\n                    includeTags = includeTags,\n                    excludeTags = excludeTags,\n                    config = configPath,\n                )\n                (plan.flowsToRun + plan.sequence.flows).forEach { path ->\n                    val commands = try {\n                        YamlCommandReader.readCommands(path).withEnv(envParameters)\n                    } catch (e: InvalidFlowFile) {\n                        throw OrchestraSyntaxError(\"Invalid flow file: ${e.message}\")\n                    }\n                    val applyConfigurationCommand = commands\n                        .find { it.applyConfigurationCommand != null }\n                        ?.applyConfigurationCommand\n                    val isRhinoExplicitlyRequested = applyConfigurationCommand?.config?.ext?.get(\"jsEngine\") == \"rhino\"\n                    val jsEngine = if (isRhinoExplicitlyRequested) {\n                        RhinoJsEngine()\n                    } else {\n                        GraalJsEngine()\n                    }.also { engine ->\n                        envParameters.forEach { (key, value) -> engine.putEnv(key, value) }\n                    }\n                    val config = applyConfigurationCommand\n                        ?.evaluateScripts(jsEngine)\n                        ?.config\n                    val flowName = config?.name ?: path.name.removeSuffix(\".yaml\")\n                    allFlows.add(ValidatedFlow(path.toString(), flowName, commands, config?.appId))\n                }\n            }\n\n            if (allFlows.isEmpty()) return Err(WorkspaceValidationError.EmptyWorkspace)\n\n            val matching = allFlows.filter { it.appId == appId }\n            if (matching.isEmpty()) {\n                val found = allFlows.mapNotNull { it.appId }.toSet()\n                return Err(WorkspaceValidationError.NoFlowsMatchingAppId(appId, found))\n            }\n\n            // Validate that all flows contain at least one launchApp command\n            // (including inside onFlowStart hooks and composite commands like runFlow/retry)\n            // or are referenced as a subflow by another flow\n            val flowsMissingLaunchApp = matching.filter { flow ->\n                flatten(flow.commands).none { it.launchAppCommand != null } &&\n                        matching.none { other ->\n                            other.commands.any { command ->\n                                command.runFlowCommand?.sourceDescription\n                                    ?.contains(flow.filePath.split(\"/\").last()) ?: false\n                            }\n                        }\n            }.map { it.name }\n            if (flowsMissingLaunchApp.isNotEmpty()) {\n                return Err(WorkspaceValidationError.MissingLaunchApp(flowsMissingLaunchApp))\n            }\n\n            matching.groupBy { it.name }.entries.find { (_, v) -> v.size > 1 }?.let { (name, _) ->\n                return Err(WorkspaceValidationError.NameConflict(name))\n            }\n\n            Ok(WorkspaceValidationResult(workspaceConfig, matching))\n        } catch (_: ZipException) {\n            Err(WorkspaceValidationError.InvalidWorkspaceFile)\n        } catch (_: ZipError) {\n            Err(WorkspaceValidationError.InvalidWorkspaceFile)\n        } catch (e: OrchestraSyntaxError) {\n            Err(WorkspaceValidationError.SyntaxError(e.message ?: \"\"))\n        } catch (e: maestro.orchestra.error.InvalidFlowFile) {\n            Err(WorkspaceValidationError.InvalidFlowFile(e.message ?: \"\"))\n        } catch (e: ValidationError) {\n            // WorkspaceExecutionPlanner throws ValidationError when the workspace has no flow files\n            if (e.message?.contains(\"do not contain any Flow files\") == true) {\n                Err(WorkspaceValidationError.EmptyWorkspace)\n            } else {\n                Err(WorkspaceValidationError.GenericError(e.message ?: \"\"))\n            }\n        }\n    }\n\n    private fun flatten(commands: List<MaestroCommand>): List<MaestroCommand> {\n        val result = mutableListOf<MaestroCommand>()\n\n        val flowStartCommands = YamlCommandReader.getConfig(commands)?.onFlowStart?.commands\n            ?: emptyList()\n\n        (commands + flowStartCommands).forEach {\n            val command = it.asCommand()\n            if (command is CompositeCommand) {\n                result.addAll(flatten(command.subCommands()))\n            } else {\n                result.add(it)\n            }\n        }\n\n        return result\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/workspace/YamlCommandsPathValidator.kt",
    "content": "package maestro.orchestra.workspace\n\nimport maestro.orchestra.error.ValidationError\nimport java.nio.file.Files\nimport java.nio.file.Path\nimport java.nio.file.Paths\n\nobject YamlCommandsPathValidator {\n\n    fun validatePathsExistInWorkspace(input: Set<Path>, flowFile: Path, pathStrings: List<String>) {\n        pathStrings.forEach {\n            val exists = validateInsideWorkspace(input, it)\n            if (!exists) {\n                throw ValidationError(\"The File \\\"${Paths.get(it).fileName}\\\" referenced in flow file: $flowFile not found in workspace\")\n            }\n        }\n    }\n\n    private fun validateInsideWorkspace(workspace: Set<Path>, pathString: String): Boolean {\n        val mediaPath = workspace.firstNotNullOfOrNull { it.resolve(it.fileSystem.getPath(pathString)) }\n        return workspace.any { Files.walk(it).anyMatch { path -> path.fileName == mediaPath?.fileName } }\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/MaestroFlowParser.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.core.JsonLocation\nimport com.fasterxml.jackson.core.JsonParser\nimport com.fasterxml.jackson.core.JsonProcessingException\nimport com.fasterxml.jackson.core.JsonToken\nimport com.fasterxml.jackson.core.TreeNode\nimport com.fasterxml.jackson.databind.DeserializationContext\nimport com.fasterxml.jackson.databind.JsonDeserializer\nimport com.fasterxml.jackson.databind.ObjectMapper\nimport com.fasterxml.jackson.databind.exc.MismatchedInputException\nimport com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException\nimport com.fasterxml.jackson.databind.module.SimpleModule\nimport com.fasterxml.jackson.dataformat.yaml.YAMLFactory\nimport com.fasterxml.jackson.dataformat.yaml.YAMLGenerator\nimport com.fasterxml.jackson.module.kotlin.KotlinModule\nimport com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException\nimport maestro.orchestra.MaestroCommand\nimport maestro.orchestra.WorkspaceConfig\nimport maestro.orchestra.error.InvalidFlowFile\nimport maestro.orchestra.error.MediaFileNotFound\nimport maestro.orchestra.util.Env.withEnv\nimport org.intellij.lang.annotations.Language\nimport java.nio.file.Path\nimport java.nio.file.Paths\nimport kotlin.io.path.absolute\nimport kotlin.io.path.isDirectory\nimport kotlin.io.path.readText\nimport kotlin.reflect.full.primaryConstructor\nimport kotlin.reflect.jvm.javaType\n\n// TODO:\n//  - Sanity check: Parsed workspace equality check for 10 users on cloud\n\nprivate val yamlFluentCommandConstructor = YamlFluentCommand::class.primaryConstructor!!\nprivate val yamlFluentCommandParameters = yamlFluentCommandConstructor.parameters\nprivate val yamlFluentCommandLocationParameter = yamlFluentCommandParameters.first { it.name == \"_location\" }\nprivate val objectCommands = yamlFluentCommandConstructor.parameters.map { it.name!! }\n\nprivate val stringCommands = mapOf<String, (JsonLocation) -> YamlFluentCommand>(\n    \"launchApp\" to { location -> YamlFluentCommand(\n        _location =  location,\n        launchApp = YamlLaunchApp(\n            appId = null,\n            clearState = null,\n            clearKeychain = null,\n            stopApp = null,\n            permissions = null,\n            arguments = null,\n        ),\n    )},\n    \"stopApp\" to { location -> YamlFluentCommand(\n        _location = location,\n        stopApp = YamlStopApp()\n    )},\n    \"killApp\" to { location -> YamlFluentCommand(\n        _location = location,\n        killApp = YamlKillApp()\n    )},\n    \"clearState\" to { location -> YamlFluentCommand(\n        _location = location,\n        clearState = YamlClearState(\n            appId = null,\n        )\n    )},\n    \"clearKeychain\" to { location -> YamlFluentCommand(\n        _location = location,\n        clearKeychain = YamlActionClearKeychain(),\n    )},\n    \"eraseText\" to { location -> YamlFluentCommand(\n        _location = location,\n        eraseText = YamlEraseText(charactersToErase = null)\n    )},\n    \"inputRandomText\" to { location -> YamlFluentCommand(\n        _location = location,\n        inputRandomText = YamlInputRandomText(length = 8),\n    )},\n    \"inputRandomNumber\" to { location -> YamlFluentCommand(\n        _location = location,\n        inputRandomNumber = YamlInputRandomNumber(length = 8),\n    )},\n    \"inputRandomEmail\" to { location -> YamlFluentCommand(\n        _location = location,\n        inputRandomEmail = YamlInputRandomEmail(),\n    )},\n    \"inputRandomPersonName\" to { location -> YamlFluentCommand(\n        _location = location,\n        inputRandomPersonName = YamlInputRandomPersonName(),\n    )},\n    \"back\" to { location -> YamlFluentCommand(\n        _location = location,\n        back = YamlActionBack(),\n    )},\n    \"hideKeyboard\" to { location -> YamlFluentCommand(\n        _location = location,\n        hideKeyboard = YamlActionHideKeyboard(),\n    )},\n    \"hide keyboard\" to { location -> YamlFluentCommand(\n        _location = location,\n        hideKeyboard = YamlActionHideKeyboard(),\n    )},\n    \"pasteText\" to { location -> YamlFluentCommand(\n        _location = location,\n        pasteText = YamlActionPasteText(),\n    )},\n    \"scroll\" to { location -> YamlFluentCommand(\n        _location = location,\n        scroll = YamlActionScroll(),\n    )},\n    \"waitForAnimationToEnd\" to { location -> YamlFluentCommand(\n        _location = location,\n        waitForAnimationToEnd = YamlWaitForAnimationToEndCommand(timeout = null)\n    )},\n    \"stopRecording\" to { location -> YamlFluentCommand(\n        _location = location,\n        stopRecording = YamlStopRecording()\n    )},\n    \"toggleAirplaneMode\" to { location -> YamlFluentCommand(\n        _location = location,\n        toggleAirplaneMode = YamlToggleAirplaneMode()\n    )},\n    \"assertNoDefectsWithAI\" to { location -> YamlFluentCommand(\n        _location = location,\n        assertNoDefectsWithAI = YamlAssertNoDefectsWithAI()\n    )},\n)\n\nprivate val allCommands = (stringCommands.keys + objectCommands).distinct()\n\nprivate const val DOCS_FIRST_FLOW = \"https://docs.maestro.dev/getting-started/writing-your-first-flow\"\nprivate const val DOCS_COMMANDS = \"https://docs.maestro.dev/api-reference/commands\"\n\nprivate class ParseException(\n    val location: JsonLocation,\n    val title: String,\n    @Language(\"markdown\") val errorMessage: String,\n    val docs: String? = null,\n) : RuntimeException(\"$title: $errorMessage\")\n\nprivate inline fun <reified T : Throwable> findException(e: Throwable): T? {\n    return findException(e, T::class.java)\n}\n\nprivate fun <T : Throwable> findException(e: Throwable, type: Class<T>): T? {\n    return if (type.isInstance(e)) type.cast(e) else e.cause?.let { findException(it, type) }\n}\n\nprivate fun wrapException(error: Throwable, parser: JsonParser, contentPath: Path, content: String): Exception {\n    findException<FlowParseException>(error)?.let { return it }\n    findException<ToCommandsException>(error)?.let { e ->\n        return when (e.cause) {\n            is InvalidFlowFile -> FlowParseException(\n                location = e.location,\n                contentPath = contentPath,\n                content = content,\n                title = \"Invalid File Path\",\n                errorMessage = e.cause.message,\n            )\n            is MediaFileNotFound -> FlowParseException(\n                location = e.location,\n                contentPath = contentPath,\n                content = content,\n                title = \"Media File Not Found\",\n                errorMessage = e.cause.message,\n            )\n            else -> FlowParseException(\n                location = e.location,\n                contentPath = contentPath,\n                content = content,\n                title = \"Parsing Failed\",\n                errorMessage = e.message ?: \"Failed to parse content\",\n            )\n        }\n    }\n    findException<ParseException>(error)?.let { e ->\n        return FlowParseException(\n            location = e.location,\n            contentPath = contentPath,\n            content = content,\n            title = e.title,\n            errorMessage = e.errorMessage,\n            docs = e.docs\n        )\n    }\n    findException<ConfigParseError>(error)?.let { e ->\n        return when (e.errorType) {\n            \"missing_app_target\" -> FlowParseException(\n                location = e.location ?: parser.currentLocation(),\n                contentPath = contentPath,\n                content = content,\n                title = \"Config Field Required\",\n                errorMessage = \"\"\"\n                    |Either 'url' or 'appId' must be specified in the config section.\n                    |\n                    |For mobile apps, use:\n                    |```yaml\n                    |appId: com.example.app\n                    |---\n                    |- launchApp\n                    |```\n                    |\n                    |For web apps, use:\n                    |```yaml\n                    |url: https://example.com\n                    |---\n                    |- launchApp\n                    |```\n                \"\"\".trimMargin(\"|\"),\n                docs = DOCS_FIRST_FLOW,\n            )\n            else -> FlowParseException(\n                location = e.location ?: parser.currentLocation(),\n                contentPath = contentPath,\n                content = content,\n                title = \"Config Parse Error\",\n                errorMessage = \"Unknown config validation error: ${e.errorType}\",\n            )\n        }\n    }\n    findException<MissingKotlinParameterException>(error)?.let { e ->\n        return FlowParseException(\n            location = e.location ?: parser.currentLocation(),\n            contentPath = contentPath,\n            content = content,\n            title = \"Config Field Required: ${e.parameter.name}\",\n            errorMessage = \"\"\"\n                |The config section is missing a required field: `${e.parameter.name}`. Eg.\n                |\n                |```yaml\n                |appId: com.example.app\n                |---\n                |- launchApp\n                |```\n            \"\"\".trimMargin(\"|\"),\n            docs = DOCS_FIRST_FLOW,\n        )\n    }\n    findException<UnrecognizedPropertyException>(error)?.let { e ->\n        val propertyName = e.path.lastOrNull()?.fieldName ?: \"<unknown>\"\n        return FlowParseException(\n            location = e.location ?: parser.currentLocation(),\n            contentPath = contentPath,\n            content = content,\n            title = \"Unknown Property: $propertyName\",\n            errorMessage = \"\"\"\n                |The property `$propertyName` is not recognized\n            \"\"\".trimMargin(\"|\"),\n        )\n    }\n    findException<MismatchedInputException>(error)?.let { e ->\n        val path = e.path.joinToString(\".\") { it.fieldName }\n        return FlowParseException(\n            location = e.location ?: parser.currentLocation(),\n            contentPath = contentPath,\n            content = content,\n            title = \"Incorrect Format: ${e.path.last().fieldName}\",\n            errorMessage = \"\"\"\n                |The format for $path is incorrect\n            \"\"\".trimMargin(\"|\"),\n        )\n    }\n    return FlowParseException(\n        parser = parser,\n        contentPath = contentPath,\n        content = content,\n        title = \"Parsing Failed\",\n        errorMessage = error.message ?: \"Failed to parse content\",\n    )\n}\n\nprivate fun String.levenshtein(other: String): Int {\n    val dp = Array(length + 1) { IntArray(other.length + 1) }\n    for (i in 0..length) dp[i][0] = i\n    for (j in 0..other.length) dp[0][j] = j\n\n    for (i in 1..length)\n        for (j in 1..other.length)\n            dp[i][j] = if (this[i - 1] == other[j - 1]) dp[i - 1][j - 1]\n            else 1 + minOf(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])\n\n    return dp[length][other.length]\n}\n\nprivate fun String.findSimilar(others: Iterable<String>, threshold: Int) =\n    others.sortedBy { levenshtein(it) }.takeWhile { it.levenshtein(this) <= threshold }\n\nprivate object YamlCommandDeserializer : JsonDeserializer<YamlFluentCommand>() {\n\n    override fun deserialize(p: JsonParser, ctxt: DeserializationContext): YamlFluentCommand {\n        return when (p.currentToken) {\n            JsonToken.VALUE_STRING -> parseStringCommand(p)\n            JsonToken.START_OBJECT -> parseObjectCommand(p)\n            else -> throw ParseException(\n                location = p.currentLocation(),\n                title = \"Invalid Command\",\n                errorMessage = \"\"\"\n                    |Invalid command format. Expected: \"<commandName>: <options>\" eg. \"tapOn: submit\"\n                \"\"\".trimMargin(\"|\"),\n                docs = DOCS_COMMANDS,\n            )\n        }\n    }\n\n    private fun parseStringCommand(parser: JsonParser): YamlFluentCommand {\n        val commandLocation = parser.currentLocation()\n        val commandText = parser.text\n        val command = stringCommands[commandText]\n        if (command != null) return command(parser.currentLocation())\n        if (commandText in objectCommands) {\n            throw ParseException(\n                location = commandLocation,\n                title = \"Missing Command Options\",\n                errorMessage = \"\"\"\n                    |The command `$commandText` requires additional options.\n                \"\"\".trimMargin(\"|\"),\n                // TODO: Add docs link\n            )\n        }\n        throw ParseException(\n            location = commandLocation,\n            title = \"Invalid Command: $commandText\",\n            errorMessage = \"\"\"\n                |`$commandText` is not a valid command.\n                |\n                |${suggestCommandMessage(commandText)}\n            \"\"\".trimMargin(\"|\").trim(),\n            docs = DOCS_COMMANDS,\n        )\n    }\n\n    private fun parseObjectCommand(parser: JsonParser): YamlFluentCommand {\n        val commandLocation = parser.currentLocation()\n        val commandName = parser.nextFieldName()\n        val commandParameter = yamlFluentCommandParameters.firstOrNull { it.name == commandName }\n        if (commandParameter == null) {\n            throw ParseException(\n                location = parser.currentLocation(),\n                title = \"Invalid Command: $commandName\",\n                errorMessage = \"\"\"\n                    |`$commandName` is not a valid command.\n                    |\n                    |${suggestCommandMessage(commandName)}\n                \"\"\".trimMargin(\"|\").trim(),\n            )\n        }\n        if (parser.nextToken() == JsonToken.VALUE_NULL) {\n            throw ParseException(\n                location = parser.currentLocation(),\n                title = \"Incorrect Command Format: $commandName\",\n                errorMessage = \"\"\"\n                    |The command `$commandName` requires additional options.\n                \"\"\".trimMargin(\"|\"),\n            )\n        }\n        val commandType = (parser.codec as ObjectMapper).constructType(commandParameter.type.javaType)\n        val command = parser.codec.readValue<Any>(parser, commandType)\n        val fluentCommand = yamlFluentCommandConstructor.callBy(mapOf(\n            yamlFluentCommandLocationParameter to commandLocation,\n            commandParameter to command,\n        ))\n\n        val nextToken = parser.nextToken()\n        if (nextToken == JsonToken.END_OBJECT) return fluentCommand\n\n        if (nextToken == JsonToken.FIELD_NAME) {\n            val fieldName = parser.currentName()\n            throw ParseException(\n                location = parser.currentLocation(),\n                title = \"Invalid Command Format: $commandName\",\n                errorMessage = \"\"\"\n                    |Found unexpected top-level field: `$fieldName`. Missing an indent or dash?\n                    |\n                    |Example of correctly formatted list of commands:\n                    |```yaml\n                    |- tapOn:\n                    |    text: submit\n                    |    optional: true\n                    |- inputText: hello\n                    |```\n                \"\"\".trimMargin(\"|\"),\n            )\n        }\n        throw ParseException(\n            location = commandLocation,\n            title = \"Invalid Command Format: $commandName\",\n            errorMessage = \"\"\"\n                |Commands must be in the format: `<commandName>: <options>` eg. `tapOn: submit`\n                |\n                |Example of correctly formatted list of commands:\n                |```yaml\n                |- tapOn:\n                |    text: submit\n                |    optional: true\n                |- inputText: hello\n                |```\n            \"\"\".trimMargin(\"|\"),\n        )\n    }\n\n    private fun suggestCommandMessage(invalidCommand: String): String {\n        val prefixCommands = if (invalidCommand.length < 3) emptyList() else allCommands.filter { it.startsWith(invalidCommand) || invalidCommand.startsWith(it) }\n        val substringCommands = if (invalidCommand.length < 3) emptyList() else allCommands.filter { it.contains(invalidCommand) || invalidCommand.contains(it) }\n        val similarCommands = invalidCommand.findSimilar(allCommands, threshold = 3)\n        val suggestions = (prefixCommands + similarCommands + substringCommands).distinct()\n        return when {\n            suggestions.isEmpty() -> \"\"\n            suggestions.size == 1 -> \"Did you mean `${suggestions.first()}`?\"\n            else -> \"Did you mean one of: ${suggestions.joinToString(\", \")}\"\n        }\n    }\n}\n\nclass FlowParseException(\n    location: JsonLocation,\n    val contentPath: Path,\n    val content: String,\n    val title: String,\n    @Language(\"markdown\") val errorMessage: String,\n    val docs: String? = null,\n) : JsonProcessingException(\"$title\\n$errorMessage\", location) {\n    constructor(parser: JsonParser, contentPath: Path, content: String, title: String, @Language(\"markdown\") errorMessage: String, docs: String? = null) : this(\n        location = parser.currentLocation(),\n        contentPath = contentPath,\n        content = content,\n        title = title,\n        errorMessage = errorMessage,\n        docs = docs,\n    )\n}\n\nobject MaestroFlowParser {\n\n    private val MAPPER = ObjectMapper(YAMLFactory().apply {\n        disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER)\n    }).apply {\n        registerModule(KotlinModule.Builder().build())\n        registerModule(SimpleModule().apply {\n            addDeserializer(YamlFluentCommand::class.java, YamlCommandDeserializer)\n        })\n    }\n\n    fun parseFlow(flowPath: Path, flow: String): List<MaestroCommand> {\n        MAPPER.createParser(flow).use { parser ->\n            try {\n                val config = parseConfig(parser)\n                val commands = parseCommands(parser)\n                val maestroCommands = commands\n                    .flatMap { it.toCommands(flowPath, config.appId) }\n                    .withEnv(config.env)\n                return listOfNotNull(config.toCommand(flowPath), *maestroCommands.toTypedArray())\n            } catch (e: Throwable) {\n                throw wrapException(e, parser, flowPath, flow)\n            }\n        }\n    }\n\n    fun parseCommand(flowPath: Path, appId: String, command: String): List<MaestroCommand> {\n        MAPPER.createParser(command).use { parser ->\n            try {\n                return parser.readValueAs(YamlFluentCommand::class.java).toCommands(flowPath, appId)\n            } catch (e: Throwable) {\n                throw wrapException(e, parser, flowPath, command)\n            }\n        }\n    }\n\n    fun parseConfigOnly(flowPath: Path, flow: String): YamlConfig {\n        MAPPER.createParser(flow).use { parser ->\n            try {\n                return parseConfig(parser)\n            } catch (e: Throwable) {\n                throw wrapException(e, parser, flowPath, flow)\n            }\n        }\n    }\n\n    fun parseWorkspaceConfig(configPath: Path, workspaceConfig: String): WorkspaceConfig {\n        MAPPER.createParser(workspaceConfig).use { parser ->\n            try {\n                return parser.readValueAs(WorkspaceConfig::class.java)\n            } catch (e: Throwable) {\n                throw wrapException(e, parser, configPath, workspaceConfig)\n            }\n        }\n    }\n\n    fun parseWatchFiles(flowPath: Path): List<Path> {\n        val flow = flowPath.readText()\n        MAPPER.createParser(flow).use { parser ->\n            try {\n                parseConfig(parser)\n                val commands = parseCommands(parser)\n                val commandWatchFiles = commands.flatMap { it.getWatchFiles(flowPath) }\n                return (listOf(flowPath) + commandWatchFiles)\n                    .filter { it.absolute().parent?.isDirectory() ?: false }\n            } catch (e: Throwable) {\n                throw wrapException(e, parser, flowPath, flow)\n            }\n        }\n    }\n\n    fun formatCommands(commands: List<String>): String {\n        return MAPPER.writeValueAsString(commands.map { MAPPER.readTree(it) })\n    }\n\n    fun checkSyntax(maestroCode: String) {\n        MAPPER.createParser(maestroCode).use { parser ->\n            val node = parser.readValueAsTree<TreeNode>()\n            if (node.isArray) {\n                checkCommandListSyntax(maestroCode)\n            } else if (node.isObject && parser.nextToken() != null) {\n                checkFlowSyntax(maestroCode)\n            } else {\n                checkCommandSyntax(maestroCode)\n            }\n        }\n    }\n\n    private fun checkCommandListSyntax(maestroCode: String) {\n        MAPPER.createParser(maestroCode).use { parser ->\n            try {\n                parseCommands(parser)\n            } catch (e: Throwable) {\n                throw wrapException(e, parser, Paths.get(\"/syntax-checker\"), maestroCode)\n            }\n        }\n    }\n\n    private fun checkCommandSyntax(command: String) {\n        MAPPER.createParser(command).use { parser ->\n            try {\n                parser.readValueAs(YamlFluentCommand::class.java)\n            } catch (e: Throwable) {\n                throw wrapException(e, parser, Paths.get(\"/syntax-checker\"), command)\n            }\n        }\n    }\n\n    private fun checkFlowSyntax(flow: String) {\n        MAPPER.createParser(flow).use { parser ->\n            try {\n                parseConfig(parser)\n                parseCommands(parser)\n            } catch (e: Throwable) {\n                throw wrapException(e, parser, Paths.get(\"/syntax-checker\"), flow)\n            }\n        }\n    }\n\n    private fun parseCommands(parser: JsonParser): List<YamlFluentCommand> {\n        if (parser.nextToken() != JsonToken.START_ARRAY) {\n            throw ParseException(\n                location = parser.currentLocation(),\n                title = \"Commands Section Required\",\n                errorMessage = \"\"\"\n                    |Flow files must have a list of commands after the config section. Eg:\n                    |\n                    |```yaml\n                    |appId: com.example.app\n                    |---\n                    |- launchApp\n                    |```\n                \"\"\".trimMargin(\"|\"),\n                docs = DOCS_FIRST_FLOW,\n            )\n        }\n\n        val commands = mutableListOf<YamlFluentCommand>()\n        while (parser.nextToken() != JsonToken.END_ARRAY) {\n            val command = parser.readValueAs(YamlFluentCommand::class.java)\n            commands.add(command)\n        }\n        return commands\n    }\n\n    private fun parseConfig(parser: JsonParser): YamlConfig {\n        if (parser.nextToken() != JsonToken.START_OBJECT) {\n            throw ParseException(\n                location = parser.currentLocation(),\n                title = \"Config Section Required\",\n                errorMessage = \"\"\"\n                    |Flow files must start with a config section. Eg:\n                    |\n                    |```yaml\n                    |appId: com.example.app # <-- config section\n                    |---\n                    |- launchApp\n                    |```\n                \"\"\".trimMargin(\"|\"),\n                docs = DOCS_FIRST_FLOW,\n            )\n        }\n\n        return parser.readValueAs(YamlConfig::class.java)\n    }\n}"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlAction.kt",
    "content": "package maestro.orchestra.yaml\n\ndata class YamlActionBack(\n    val label: String? = null,\n    val optional: Boolean = false,\n)\n\ndata class YamlActionClearKeychain(\n    val label: String? = null,\n    val optional: Boolean = false,\n)\n\ndata class YamlActionHideKeyboard(\n    val label: String? = null,\n    val optional: Boolean = false,\n)\n\ndata class YamlActionPasteText(\n    val label: String? = null,\n    val optional: Boolean = false,\n)\n\ndata class YamlActionScroll(\n    val label: String? = null,\n    val optional: Boolean = false,\n)\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlAddMedia.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\n\ndata class YamlAddMedia(\n    val files: List<String?>? = null,\n    val label: String? = null,\n    val optional: Boolean = false,\n) {\n    companion object {\n\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(files: List<String>) = YamlAddMedia(\n            files = files,\n        )\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlAssertNoDefectsWithAI.kt",
    "content": "package maestro.orchestra.yaml\n\ndata class YamlAssertNoDefectsWithAI(\n    val optional: Boolean = true,\n    val label: String? = null,\n)\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlAssertScreenshot.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\nimport maestro.orchestra.ElementSelector\n\nprivate const val DEFAULT_DIFF_THRESHOLD = 95.0\n\ndata class YamlAssertScreenshot(\n    val path: String,\n    val thresholdPercentage: Double = DEFAULT_DIFF_THRESHOLD,\n    val cropOn: YamlElementSelectorUnion? = null,\n    val label: String? = null,\n    val optional: Boolean = false,\n) {\n\n    companion object {\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(path: String): YamlAssertScreenshot {\n            return YamlAssertScreenshot(\n                path = path\n            )\n        }\n    }\n}"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlAssertTrue.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\n\ndata class YamlAssertTrue(\n    val condition: String? = null,\n    val label: String? = null,\n    val optional: Boolean = false,\n){\n    companion object {\n\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(condition: Any): YamlAssertTrue {\n            val evaluatedCondition = when (condition) {\n                is String -> condition\n                is Int, is Long, is Char, is Boolean, is Float, is Double -> condition.toString()\n                is Map<*, *> -> {\n                    val evaluatedCondition = condition.getOrDefault(\"condition\", \"\") as String\n                    val label = condition.getOrDefault(\"label\", null) as String?\n                    val optional = condition.getOrDefault(\"optional\", false) as Boolean\n                    return YamlAssertTrue(evaluatedCondition, label, optional)\n                }\n                else -> throw UnsupportedOperationException(\"Cannot deserialize assert true with data type ${condition.javaClass}\")\n            }\n            return YamlAssertTrue(\n                condition = evaluatedCondition,\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlAssertWithAI.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\n\ndata class YamlAssertWithAI(\n    val assertion: String,\n    val optional: Boolean = true,\n    val label: String? = null,\n) {\n\n    companion object {\n\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(assertion: String): YamlAssertWithAI {\n            return YamlAssertWithAI(\n                assertion = assertion,\n                optional = true,\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlClearState.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\n\ndata class YamlClearState(\n    val appId: String? = null,\n    val label: String? = null,\n    val optional: Boolean = false,\n) {\n    companion object {\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(appId: String) = YamlClearState(\n            appId = appId,\n        )\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlCommandReader.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.core.JsonLocation\nimport com.fasterxml.jackson.core.JsonProcessingException\nimport maestro.orchestra.ApplyConfigurationCommand\nimport maestro.orchestra.MaestroCommand\nimport maestro.orchestra.MaestroConfig\nimport maestro.orchestra.WorkspaceConfig\nimport maestro.orchestra.error.SyntaxError\nimport maestro.utils.drawTextBox\nimport java.nio.file.Path\nimport java.nio.file.Paths\nimport kotlin.io.path.absolutePathString\nimport kotlin.io.path.readText\n\nobject YamlCommandReader {\n\n    // If it exists, automatically resolves the initFlow file and inlines the commands into the config\n    fun readCommands(flowPath: Path): List<MaestroCommand> = mapParsingErrors(flowPath) {\n        val flow = flowPath.readText()\n        MaestroFlowParser.parseFlow(flowPath, flow)\n    }\n\n    fun readSingleCommand(flowPath: Path, appId: String, command: String): List<MaestroCommand> = mapParsingErrors(flowPath) {\n        MaestroFlowParser.parseCommand(flowPath, appId, command)\n    }\n\n    fun readConfig(flowPath: Path) = mapParsingErrors(flowPath) {\n        val flow = flowPath.readText()\n        MaestroFlowParser.parseConfigOnly(flowPath, flow)\n    }\n\n    fun readWorkspaceConfig(configPath: Path): WorkspaceConfig = mapParsingErrors(configPath) {\n        val config = configPath.readText()\n        if (config.isBlank()) return@mapParsingErrors WorkspaceConfig()\n        MaestroFlowParser.parseWorkspaceConfig(configPath, config)\n    }\n\n    // Files to watch for changes. Includes any referenced files.\n    fun getWatchFiles(flowPath: Path): List<Path> = mapParsingErrors(flowPath) {\n        MaestroFlowParser.parseWatchFiles(flowPath)\n    }\n\n    fun getConfig(commands: List<MaestroCommand>): MaestroConfig? {\n        val configurationCommand = commands\n            .map(MaestroCommand::asCommand)\n            .filterIsInstance<ApplyConfigurationCommand>()\n            .firstOrNull()\n\n        return configurationCommand?.config\n    }\n\n    fun formatCommands(commands: List<String>): String = MaestroFlowParser.formatCommands(commands)\n\n    fun checkSyntax(maestroCode: String) = mapParsingErrors(Paths.get(\"/syntax-checker/\")) {\n        MaestroFlowParser.checkSyntax(maestroCode)\n    }\n\n    private fun <T> mapParsingErrors(path: Path, block: () -> T): T {\n        try {\n            return block()\n        } catch (e: FlowParseException) {\n            val message = errorMessage(e)\n            throw SyntaxError(message, e)\n        } catch (e: Throwable) {\n            val message = fallbackErrorMessage(path, e)\n            throw SyntaxError(message)\n        }\n    }\n\n    private fun errorMessage(e: FlowParseException): String {\n        val inlineMessage = if (e.docs == null) {\n            e.errorMessage\n        } else {\n             \"${e.errorMessage}\\n\\n> ${e.docs}\"\n        }\n        val message = \"\"\"\n            ~> ${e.title}\n            ~\n            ~${e.contentPath.absolutePathString()}:${e.location.lineNr}\n            ~${inlineMessage(e.content, e.location, inlineMessage)}\n        \"\"\".trimMargin(\"~\").trim()\n        return message\n    }\n\n    private fun inlineMessage(flow: String, jsonLocation: JsonLocation, message: String): String {\n        val lineNumber = jsonLocation.lineNr - 1\n        val columnNumber = jsonLocation.columnNr\n        val linesBefore = 2\n        val linesAfter = 2\n        val sb = StringBuilder()\n        flow.lines().forEachIndexed { index, line ->\n            val lineNumberString = (index + 1).toString().padStart(4)\n            val lineContent = line.trimEnd()\n            if (index < lineNumber - linesBefore || index > lineNumber + linesAfter) return@forEachIndexed\n            sb.append(\"$lineNumberString | $lineContent\\n\")\n            if (index == lineNumber) {\n                val caret = \"\\u00A0\".repeat(4 + columnNumber - 1) + \"^\"\n                sb.appendLine(caret)\n                sb.appendLine(drawTextBox(message, 80))\n            }\n        }\n        val full = sb.toString().trim()\n        return drawTextBox(full, 100)\n    }\n\n    private fun fallbackErrorMessage(path: Path, e: Throwable): String {\n        val prefix = \"Failed to parse file: ${path.absolutePathString()}\"\n\n        val jsonException = getJsonProcessingException(e) ?: return \"$prefix\\n${e.message ?: e.toString()}\"\n\n        val lineNumber = jsonException.location?.lineNr ?: -1\n        val originalMessage = jsonException.originalMessage\n\n        val header = if (lineNumber != -1) \"$prefix:$lineNumber\" else prefix\n\n        return \"$header\\n$originalMessage\"\n    }\n\n    private fun getJsonProcessingException(e: Throwable): JsonProcessingException? {\n        if (e is JsonProcessingException) return e\n        val cause = e.cause\n        if (cause == null || cause == e) return null\n        return getJsonProcessingException(cause)\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlCondition.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonFormat\nimport maestro.device.Platform\n\ndata class YamlCondition(\n    @JsonFormat(with = [JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_PROPERTIES])\n    val platform: Platform? = null,\n    val visible: YamlElementSelectorUnion? = null,\n    val notVisible: YamlElementSelectorUnion? = null,\n    val `true`: String? = null,\n    val label: String? = null,\n    val optional: Boolean = false,\n)\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlConfig.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonAlias\nimport com.fasterxml.jackson.annotation.JsonAnySetter\nimport com.fasterxml.jackson.core.JsonLocation\nimport maestro.orchestra.ApplyConfigurationCommand\nimport maestro.orchestra.MaestroCommand\nimport maestro.orchestra.MaestroConfig\nimport maestro.orchestra.MaestroOnFlowComplete\nimport maestro.orchestra.MaestroOnFlowStart\nimport java.nio.file.Path\n\n// Exception for config field validation errors\nclass ConfigParseError(\n    val errorType: String,\n    val location: JsonLocation? = null\n) : RuntimeException(\"Config validation error: $errorType\")\n\ndata class YamlConfig(\n    val name: String?,\n    @JsonAlias(\"appId\") private val _appId: String?,\n    \n    val url: String?, // Raw url from YAML - preserved to distinguish web vs app configs\n    val tags: List<String>? = emptyList(),\n    val env: Map<String, String> = emptyMap(),\n    val onFlowStart: YamlOnFlowStart?,\n    val onFlowComplete: YamlOnFlowComplete?,\n    val properties: Map<String, String> = emptyMap(),\n    private val ext: MutableMap<String, Any?> = mutableMapOf<String, Any?>()\n) {\n\n    // Computed appId: uses url for web flows, _appId for mobile apps\n    // Preserving both fields allows detecting web vs app configuration contexts\n    val appId: String\n\n    init {\n        if (url == null && _appId == null) {\n            throw ConfigParseError(\"missing_app_target\")\n        }\n        appId = url ?: _appId!!\n    }\n\n    @JsonAnySetter\n    fun setOtherField(key: String, other: Any?) {\n        ext[key] = other\n    }\n\n    fun toCommand(flowPath: Path): MaestroCommand {\n        val config = MaestroConfig(\n            appId = appId,  // maestro-cli uses url as appId for web flows\n            name = name,\n            tags = tags,\n            ext = ext.toMap(),\n            onFlowStart = onFlowStart(flowPath),\n            onFlowComplete = onFlowComplete(flowPath),\n            properties = properties\n        )\n        return MaestroCommand(ApplyConfigurationCommand(config))\n    }\n\n    private fun onFlowComplete(flowPath: Path): MaestroOnFlowComplete? {\n        if (onFlowComplete == null) return null\n\n        return MaestroOnFlowComplete(onFlowComplete.commands.flatMap { it.toCommands(flowPath, appId) })\n    }\n\n    private fun onFlowStart(flowPath: Path): MaestroOnFlowStart? {\n        if (onFlowStart == null) return null\n\n        return MaestroOnFlowStart(onFlowStart.commands.flatMap { it.toCommands(flowPath, appId) })\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlElementSelector.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize\n\n@JsonDeserialize(`as` = YamlElementSelector::class)\ndata class YamlElementSelector(\n    val text: String? = null,\n    val id: String? = null,\n    val width: Int? = null,\n    val height: Int? = null,\n    val tolerance: Int? = null,\n    val optional: Boolean? = null,\n    val retryTapIfNoChange: Boolean? = null,\n    val waitUntilVisible: Boolean? = null,\n    val point: String? = null,\n    val start: String? = null,\n    val end: String? = null,\n    val below: YamlElementSelectorUnion? = null,\n    val above: YamlElementSelectorUnion? = null,\n    val leftOf: YamlElementSelectorUnion? = null,\n    val rightOf: YamlElementSelectorUnion? = null,\n    val containsChild: YamlElementSelectorUnion? = null,\n    val containsDescendants: List<YamlElementSelectorUnion>? = null,\n    val traits: String? = null,\n    val index: String? = null,\n    val enabled: Boolean? = null,\n    val selected: Boolean? = null,\n    val checked: Boolean? = null,\n    val focused: Boolean? = null,\n    val repeat: Int? = null,\n    val delay: Int? = null,\n    val waitToSettleTimeoutMs: Int? = null,\n    val childOf: YamlElementSelectorUnion? = null,\n    val label: String? = null,\n    val css: String? = null,\n) : YamlElementSelectorUnion\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlElementSelectorUnion.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.core.JsonParser\nimport com.fasterxml.jackson.core.TreeNode\nimport com.fasterxml.jackson.databind.DeserializationContext\nimport com.fasterxml.jackson.databind.JsonDeserializer\nimport com.fasterxml.jackson.databind.ObjectMapper\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize\nimport com.fasterxml.jackson.databind.node.POJONode\nimport com.fasterxml.jackson.databind.node.ValueNode\n\n@JsonDeserialize(using = YamlElementSelectorDeserializer::class)\ninterface YamlElementSelectorUnion\n\ndata class StringElementSelector(val value: String) : YamlElementSelectorUnion\n\nclass YamlElementSelectorDeserializer : JsonDeserializer<YamlElementSelectorUnion>() {\n\n    override fun deserialize(parser: JsonParser, ctx: DeserializationContext): YamlElementSelectorUnion {\n        val mapper = parser.codec as ObjectMapper\n        val root: TreeNode = mapper.readTree(parser)\n\n        return if (root is ValueNode && root !is POJONode) {\n            StringElementSelector(root.asText())\n        } else {\n            mapper.convertValue(root, YamlElementSelector::class.java)\n        }\n    }\n\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlEraseTextUnion.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\n\ndata class YamlEraseText(\n    val charactersToErase: Int? = null,\n    val label: String? = null,\n    val optional: Boolean = false,\n) {\n\n    companion object {\n\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(charactersToRemove: Int): YamlEraseText {\n            return YamlEraseText(\n                charactersToErase = charactersToRemove\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlEvalScript.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\nimport java.lang.UnsupportedOperationException\n\ndata class YamlEvalScript(\n    val script: String,\n    val label: String? = null,\n    val optional: Boolean = false,\n){\n    companion object {\n\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(script: Any): YamlEvalScript {\n            val evalScript = when (script) {\n                is String -> script\n                is Map<*, *> -> {\n                    val evaluatedScript = script.getOrDefault(\"script\", \"\") as String\n                    val label = script.getOrDefault(\"label\", null) as String?\n                    return YamlEvalScript(evaluatedScript, label)\n                }\n                is Int, is Long, is Char, is Boolean, is Float, is Double -> script.toString()\n                else -> throw UnsupportedOperationException(\"Cannot deserialize evaluate script with data type ${script.javaClass}\")\n            }\n            return YamlEvalScript(\n                script = evalScript,\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlExtendedWaitUntil.kt",
    "content": "package maestro.orchestra.yaml\n\ndata class YamlExtendedWaitUntil(\n    val visible: YamlElementSelectorUnion? = null,\n    val notVisible: YamlElementSelectorUnion? = null,\n    val timeout: String? = null,\n    val label: String? = null,\n    val optional: Boolean = false,\n)\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlExtractTextWithAI.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\n\ndata class YamlExtractTextWithAI(\n    val query: String,\n    val outputVariable: String = \"aiOutput\",\n    val optional: Boolean = true,\n    val label: String? = null,\n) {\n\n    companion object {\n\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(query: String): YamlExtractTextWithAI {\n            return YamlExtractTextWithAI(\n                query = query,\n                optional = true,\n            )\n        }\n    }\n}"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlFluentCommand.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonIgnore\nimport com.fasterxml.jackson.core.JsonLocation\nimport maestro.device.DeviceOrientation\nimport maestro.KeyCode\nimport maestro.Point\nimport maestro.TapRepeat\nimport maestro.orchestra.AddMediaCommand\nimport maestro.orchestra.AssertConditionCommand\nimport maestro.orchestra.AssertNoDefectsWithAICommand\nimport maestro.orchestra.AssertScreenshotCommand\nimport maestro.orchestra.AssertWithAICommand\nimport maestro.orchestra.BackPressCommand\nimport maestro.orchestra.ClearKeychainCommand\nimport maestro.orchestra.ClearStateCommand\nimport maestro.orchestra.Condition\nimport maestro.orchestra.CopyTextFromCommand\nimport maestro.orchestra.SetClipboardCommand\nimport maestro.orchestra.ElementSelector\nimport maestro.orchestra.ElementTrait\nimport maestro.orchestra.EraseTextCommand\nimport maestro.orchestra.EvalScriptCommand\nimport maestro.orchestra.ExtractTextWithAICommand\nimport maestro.orchestra.HideKeyboardCommand\nimport maestro.orchestra.InputRandomCommand\nimport maestro.orchestra.InputRandomType\nimport maestro.orchestra.InputTextCommand\nimport maestro.orchestra.KillAppCommand\nimport maestro.orchestra.LaunchAppCommand\nimport maestro.orchestra.MaestroCommand\nimport maestro.orchestra.MaestroConfig\nimport maestro.orchestra.OpenLinkCommand\nimport maestro.orchestra.PasteTextCommand\nimport maestro.orchestra.PressKeyCommand\nimport maestro.orchestra.RepeatCommand\nimport maestro.orchestra.RetryCommand\nimport maestro.orchestra.RunFlowCommand\nimport maestro.orchestra.RunScriptCommand\nimport maestro.orchestra.ScrollCommand\nimport maestro.orchestra.ScrollUntilVisibleCommand\nimport maestro.orchestra.SetAirplaneModeCommand\nimport maestro.orchestra.SetLocationCommand\nimport maestro.orchestra.SetOrientationCommand\nimport maestro.orchestra.SetPermissionsCommand\nimport maestro.orchestra.StartRecordingCommand\nimport maestro.orchestra.StopAppCommand\nimport maestro.orchestra.StopRecordingCommand\nimport maestro.orchestra.SwipeCommand\nimport maestro.orchestra.TakeScreenshotCommand\nimport maestro.orchestra.TapOnElementCommand\nimport maestro.orchestra.TapOnPointV2Command\nimport maestro.orchestra.ToggleAirplaneModeCommand\nimport maestro.orchestra.TravelCommand\nimport maestro.orchestra.WaitForAnimationToEndCommand\nimport maestro.orchestra.error.InvalidFlowFile\nimport maestro.orchestra.error.MediaFileNotFound\nimport maestro.orchestra.error.SyntaxError\nimport maestro.orchestra.util.Env.withEnv\nimport java.nio.file.Path\nimport kotlin.io.path.absolutePathString\nimport kotlin.io.path.exists\nimport kotlin.io.path.isDirectory\nimport kotlin.io.path.readText\n\nclass ToCommandsException(\n    override val cause: Throwable,\n    val location: JsonLocation,\n) : RuntimeException(cause)\n\ndata class YamlFluentCommand(\n    val tapOn: YamlElementSelectorUnion? = null,\n    val doubleTapOn: YamlElementSelectorUnion? = null,\n    val longPressOn: YamlElementSelectorUnion? = null,\n    val assertVisible: YamlElementSelectorUnion? = null,\n    val assertNotVisible: YamlElementSelectorUnion? = null,\n    val assertTrue: YamlAssertTrue? = null,\n    val assertNoDefectsWithAI: YamlAssertNoDefectsWithAI? = null,\n    val assertScreenshot: YamlAssertScreenshot? = null,\n    val assertWithAI: YamlAssertWithAI? = null,\n    val extractTextWithAI: YamlExtractTextWithAI? = null,\n    val back: YamlActionBack? = null,\n    val clearKeychain: YamlActionClearKeychain? = null,\n    val hideKeyboard: YamlActionHideKeyboard? = null,\n    val pasteText: YamlActionPasteText? = null,\n    val scroll: YamlActionScroll? = null,\n    val inputText: YamlInputText? = null,\n    val inputRandomText: YamlInputRandomText? = null,\n    val inputRandomNumber: YamlInputRandomNumber? = null,\n    val inputRandomEmail: YamlInputRandomEmail? = null,\n    val inputRandomPersonName: YamlInputRandomPersonName? = null,\n    val inputRandomCityName: YamlInputRandomCityName? = null,\n    val inputRandomCountryName: YamlInputRandomCountryName? = null,\n    val inputRandomColorName: YamlInputRandomColorName? = null,\n    val launchApp: YamlLaunchApp? = null,\n    val setPermissions: YamlSetPermissions? = null,\n    val swipe: YamlSwipe? = null,\n    val openLink: YamlOpenLink? = null,\n    val openBrowser: String? = null,\n    val pressKey: YamlPressKey? = null,\n    val eraseText: YamlEraseText? = null,\n    val action: String? = null,\n    val takeScreenshot: YamlTakeScreenshot? = null,\n    val extendedWaitUntil: YamlExtendedWaitUntil? = null,\n    val stopApp: YamlStopApp? = null,\n    val killApp: YamlKillApp? = null,\n    val clearState: YamlClearState? = null,\n    val runFlow: YamlRunFlow? = null,\n    val setLocation: YamlSetLocation? = null,\n    val setOrientation: YamlSetOrientation? = null,\n    val repeat: YamlRepeatCommand? = null,\n    val copyTextFrom: YamlElementSelectorUnion? = null,\n    val setClipboard: YamlSetClipboard? = null,\n    val runScript: YamlRunScript? = null,\n    val waitForAnimationToEnd: YamlWaitForAnimationToEndCommand? = null,\n    val evalScript: YamlEvalScript? = null,\n    val scrollUntilVisible: YamlScrollUntilVisible? = null,\n    val travel: YamlTravelCommand? = null,\n    val startRecording: YamlStartRecording? = null,\n    val stopRecording: YamlStopRecording? = null,\n    val addMedia: YamlAddMedia? = null,\n    val setAirplaneMode: YamlSetAirplaneMode? = null,\n    val toggleAirplaneMode: YamlToggleAirplaneMode? = null,\n    val retry: YamlRetryCommand? = null,\n    @JsonIgnore val _location: JsonLocation,\n) {\n\n    fun toCommands(flowPath: Path, appId: String): List<MaestroCommand> {\n        return try {\n            _toCommands(flowPath, appId)\n        } catch (e: Throwable) {\n            throw ToCommandsException(e, _location)\n        }\n    }\n\n    @SuppressWarnings(\"ComplexMethod\")\n    private fun _toCommands(flowPath: Path, appId: String): List<MaestroCommand> {\n        return when {\n            launchApp != null -> listOf(launchApp(launchApp, appId))\n            setPermissions != null -> listOf(setPermissions(command = setPermissions, appId))\n            tapOn != null -> listOf(tapCommand(tapOn))\n            longPressOn != null -> listOf(tapCommand(longPressOn, longPress = true))\n            assertVisible != null -> listOf(\n                MaestroCommand(\n                    AssertConditionCommand(\n                        condition = Condition(\n                            visible = toElementSelector(assertVisible),\n                        ),\n                        label = (assertVisible as? YamlElementSelector)?.label,\n                        optional = (assertVisible as? YamlElementSelector)?.optional ?: false,\n                    )\n                )\n            )\n\n            assertNotVisible != null -> listOf(\n                MaestroCommand(\n                    AssertConditionCommand(\n                        condition = Condition(\n                            notVisible = toElementSelector(assertNotVisible),\n                        ),\n                        label = (assertNotVisible as? YamlElementSelector)?.label,\n                        optional = (assertNotVisible as? YamlElementSelector)?.optional ?: false,\n                    )\n                )\n            )\n\n            assertTrue != null -> listOf(\n                MaestroCommand(\n                    AssertConditionCommand(\n                        Condition(\n                            scriptCondition = assertTrue.condition,\n                        ),\n                        label = assertTrue.label,\n                        optional = assertTrue.optional,\n                    )\n                )\n            )\n\n            assertNoDefectsWithAI != null -> listOf(\n                MaestroCommand(\n                    AssertNoDefectsWithAICommand(\n                        optional = assertNoDefectsWithAI.optional,\n                        label = assertNoDefectsWithAI.label,\n                    )\n                )\n            )\n\n            assertWithAI != null -> listOf(\n                MaestroCommand(\n                    AssertWithAICommand(\n                        assertion = assertWithAI.assertion,\n                        optional = assertWithAI.optional,\n                        label = assertWithAI.label,\n                    )\n                )\n            )\n\n            extractTextWithAI != null -> listOf(\n                MaestroCommand(\n                    ExtractTextWithAICommand(\n                        query = extractTextWithAI.query,\n                        outputVariable = extractTextWithAI.outputVariable,\n                        optional = extractTextWithAI.optional,\n                        label = extractTextWithAI.label,\n                    )\n                )\n            )\n\n            assertScreenshot != null -> listOf(\n                MaestroCommand(\n                    AssertScreenshotCommand(\n                        path = assertScreenshot.path,\n                        thresholdPercentage = assertScreenshot.thresholdPercentage,\n                        cropOn = assertScreenshot.cropOn?.let { toElementSelector(it) },\n                        optional = assertScreenshot.optional,\n                        label = assertScreenshot.label,\n                        flowPath = flowPath.parent,\n                    )\n                )\n            )\n            addMedia != null -> listOf(\n                MaestroCommand(\n                    addMediaCommand = addMediaCommand(addMedia, flowPath)\n                )\n            )\n            inputText != null -> listOf(MaestroCommand(InputTextCommand(text = inputText.text, label = inputText.label, optional = inputText.optional)))\n            inputRandomText != null -> listOf(MaestroCommand(InputRandomCommand(inputType = InputRandomType.TEXT, length = inputRandomText.length, label = inputRandomText.label, optional = inputRandomText.optional)))\n            inputRandomNumber != null -> listOf(MaestroCommand(InputRandomCommand(inputType = InputRandomType.NUMBER, length = inputRandomNumber.length, label = inputRandomNumber.label, optional = inputRandomNumber.optional)))\n            inputRandomEmail != null -> listOf(MaestroCommand(InputRandomCommand(inputType = InputRandomType.TEXT_EMAIL_ADDRESS, label = inputRandomEmail.label, optional = inputRandomEmail.optional)))\n            inputRandomPersonName != null -> listOf(MaestroCommand(InputRandomCommand(inputType = InputRandomType.TEXT_PERSON_NAME, label = inputRandomPersonName.label, optional = inputRandomPersonName.optional)))\n            inputRandomCityName != null -> listOf(MaestroCommand(InputRandomCommand(inputType = InputRandomType.TEXT_CITY_NAME, label = inputRandomCityName.label, optional = inputRandomCityName.optional)))\n            inputRandomCountryName != null -> listOf(MaestroCommand(InputRandomCommand(inputType = InputRandomType.TEXT_COUNTRY_NAME, label = inputRandomCountryName.label, optional = inputRandomCountryName.optional)))\n            inputRandomColorName != null -> listOf(MaestroCommand(InputRandomCommand(inputType = InputRandomType.TEXT_COLOR, label = inputRandomColorName.label, optional = inputRandomColorName.optional)))\n\n            swipe != null -> listOf(swipeCommand(swipe))\n            openLink != null -> listOf(\n                MaestroCommand(\n                    OpenLinkCommand(\n                        link = openLink.link,\n                        autoVerify = openLink.autoVerify,\n                        browser = openLink.browser,\n                        label = openLink.label,\n                        optional = openLink.optional\n                    )\n                )\n            )\n\n            pressKey != null -> listOf(\n                MaestroCommand(\n                    PressKeyCommand(\n                        code = KeyCode.getByName(pressKey.key) ?: throw SyntaxError(\"Unknown key name: $pressKey\"),\n                        label = pressKey.label,\n                        optional = pressKey.optional\n                    )\n                )\n            )\n\n            eraseText != null -> listOf(eraseCommand(eraseText))\n            action != null -> listOf(\n                when (action) {\n                    \"back\" -> MaestroCommand(BackPressCommand())\n                    \"hideKeyboard\" -> MaestroCommand(HideKeyboardCommand())\n                    \"scroll\" -> MaestroCommand(ScrollCommand())\n                    \"clearKeychain\" -> MaestroCommand(ClearKeychainCommand())\n                    \"pasteText\" -> MaestroCommand(PasteTextCommand())\n                    else -> error(\"Unknown navigation target: $action\")\n                }\n            )\n\n            back != null -> listOf(MaestroCommand(BackPressCommand(label = back.label, optional = back.optional)))\n            clearKeychain != null -> listOf(\n                MaestroCommand(\n                    ClearKeychainCommand(\n                        label = clearKeychain.label,\n                        optional = clearKeychain.optional\n                    )\n                )\n            )\n\n            hideKeyboard != null -> listOf(\n                MaestroCommand(\n                    HideKeyboardCommand(\n                        label = hideKeyboard.label,\n                        optional = hideKeyboard.optional\n                    )\n                )\n            )\n\n            pasteText != null -> listOf(\n                MaestroCommand(\n                    PasteTextCommand(\n                        label = pasteText.label,\n                        optional = pasteText.optional\n                    )\n                )\n            )\n\n            scroll != null -> listOf(MaestroCommand(ScrollCommand(label = scroll.label, optional = scroll.optional)))\n            takeScreenshot != null -> listOf(\n                MaestroCommand(\n                    TakeScreenshotCommand(\n                        path = takeScreenshot.path,\n                        label = takeScreenshot.label,\n                        optional = takeScreenshot.optional,\n                        cropOn = takeScreenshot.cropOn?.let { toElementSelector(selectorUnion = it) },\n                    )\n                )\n            )\n\n            extendedWaitUntil != null -> listOf(extendedWait(extendedWaitUntil))\n            stopApp != null -> listOf(\n                MaestroCommand(\n                    StopAppCommand(\n                        appId = stopApp.appId ?: appId,\n                        label = stopApp.label,\n                        optional = stopApp.optional,\n                    )\n                )\n            )\n\n            killApp != null -> listOf(\n                MaestroCommand(\n                    KillAppCommand(\n                        appId = killApp.appId ?: appId,\n                        label = killApp.label,\n                        optional = killApp.optional,\n                    )\n                )\n            )\n\n            clearState != null -> listOf(\n                MaestroCommand(\n                    ClearStateCommand(\n                        appId = clearState.appId ?: appId,\n                        label = clearState.label,\n                        optional = clearState.optional,\n                    )\n                )\n            )\n\n            runFlow != null -> listOf(runFlowCommand(appId, flowPath, runFlow))\n            setLocation != null -> listOf(\n                MaestroCommand(\n                    SetLocationCommand(\n                        latitude = setLocation.latitude,\n                        longitude = setLocation.longitude,\n                        label = setLocation.label,\n                        optional = setLocation.optional,\n                    )\n                )\n            )\n\n            setOrientation != null -> listOf(\n                MaestroCommand(\n                    SetOrientationCommand(\n                        orientation = DeviceOrientation.getByName(setOrientation.orientation)?.name\n                            ?: setOrientation.orientation,\n                        label = setOrientation.label,\n                        optional = setOrientation.optional,\n                    )\n                )\n            )\n            \n            repeat != null -> listOf(\n                repeatCommand(repeat, flowPath, appId)\n            )\n\n            retry != null -> listOf(\n                retryCommand(retry, flowPath, appId)\n            )\n\n            copyTextFrom != null -> listOf(copyTextFromCommand(copyTextFrom))\n            setClipboard != null -> listOf(\n                MaestroCommand(\n                    SetClipboardCommand(\n                        text = setClipboard.text,\n                        label = setClipboard.label,\n                        optional = setClipboard.optional\n                    )\n                )\n            )\n            runScript != null -> {\n                val scriptPath = resolvePath(flowPath, runScript.file)\n                listOf(\n                    MaestroCommand(\n                        RunScriptCommand(\n                            script = scriptPath.readText(),\n                            env = runScript.env,\n                            sourceDescription = scriptPath.toString(),\n                            condition = runScript.`when`?.toCondition(),\n                            label = runScript.label,\n                            optional = runScript.optional,\n                        )\n                    )\n                )\n            }\n\n            waitForAnimationToEnd != null -> listOf(\n                MaestroCommand(\n                    WaitForAnimationToEndCommand(\n                        timeout = waitForAnimationToEnd.timeout,\n                        label = waitForAnimationToEnd.label,\n                        optional = waitForAnimationToEnd.optional,\n                    )\n                )\n            )\n\n            evalScript != null -> listOf(\n                MaestroCommand(\n                    EvalScriptCommand(\n                        scriptString = evalScript.script,\n                        label = evalScript.label,\n                        optional = evalScript.optional,\n                    )\n                )\n            )\n\n            scrollUntilVisible != null -> listOf(scrollUntilVisibleCommand(scrollUntilVisible))\n            travel != null -> listOf(travelCommand(travel))\n            startRecording != null -> listOf(\n                MaestroCommand(\n                    StartRecordingCommand(\n                        startRecording.path,\n                        startRecording.label,\n                        startRecording.optional\n                    )\n                )\n            )\n\n            stopRecording != null -> listOf(\n                MaestroCommand(\n                    StopRecordingCommand(\n                        stopRecording.label,\n                        stopRecording.optional\n                    )\n                )\n            )\n\n            doubleTapOn != null -> {\n                val yamlDelay = (doubleTapOn as? YamlElementSelector)?.delay?.toLong()\n                val delay =\n                    if (yamlDelay != null && yamlDelay >= 0) yamlDelay else TapOnElementCommand.DEFAULT_REPEAT_DELAY\n                val tapRepeat = TapRepeat(2, delay)\n                listOf(tapCommand(doubleTapOn, tapRepeat = tapRepeat))\n            }\n\n            setAirplaneMode != null -> listOf(\n                MaestroCommand(\n                    SetAirplaneModeCommand(\n                        setAirplaneMode.value,\n                        setAirplaneMode.label,\n                        setAirplaneMode.optional\n                    )\n                )\n            )\n\n            toggleAirplaneMode != null -> listOf(\n                MaestroCommand(\n                    ToggleAirplaneModeCommand(\n                        toggleAirplaneMode.label,\n                        toggleAirplaneMode.optional\n                    )\n                )\n            )\n\n            else -> throw SyntaxError(\"Invalid command: No mapping provided for $this\")\n        }\n    }\n\n    private fun addMediaCommand(addMedia: YamlAddMedia, flowPath: Path): AddMediaCommand {\n        if (addMedia.files == null || addMedia.files.any { it == null }) {\n            throw SyntaxError(\"Invalid addMedia command: media files cannot be empty\")\n        }\n\n        val mediaPaths = addMedia.files.filterNotNull().map {\n            val path = flowPath.fileSystem.getPath(it)\n\n            val resolvedPath = if (path.isAbsolute) {\n                path\n            } else {\n                flowPath.resolveSibling(path).toAbsolutePath()\n            }\n            if (!resolvedPath.exists()) {\n                throw MediaFileNotFound(\"Media file at $path in flow file: $flowPath not found\", path)\n            }\n            resolvedPath\n        }\n        val mediaAbsolutePathStrings = mediaPaths.mapNotNull { it.absolutePathString() }\n        return AddMediaCommand(mediaAbsolutePathStrings, addMedia.label, addMedia.optional)\n    }\n\n    private fun runFlowCommand(\n        appId: String,\n        flowPath: Path,\n        runFlow: YamlRunFlow\n    ): MaestroCommand {\n        if (runFlow.file == null && runFlow.commands == null) {\n            throw SyntaxError(\"Invalid runFlow command: No file or commands provided\")\n        }\n\n        if (runFlow.file != null && runFlow.commands != null) {\n            throw SyntaxError(\"Invalid runFlow command: Can't provide both file and commands at the same time\")\n        }\n\n        val commands = runFlow.commands\n            ?.flatMap {\n                it.toCommands(flowPath, appId)\n                    .withEnv(runFlow.env)\n            }\n            ?: runFlow(flowPath, runFlow)\n\n        val config = runFlow.file?.let {\n            readConfig(flowPath, runFlow.file)\n        }\n\n        return MaestroCommand(\n            RunFlowCommand(\n                commands = commands,\n                condition = runFlow.`when`?.toCondition(),\n                sourceDescription = runFlow.file,\n                config = config,\n                label = runFlow.label,\n                optional = runFlow.optional,\n            )\n        )\n    }\n\n    private fun retryCommand(retry: YamlRetryCommand, flowPath: Path, appId: String): MaestroCommand {\n        if (retry.file == null && retry.commands == null) {\n            throw SyntaxError(\"Invalid retry command: No file or commands provided\")\n        }\n\n        if (retry.file != null && retry.commands != null) {\n            throw SyntaxError(\"Invalid retry command: Can't provide both file and commands at the same time\")\n        }\n\n        val commands = retry.commands\n            ?.flatMap {\n                it.toCommands(flowPath, appId)\n                    .withEnv(retry.env)\n            }\n            ?: retry(flowPath, retry)\n\n        val config = retry.file?.let {\n            readConfig(flowPath, retry.file)\n        }\n\n\n        val maxRetries = retry.maxRetries ?: \"1\"\n\n        return MaestroCommand(\n            RetryCommand(\n                maxRetries = maxRetries,\n                commands = commands,\n                sourceDescription = retry.file,\n                label = retry.label,\n                optional = retry.optional,\n                config = config\n            )\n        )\n    }\n\n    private fun travelCommand(command: YamlTravelCommand): MaestroCommand {\n        return MaestroCommand(\n            TravelCommand(\n                points = command.points\n                    .map { point ->\n                        val spitPoint = point.split(\",\")\n\n                        if (spitPoint.size != 2) {\n                            throw SyntaxError(\"Invalid travel point: $point\")\n                        }\n\n                        val latitude =\n                            spitPoint[0].toDoubleOrNull() ?: throw SyntaxError(\"Invalid travel point latitude: $point\")\n                        val longitude =\n                            spitPoint[1].toDoubleOrNull() ?: throw SyntaxError(\"Invalid travel point longitude: $point\")\n\n                        TravelCommand.GeoPoint(\n                            latitude = latitude.toString(),\n                            longitude = longitude.toString(),\n                        )\n                    },\n                speedMPS = command.speed,\n                label = command.label,\n                optional = command.optional,\n            )\n        )\n    }\n\n    private fun repeatCommand(repeat: YamlRepeatCommand, flowPath: Path, appId: String) = MaestroCommand(\n        RepeatCommand(\n            times = repeat.times,\n            condition = repeat.`while`?.toCondition(),\n            commands = repeat.commands\n                .flatMap { it.toCommands(flowPath, appId) },\n            label = repeat.label,\n            optional = repeat.optional,\n        )\n    )\n\n    private fun eraseCommand(eraseText: YamlEraseText): MaestroCommand {\n        return if (eraseText.charactersToErase != null) {\n            MaestroCommand(\n                EraseTextCommand(\n                    charactersToErase = eraseText.charactersToErase,\n                    label = eraseText.label,\n                    optional = eraseText.optional\n                )\n            )\n        } else {\n            MaestroCommand(\n                EraseTextCommand(\n                    charactersToErase = null,\n                    label = eraseText.label,\n                    optional = eraseText.optional\n                )\n            )\n        }\n    }\n\n    fun getWatchFiles(flowPath: Path): List<Path> {\n        return when {\n            runFlow != null -> getRunFlowWatchFiles(flowPath, runFlow)\n            else -> return emptyList()\n        }\n    }\n\n    private fun getRunFlowWatchFiles(flowPath: Path, runFlow: YamlRunFlow): List<Path> {\n        if (runFlow.file == null) {\n            return emptyList()\n        }\n\n        val runFlowPath = resolvePath(flowPath, runFlow.file)\n        return listOf(runFlowPath) + YamlCommandReader.getWatchFiles(runFlowPath)\n    }\n\n    private fun runFlow(flowPath: Path, command: YamlRunFlow): List<MaestroCommand> {\n        if (command.file == null) {\n            error(\"Invalid runFlow command: No file or commands provided\")\n        }\n\n        val runFlowPath = resolvePath(flowPath, command.file)\n        return YamlCommandReader.readCommands(runFlowPath)\n            .withEnv(command.env)\n    }\n\n    private fun retry(flowPath: Path, command: YamlRetryCommand): List<MaestroCommand> {\n        if (command.file == null) {\n            error(\"Invalid runFlow command: No file or commands provided\")\n        }\n\n        val retryFlowPath = resolvePath(flowPath, command.file)\n        return YamlCommandReader.readCommands(retryFlowPath)\n            .withEnv(command.env)\n    }\n\n    private fun readConfig(flowPath: Path, commandFile: String): MaestroConfig? {\n        val runFlowPath = resolvePath(flowPath, commandFile)\n        return YamlCommandReader.readConfig(runFlowPath).toCommand(runFlowPath).applyConfigurationCommand?.config\n    }\n\n    private fun resolvePath(flowPath: Path, requestedPath: String): Path {\n        val path = flowPath.fileSystem.getPath(requestedPath)\n\n        val resolvedPath = if (path.isAbsolute) {\n            path\n        } else {\n            flowPath.resolveSibling(path).toAbsolutePath()\n        }\n        if (resolvedPath.equals(flowPath.toAbsolutePath())) {\n            throw InvalidFlowFile(\n                \"Referenced Flow file can't be the same as the main Flow file: ${resolvedPath.toUri()}\",\n                resolvedPath\n            )\n        }\n        if (!resolvedPath.exists()) {\n            throw InvalidFlowFile(\"Flow file does not exist: ${resolvedPath.toUri()}\", resolvedPath)\n        }\n        if (resolvedPath.isDirectory()) {\n            throw InvalidFlowFile(\"Flow file can't be a directory: ${resolvedPath.toUri()}\", resolvedPath)\n        }\n        return resolvedPath\n    }\n\n    private fun extendedWait(command: YamlExtendedWaitUntil): MaestroCommand {\n        if (command.visible == null && command.notVisible == null) {\n            throw SyntaxError(\"extendedWaitUntil expects either `visible` or `notVisible` to be provided\")\n        }\n\n        val condition = Condition(\n            visible = command.visible?.let { toElementSelector(it) },\n            notVisible = command.notVisible?.let { toElementSelector(it) },\n        )\n\n        return MaestroCommand(\n            AssertConditionCommand(\n                condition = condition,\n                timeout = command.timeout,\n                label = command.label,\n                optional = command.optional,\n            )\n        )\n    }\n\n    private fun launchApp(command: YamlLaunchApp, appId: String): MaestroCommand {\n        return MaestroCommand(\n            LaunchAppCommand(\n                appId = command.appId ?: appId,\n                clearState = command.clearState,\n                clearKeychain = command.clearKeychain,\n                stopApp = command.stopApp,\n                permissions = command.permissions,\n                launchArguments = command.arguments,\n                label = command.label,\n                optional = command.optional,\n            )\n        )\n    }\n\n    private fun setPermissions(command: YamlSetPermissions, appId: String): MaestroCommand {\n        return MaestroCommand(\n            SetPermissionsCommand(\n                appId = command.appId ?: appId,\n                permissions = command.permissions,\n                label = command.label,\n                optional = command.optional,\n            )\n        )\n    }\n\n    private fun tapCommand(\n        tapOn: YamlElementSelectorUnion,\n        longPress: Boolean = false,\n        tapRepeat: TapRepeat? = null\n    ): MaestroCommand {\n        val retryIfNoChange = (tapOn as? YamlElementSelector)?.retryTapIfNoChange ?: false\n        val waitUntilVisible = (tapOn as? YamlElementSelector)?.waitUntilVisible ?: false\n        val point = (tapOn as? YamlElementSelector)?.point\n        val label = (tapOn as? YamlElementSelector)?.label\n        val optional = (tapOn as? YamlElementSelector)?.optional ?: false\n\n        val delay = (tapOn as? YamlElementSelector)?.delay?.toLong()\n        val repeat = tapRepeat ?: (tapOn as? YamlElementSelector)?.repeat?.let {\n            val count = if (it <= 0) 1 else it\n            val d = if (delay != null && delay >= 0) delay else TapOnElementCommand.DEFAULT_REPEAT_DELAY\n            TapRepeat(count, d)\n        }\n\n        val waitToSettleTimeoutMs = (tapOn as? YamlElementSelector)?.waitToSettleTimeoutMs?.let {\n            if (it > TapOnElementCommand.MAX_TIMEOUT_WAIT_TO_SETTLE_MS) TapOnElementCommand.MAX_TIMEOUT_WAIT_TO_SETTLE_MS\n            else it\n        }\n\n        return if (point != null) {\n            val elementSelector = toElementSelector(tapOn)\n            \n            // Check if we have both element selector and point - this means element-relative tap\n            if (hasAnySelector(elementSelector)) {\n                MaestroCommand(\n                    command = TapOnElementCommand(\n                        selector = elementSelector,\n                        retryIfNoChange = retryIfNoChange,\n                        waitUntilVisible = waitUntilVisible,\n                        longPress = longPress,\n                        repeat = repeat,\n                        waitToSettleTimeoutMs = waitToSettleTimeoutMs,\n                        label = label,\n                        optional = optional,\n                        relativePoint = point, // Parameter for element-relative coordinates\n                    )\n                )\n            } else {\n                // Pure point-based tap (screen coordinates)\n                MaestroCommand(\n                    TapOnPointV2Command(\n                        point = point,\n                        retryIfNoChange = retryIfNoChange,\n                        longPress = longPress,\n                        repeat = repeat,\n                        waitToSettleTimeoutMs = waitToSettleTimeoutMs,\n                        label = label,\n                        optional = optional,\n                    )\n                )\n            }\n        } else {\n            MaestroCommand(\n                command = TapOnElementCommand(\n                    selector = toElementSelector(tapOn),\n                    retryIfNoChange = retryIfNoChange,\n                    waitUntilVisible = waitUntilVisible,\n                    longPress = longPress,\n                    repeat = repeat,\n                    waitToSettleTimeoutMs = waitToSettleTimeoutMs,\n                    label = label,\n                    optional = optional,\n                )\n            )\n        }\n    }\n\n    private fun swipeCommand(swipe: YamlSwipe): MaestroCommand {\n        when (swipe) {\n            is YamlSwipeDirection -> return MaestroCommand(\n                SwipeCommand(\n                    direction = swipe.direction,\n                    duration = swipe.duration,\n                    label = swipe.label,\n                    optional = swipe.optional,\n                    waitToSettleTimeoutMs = swipe.waitToSettleTimeoutMs\n                )\n            )\n\n            is YamlCoordinateSwipe -> {\n                val start = swipe.start\n                val end = swipe.end\n                val startPoint: Point?\n                val endPoint: Point?\n\n                val startPoints = start.split(\",\")\n                    .map {\n                        it.trim().toInt()\n                    }\n                startPoint = Point(startPoints[0], startPoints[1])\n\n                val endPoints = end.split(\",\")\n                    .map {\n                        it.trim().toInt()\n                    }\n                endPoint = Point(endPoints[0], endPoints[1])\n\n                return MaestroCommand(\n                    SwipeCommand(\n                        startPoint = startPoint,\n                        endPoint = endPoint,\n                        duration = swipe.duration,\n                        label = swipe.label,\n                        optional = swipe.optional,\n                        waitToSettleTimeoutMs = swipe.waitToSettleTimeoutMs\n                    )\n                )\n            }\n\n            is YamlRelativeCoordinateSwipe -> {\n                return MaestroCommand(\n                    SwipeCommand(\n                        startRelative = swipe.start,\n                        endRelative = swipe.end,\n                        duration = swipe.duration,\n                        label = swipe.label,\n                        optional = swipe.optional,\n                        waitToSettleTimeoutMs = swipe.waitToSettleTimeoutMs\n                    )\n                )\n            }\n\n            is YamlSwipeElement -> return swipeElementCommand(swipe)\n            else -> {\n                throw IllegalStateException(\n                    \"Provide swipe direction UP, DOWN, RIGHT OR LEFT or by giving explicit \" +\n                            \"start and end coordinates.\"\n                )\n            }\n        }\n    }\n\n    private fun swipeElementCommand(swipeElement: YamlSwipeElement): MaestroCommand {\n        return MaestroCommand(\n            swipeCommand = SwipeCommand(\n                direction = swipeElement.direction,\n                elementSelector = toElementSelector(swipeElement.from),\n                duration = swipeElement.duration,\n                label = swipeElement.label,\n                optional = swipeElement.optional,\n                waitToSettleTimeoutMs = swipeElement.waitToSettleTimeoutMs\n            )\n        )\n    }\n\n    private fun toElementSelector(selectorUnion: YamlElementSelectorUnion): ElementSelector {\n        return if (selectorUnion is StringElementSelector) {\n            ElementSelector(\n                textRegex = selectorUnion.value,\n            )\n        } else if (selectorUnion is YamlElementSelector) {\n            toElementSelector(selectorUnion)\n        } else {\n            throw IllegalStateException(\"Unknown selector type: $selectorUnion\")\n        }\n    }\n\n    private fun hasAnySelector(selector: ElementSelector): Boolean {\n        return selector.textRegex != null ||\n                selector.idRegex != null ||\n                selector.size != null ||\n                selector.below != null ||\n                selector.above != null ||\n                selector.leftOf != null ||\n                selector.rightOf != null ||\n                selector.containsChild != null ||\n                selector.containsDescendants != null ||\n                selector.traits != null ||\n                selector.index != null ||\n                selector.enabled != null ||\n                selector.selected != null ||\n                selector.checked != null ||\n                selector.focused != null ||\n                selector.childOf != null ||\n                selector.css != null\n    }\n\n    private fun toElementSelector(selector: YamlElementSelector): ElementSelector {\n        val size = if (selector.width != null || selector.height != null) {\n            ElementSelector.SizeSelector(\n                width = selector.width,\n                height = selector.height,\n                tolerance = selector.tolerance,\n            )\n        } else {\n            null\n        }\n\n        return ElementSelector(\n            textRegex = selector.text,\n            idRegex = selector.id,\n            size = size,\n            optional = selector.optional ?: false,\n            below = selector.below?.let { toElementSelector(it) },\n            above = selector.above?.let { toElementSelector(it) },\n            leftOf = selector.leftOf?.let { toElementSelector(it) },\n            rightOf = selector.rightOf?.let { toElementSelector(it) },\n            containsChild = selector.containsChild?.let { toElementSelector(it) },\n            containsDescendants = selector.containsDescendants?.map { toElementSelector(it) },\n            traits = selector.traits\n                ?.split(\" \")\n                ?.map { ElementTrait.valueOf(it.replace('-', '_').uppercase()) },\n            index = selector.index,\n            enabled = selector.enabled,\n            selected = selector.selected,\n            checked = selector.checked,\n            focused = selector.focused,\n            childOf = selector.childOf?.let { toElementSelector(it) },\n            css = selector.css,\n        )\n    }\n\n    private fun copyTextFromCommand(\n        copyText: YamlElementSelectorUnion\n    ): MaestroCommand {\n        return if (copyText is StringElementSelector) {\n            MaestroCommand(\n                CopyTextFromCommand(\n                    selector = toElementSelector(copyText)\n                )\n            )\n        } else {\n            MaestroCommand(\n                CopyTextFromCommand(\n                    selector = toElementSelector(copyText),\n                    label = (copyText as? YamlElementSelector)?.label,\n                    optional = (copyText as? YamlElementSelector)?.optional ?: false\n                )\n            )\n        }\n    }\n\n    private fun scrollUntilVisibleCommand(yaml: YamlScrollUntilVisible): MaestroCommand {\n        val visibility =\n            if (yaml.visibilityPercentage < 0) 0 else if (yaml.visibilityPercentage > 100) 100 else yaml.visibilityPercentage\n        return MaestroCommand(\n            ScrollUntilVisibleCommand(\n                selector = toElementSelector(yaml.element),\n                direction = yaml.direction,\n                timeout = yaml.timeout,\n                scrollDuration = yaml.speed,\n                visibilityPercentage = visibility,\n                centerElement = yaml.centerElement,\n                label = yaml.label,\n                optional = yaml.optional,\n                originalSpeedValue = yaml.speed,\n                waitToSettleTimeoutMs = yaml.waitToSettleTimeoutMs\n            )\n        )\n    }\n\n    private fun YamlCondition.toCondition(): Condition {\n        return Condition(\n            platform = platform,\n            visible = visible?.let { toElementSelector(it) },\n            notVisible = notVisible?.let { toElementSelector(it) },\n            scriptCondition = `true`?.trim(),\n            label = label\n        )\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlInputRandomText.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.orchestra.yaml\n\ndata class YamlInputRandomText(\n    val length: Int?,\n    val label: String? = null,\n    val optional: Boolean = false,\n)\n\ndata class YamlInputRandomNumber(\n    val length: Int?,\n    val label: String? = null,\n    val optional: Boolean = false,\n)\n\ndata class YamlInputRandomEmail(\n    val label: String? = null,\n    val optional: Boolean = false,\n)\n\ndata class YamlInputRandomPersonName(\n    val label: String? = null,\n    val optional: Boolean = false,\n)\n\ndata class YamlInputRandomCityName(\n    val label: String? = null,\n    val optional: Boolean = false,\n)\n\ndata class YamlInputRandomCountryName(\n    val label: String? = null,\n    val optional: Boolean = false,\n)\n\ndata class YamlInputRandomColorName(\n    val label: String? = null,\n    val optional: Boolean = false,\n)"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlInputText.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\nimport java.lang.UnsupportedOperationException\n\ndata class YamlInputText(\n    val text: String,\n    val label: String? = null,\n    val optional: Boolean = false,\n) {\n\n    companion object {\n\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(text: Any): YamlInputText {\n            val inputText = when (text) {\n                is String -> text\n                is Map<*, *> -> {\n                    val input = text.getOrDefault(\"text\", \"\") as String\n                    val label = text.getOrDefault(\"label\", null) as String?\n                    return YamlInputText(input, label)\n                }\n                is Int, is Long, is Char, is Boolean, is Float, is Double -> text.toString()\n                else -> throw UnsupportedOperationException(\"Cannot deserialize input text with data type ${text.javaClass}\")\n            }\n            return YamlInputText(text = inputText)\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlKillApp.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\n\ndata class YamlKillApp(\n    val appId: String? = null,\n    val label: String? = null,\n    val optional: Boolean = false,\n) {\n\n    companion object {\n\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(appId: String) = YamlKillApp(\n            appId = appId,\n        )\n\n    }\n\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlLaunchApp.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonAlias\nimport com.fasterxml.jackson.annotation.JsonCreator\n\ndata class YamlLaunchApp(\n    @JsonAlias(\"url\")\n    val appId: String?,\n    val clearState: Boolean?,\n    val clearKeychain: Boolean?,\n    val stopApp: Boolean?,\n    val permissions: Map<String, String>?,\n    val arguments: Map<String, Any>?,\n    val label: String? = null,\n    val optional: Boolean = false,\n) {\n\n    companion object {\n\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(appId: String): YamlLaunchApp {\n            return YamlLaunchApp(\n                appId = appId,\n                clearState = null,\n                clearKeychain = null,\n                stopApp = null,\n                permissions = null,\n                arguments = null,\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlOnFlowComplete.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\n\ndata class YamlOnFlowComplete(val commands: List<YamlFluentCommand>) {\n    companion object {\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(commands: List<YamlFluentCommand>) = YamlOnFlowComplete(\n            commands = commands\n        )\n    }\n}"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlOnFlowStart.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\n\ndata class YamlOnFlowStart(val commands: List<YamlFluentCommand>) {\n    companion object {\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(commands: List<YamlFluentCommand>) = YamlOnFlowStart(\n            commands = commands\n        )\n    }\n}"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlOpenLink.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\n\ndata class YamlOpenLink(\n    val link: String,\n    val browser: Boolean = false,\n    val autoVerify: Boolean = false,\n    val label: String? = null,\n    val optional: Boolean = false,\n) {\n    companion object {\n\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(link: String): YamlOpenLink {\n            return YamlOpenLink(\n                link = link,\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlPressKey.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\n\ndata class YamlPressKey (\n    val key: String,\n    val label: String? = null,\n    val optional: Boolean = false,\n){\n    companion object {\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(key: String) = YamlPressKey(\n            key = key,\n        )\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlRepeatCommand.kt",
    "content": "package maestro.orchestra.yaml\n\ndata class YamlRepeatCommand(\n    val times: String? = null,\n    val `while`: YamlCondition? = null,\n    val commands: List<YamlFluentCommand>,\n    val label: String? = null,\n    val optional: Boolean = false,\n)\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlRetry.kt",
    "content": "package maestro.orchestra.yaml\n\ndata class YamlRetryCommand(\n    val maxRetries: String? = null,\n    val file: String? = null,\n    val commands: List<YamlFluentCommand>? = null,\n    val env: Map<String, String> = emptyMap(),\n    val label: String? = null,\n    val optional: Boolean = false,\n)"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlRunFlow.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\n\ndata class YamlRunFlow(\n    val file: String? = null,\n    val `when`: YamlCondition? = null,\n    val env: Map<String, String> = emptyMap(),\n    val commands: List<YamlFluentCommand>? = null,\n    val label: String? = null,\n    val optional: Boolean = false,\n) {\n\n    companion object {\n\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(file: String) = YamlRunFlow(\n            file = file,\n        )\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlRunScript.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\n\ndata class YamlRunScript(\n    val file: String,\n    val env: Map<String, String> = emptyMap(),\n    val `when`: YamlCondition? = null,\n    val label: String? = null,\n    val optional: Boolean = false,\n) {\n\n    companion object {\n\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(file: String) = YamlRunScript(\n            file = file,\n        )\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlScrollUntilVisible.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonFormat\nimport maestro.ScrollDirection\nimport maestro.orchestra.ScrollUntilVisibleCommand\n\ndata class YamlScrollUntilVisible(\n    @JsonFormat(with = [JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_PROPERTIES])\n    val direction: ScrollDirection = ScrollDirection.DOWN,\n    val element: YamlElementSelectorUnion,\n    val timeout: String = ScrollUntilVisibleCommand.DEFAULT_TIMEOUT_IN_MILLIS,\n    val speed: String = ScrollUntilVisibleCommand.DEFAULT_SCROLL_DURATION,\n    val visibilityPercentage: Int = ScrollUntilVisibleCommand.DEFAULT_ELEMENT_VISIBILITY_PERCENTAGE,\n    val centerElement: Boolean = ScrollUntilVisibleCommand.DEFAULT_CENTER_ELEMENT,\n    val waitToSettleTimeoutMs: Int? = null,\n    val label: String? = null,\n    val optional: Boolean = false,\n)\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlSetAirplaneMode.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\nimport com.fasterxml.jackson.core.JsonParser\nimport com.fasterxml.jackson.core.TreeNode\nimport com.fasterxml.jackson.databind.DeserializationContext\nimport com.fasterxml.jackson.databind.JsonDeserializer\nimport com.fasterxml.jackson.databind.ObjectMapper\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize\nimport maestro.orchestra.AirplaneValue\n\n@JsonDeserialize(using = YamlSetAirplaneModeDeserializer::class)\ndata class YamlSetAirplaneMode(\n    val value: AirplaneValue,\n    val label: String? = null,\n    val optional: Boolean = false,\n) {\n    companion object {\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(value: AirplaneValue): YamlSetAirplaneMode {\n            return YamlSetAirplaneMode(value)\n        }\n    }\n}\n\nclass YamlSetAirplaneModeDeserializer : JsonDeserializer<YamlSetAirplaneMode>() {\n\n    override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): YamlSetAirplaneMode {\n        val mapper = (parser.codec as ObjectMapper)\n        val root: TreeNode = mapper.readTree(parser)\n        val input = root.fieldNames().asSequence().toList()\n        val label = getLabel(root)\n        when {\n            input.contains(\"value\") -> {\n                val parsedValue = root.get(\"value\").toString().replace(\"\\\"\", \"\")\n                val returnValue = when (parsedValue) {\n                    \"enabled\" -> AirplaneValue.Enable\n                    \"disabled\" -> AirplaneValue.Disable\n                    else -> throwInvalidInputException(input)\n                }\n                return YamlSetAirplaneMode(returnValue, label)\n            }\n            (root.isValueNode && root.toString().contains(\"enabled\")) -> {\n                return YamlSetAirplaneMode(AirplaneValue.Enable, label)\n            }\n            (root.isValueNode && root.toString().contains(\"disabled\")) -> {\n                return YamlSetAirplaneMode(AirplaneValue.Disable, label)\n            }\n            else -> throwInvalidInputException(input)\n        }\n    }\n\n    private fun throwInvalidInputException(input: List<String>): Nothing {\n        throw IllegalArgumentException(\n            \"setAirplaneMode command takes either: \\n\" +\n                    \"\\t1. enabled: To enable airplane mode\\n\" +\n                    \"\\t2. disabled: To disable airplane mode\\n\" +\n                    \"\\t3. value: To set airplane mode to a specific value (enabled or disabled) \\n\" +\n                    \"It seems you provided invalid input with: $input\"\n        )\n    }\n\n    private fun getLabel(root: TreeNode): String? {\n        return if (root.path(\"label\").isMissingNode) {\n            null\n        } else {\n            root.path(\"label\").toString().replace(\"\\\"\", \"\")\n        }\n    }\n\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlSetClipboard.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\n\ndata class YamlSetClipboard(\n    val text: String,\n    val label: String? = null,\n    val optional: Boolean = false,\n) {\n\n    companion object {\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(text: String) = YamlSetClipboard(\n            text = text,\n        )\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlSetLocation.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\n\ndata class YamlSetLocation @JsonCreator constructor(\n    val latitude: String,\n    val longitude: String,\n    val label: String? = null,\n    val optional: Boolean = false,\n)\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlSetOrientation.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\nimport com.fasterxml.jackson.core.JsonParser\nimport com.fasterxml.jackson.core.TreeNode\nimport com.fasterxml.jackson.databind.DeserializationContext\nimport com.fasterxml.jackson.databind.JsonDeserializer\nimport com.fasterxml.jackson.databind.ObjectMapper\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize\nimport com.fasterxml.jackson.databind.node.TextNode\nimport maestro.device.DeviceOrientation\n\n@JsonDeserialize(using = YamlSetOrientationDeserializer::class)\ndata class YamlSetOrientation(\n    val orientation: String,\n    val label: String? = null,\n    val optional: Boolean = false,\n) {\n    companion object {\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(orientation: String) = YamlSetOrientation(\n            orientation = orientation,\n        )\n    }\n}\n\nclass YamlSetOrientationDeserializer : JsonDeserializer<YamlSetOrientation>() {\n    override fun deserialize(parser: JsonParser, context: DeserializationContext): YamlSetOrientation? {\n        val mapper = (parser.codec as ObjectMapper)\n        val root: TreeNode = mapper.readTree(parser)\n\n        if (root.isValueNode) {\n            val orientation = (root as TextNode).textValue()\n            validateOrientationIfLiteral(orientation)\n            return YamlSetOrientation(orientation)\n        }\n\n        val orientationNode = root.get(\"orientation\")\n            ?: throw IllegalArgumentException(\"Missing required field 'orientation' in SetOrientation action\")\n\n        val orientation = (orientationNode as TextNode).textValue()\n        validateOrientationIfLiteral(orientation)\n        val label = (root.get(\"label\") as? TextNode)?.textValue()\n        val optional = root.get(\"optional\")?.toString()?.toBoolean() ?: false\n\n        return YamlSetOrientation(\n            orientation = orientation,\n            label = label,\n            optional = optional,\n        )\n    }\n\n    private fun validateOrientationIfLiteral(orientation: String) {\n        if (orientation.contains(\"\\${\")) {\n            return\n        }\n\n        val validOrientations = DeviceOrientation.entries\n        val isValid = DeviceOrientation.getByName(orientation) != null\n        if (!isValid) {\n            throw IllegalArgumentException(\n                \"Unknown orientation: $orientation. Valid orientations are: $validOrientations \\n\" +\n                        \"(case insensitive, underscores optional, e.g 'landscape_left', 'landscapeLeft', and 'LANDSCAPE_LEFT' are all valid)\"\n            )\n        }\n    }\n}"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlSetPermissions.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonAlias\n\ndata class YamlSetPermissions(\n    @JsonAlias(\"url\")\n    val appId: String?,\n    val permissions: Map<String, String>,\n    val label: String? = null,\n    val optional: Boolean = false,\n)"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlStartRecording.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\n\ndata class YamlStartRecording(\n    val path: String,\n    val label: String? = null,\n    val optional: Boolean = false,\n) {\n\n    companion object {\n\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(path: String): YamlStartRecording {\n            return YamlStartRecording(\n                path = path,\n            )\n        }\n    }\n}\n\ndata class YamlStopRecording(\n    val label: String? = null,\n    val optional: Boolean = false,\n)\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlStopApp.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\n\ndata class YamlStopApp(\n    val appId: String? = null,\n    val label: String? = null,\n    val optional: Boolean = false,\n) {\n\n    companion object {\n\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(appId: String) = YamlStopApp(\n            appId = appId,\n        )\n\n    }\n\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlSwipe.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonFormat\nimport com.fasterxml.jackson.core.JsonParser\nimport com.fasterxml.jackson.core.TreeNode\nimport com.fasterxml.jackson.databind.DeserializationContext\nimport com.fasterxml.jackson.databind.JsonDeserializer\nimport com.fasterxml.jackson.databind.ObjectMapper\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize\nimport maestro.SwipeDirection\nimport maestro.directionValueOfOrNull\n\n@JsonDeserialize(using = YamlSwipeDeserializer::class)\ninterface YamlSwipe {\n    val duration: Long\n    val label: String?\n    val optional: Boolean\n    val waitToSettleTimeoutMs: Int?\n}\n\ndata class YamlSwipeDirection(\n    val direction: SwipeDirection,\n    override val duration: Long = DEFAULT_DURATION_IN_MILLIS,\n    override val label: String? = null,\n    override val optional: Boolean,\n    override val waitToSettleTimeoutMs: Int? = null,\n) : YamlSwipe\n\ndata class YamlCoordinateSwipe(\n    val start: String,\n    val end: String,\n    override val duration: Long = DEFAULT_DURATION_IN_MILLIS,\n    override val label: String? = null,\n    override val optional: Boolean,\n    override val waitToSettleTimeoutMs: Int? = null,\n) : YamlSwipe\n\ndata class YamlRelativeCoordinateSwipe(\n    val start: String,\n    val end: String,\n    override val duration: Long = DEFAULT_DURATION_IN_MILLIS,\n    override val label: String? = null,\n    override val optional: Boolean,\n    override val waitToSettleTimeoutMs: Int? = null,\n) : YamlSwipe\n\n@JsonDeserialize(`as` = YamlSwipeElement::class)\ndata class YamlSwipeElement(\n    @JsonFormat(with = [JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_PROPERTIES])\n    val direction: SwipeDirection,\n    val from: YamlElementSelectorUnion,\n    override val duration: Long = DEFAULT_DURATION_IN_MILLIS,\n    override val label: String? = null,\n    override val optional: Boolean,\n    override val waitToSettleTimeoutMs: Int? = null,\n) : YamlSwipe\n\nprivate const val DEFAULT_DURATION_IN_MILLIS = 400L\n\nclass YamlSwipeDeserializer : JsonDeserializer<YamlSwipe>() {\n\n    override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): YamlSwipe {\n        val mapper = (parser.codec as ObjectMapper)\n        val root: TreeNode = mapper.readTree(parser)\n        val input = root.fieldNames().asSequence().toList()\n        val duration = getDuration(root)\n        val label = getLabel(root)\n        val optional = getOptional(root)\n        val waitToSettleTimeoutMs = getWaitToSettleTimeoutMs(root)\n        when {\n            input.contains(\"start\") || input.contains(\"end\") -> {\n                check(root.get(\"direction\") == null) { \"You cannot provide direction with start/end swipe.\" }\n                check(root.get(\"start\") != null && root.get(\"end\") != null) {\n                    \"You need to provide both start and end coordinates, to swipe with coordinates\"\n                }\n                return resolveCoordinateSwipe(root, duration, label, optional, waitToSettleTimeoutMs)\n            }\n            input.contains(\"direction\") -> {\n                check(root.get(\"start\") == null && root.get(\"end\") == null) {\n                    \"You cannot provide start/end coordinates with directional swipe\"\n                }\n                val direction = root.get(\"direction\").toString().replace(\"\\\"\", \"\")\n                check(directionValueOfOrNull<SwipeDirection>(direction) != null) {\n                    \"Invalid direction provided to directional swipe: $direction. Direction can be either:\\n\" +\n                        \"1. RIGHT or right\\n\" +\n                        \"2. LEFT or left\\n\" +\n                        \"3. UP or up\\n\" +\n                        \"4. DOWN or down\"\n                }\n                val isDirectionalSwipe = isDirectionalSwipe(input)\n                return if (isDirectionalSwipe) {\n                    YamlSwipeDirection(SwipeDirection.valueOf(direction.uppercase()), duration, label, optional, waitToSettleTimeoutMs = waitToSettleTimeoutMs)\n                } else {\n                    mapper.convertValue(root, YamlSwipeElement::class.java)\n                }\n            }\n            else -> {\n                throw IllegalArgumentException(\n                    \"Swipe command takes either: \\n\" +\n                        \"\\t1. direction: Direction based swipe with: \\\"RIGHT\\\", \\\"LEFT\\\", \\\"UP\\\", or \\\"DOWN\\\" or \\n\" +\n                        \"\\t2. start and end: Coordinates based swipe with: \\\"start\\\" and \\\"end\\\" coordinates \\n\" +\n                        \"\\t3. direction and element to swipe directionally on element\\n\" +\n                        \"It seems you provided invalid input with: $input\"\n                )\n            }\n        }\n    }\n\n    private fun resolveCoordinateSwipe(\n        root: TreeNode,\n        duration: Long,\n        label: String?,\n        optional: Boolean,\n        waitToSettleTimeoutMs: Int?\n    ): YamlSwipe {\n        when {\n            isRelativeSwipe(root) -> {\n                val start = root.path(\"start\").toString().replace(\"\\\"\", \"\")\n                val end = root.path(\"end\").toString().replace(\"\\\"\", \"\")\n                check(start.contains(\"%\") && end.contains(\"%\")) {\n                    \"You need to provide start and end coordinates with %, Found: (${start}, ${end})\"\n                }\n                val startPoints = start\n                    .replace(\"%\", \"\")\n                    .split(\",\")\n                    .map { it.trim().toInt() }\n                val endPoints = end\n                    .replace(\"%\", \"\")\n                    .split(\",\")\n                    .map { it.trim().toInt() }\n                check(startPoints[0] in 0..100 && startPoints[1] in 0..100) {\n                    \"Invalid start point: $start should be between 0 to 100\"\n                }\n                check(endPoints[0] in 0..100 && endPoints[1] in 0..100) {\n                    \"Invalid start point: $end should be between 0 to 100\"\n                }\n\n                return YamlRelativeCoordinateSwipe(\n                    start,\n                    end,\n                    duration,\n                    label,\n                    optional,\n                    waitToSettleTimeoutMs = waitToSettleTimeoutMs\n                )\n            }\n            else -> return YamlCoordinateSwipe(\n                root.path(\"start\").toString().replace(\"\\\"\", \"\"),\n                root.path(\"end\").toString().replace(\"\\\"\", \"\"),\n                duration,\n                label,\n                optional,\n                waitToSettleTimeoutMs = waitToSettleTimeoutMs\n            )\n        }\n    }\n\n    private fun isRelativeSwipe(root: TreeNode): Boolean {\n        return root.get(\"start\").toString().contains(\"%\") || root.get(\"end\").toString().contains(\"%\")\n    }\n\n    private fun getDuration(root: TreeNode): Long {\n        return if (root.path(\"duration\").isMissingNode) {\n            DEFAULT_DURATION_IN_MILLIS\n        } else {\n            root.path(\"duration\").toString().replace(\"\\\"\", \"\").toLong()\n        }\n    }\n\n    private fun getLabel(root: TreeNode): String? {\n        return if (root.path(\"label\").isMissingNode) {\n            null\n        } else {\n            root.path(\"label\").toString().replace(\"\\\"\", \"\")\n        }\n    }\n\n    private fun getWaitToSettleTimeoutMs(root: TreeNode): Int? {\n        return if (root.path(\"waitToSettleTimeoutMs\").isMissingNode) {\n            null\n        } else {\n            root.path(\"waitToSettleTimeoutMs\").toString().replace(\"\\\"\", \"\").toIntOrNull()\n        }\n    }\n\n    private fun getOptional(root: TreeNode): Boolean {\n        return if (root.path(\"optional\").isMissingNode) {\n            false\n        } else {\n            root.path(\"optional\").toString().replace(\"\\\"\", \"\").toBoolean()\n        }\n    }\n\n    private fun isDirectionalSwipe(input: List<String>): Boolean {\n        return input == listOf(\"direction\", \"duration\") || input == listOf(\"direction\") ||\n                input == listOf(\"direction\", \"label\") || input == listOf(\"direction\", \"duration\", \"label\") ||\n                (input.contains(\"direction\") && !input.contains(\"from\"))\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlTakeScreenshot.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.fasterxml.jackson.annotation.JsonCreator\n\ndata class YamlTakeScreenshot(\n    val path: String,\n    val label: String? = null,\n    val cropOn: YamlElementSelectorUnion? = null,\n    val optional: Boolean = false,\n) {\n\n    companion object {\n\n        @JvmStatic\n        @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n        fun parse(path: String): YamlTakeScreenshot {\n            return YamlTakeScreenshot(\n                path = path,\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlToggleAirplaneMode.kt",
    "content": "package maestro.orchestra.yaml\n\ndata class YamlToggleAirplaneMode(\n    val label: String? = null,\n    val optional: Boolean = false,\n)\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlTravelCommand.kt",
    "content": "package maestro.orchestra.yaml\n\ndata class YamlTravelCommand(\n    val points: List<String>,\n    val speed: Double? = null,\n    val label: String? = null,\n    val optional: Boolean = false,\n)\n"
  },
  {
    "path": "maestro-orchestra/src/main/java/maestro/orchestra/yaml/YamlWaitForAnimationToEndCommand.kt",
    "content": "package maestro.orchestra.yaml\n\ndata class YamlWaitForAnimationToEndCommand(\n    val timeout: Long?,\n    val label: String? = null,\n    val optional: Boolean = false,\n)\n"
  },
  {
    "path": "maestro-orchestra/src/test/java/maestro/orchestra/CommandDescriptionTest.kt",
    "content": "package maestro.orchestra\n\nimport com.google.common.truth.Truth.assertThat\nimport maestro.js.GraalJsEngine\nimport maestro.js.RhinoJsEngine\nimport maestro.orchestra.yaml.junit.YamlFile\nimport maestro.orchestra.yaml.junit.YamlCommandsExtension\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.extension.ExtendWith\n\n@ExtendWith(YamlCommandsExtension::class)\ninternal class CommandDescriptionTest {\n\n    @Test\n    fun `original description contains raw command details`(\n        @YamlFile(\"029_command_descriptions.yaml\") commands: List<Command>\n    ) {\n        val jsEngine = GraalJsEngine(platform = \"ios\")\n        jsEngine.putEnv(\"username\", \"Alice\")\n\n        // Tap command with label\n        val tapCommand = commands[1] as TapOnElementCommand\n        assertThat(tapCommand.label).isEqualTo(\"Scroll to find the maybe-later button\")\n        assertThat(tapCommand.description()).isEqualTo(\"Scroll to find the maybe-later button\")  \n        assertThat(tapCommand.originalDescription).isEqualTo(\"Tap on \\\"maybe-later\\\"\")\n\n        // Input command without label\n        val inputCommand = commands[2] as InputTextCommand\n        val evaluatedInput = inputCommand.evaluateScripts(jsEngine)\n        assertThat(evaluatedInput.label).isNull()\n        assertThat(evaluatedInput.description()).isEqualTo(\"Input text Alice\")\n        assertThat(evaluatedInput.originalDescription).isEqualTo(\"Input text Alice\")\n\n        // Assert command without label\n        val assertCommand = commands[3] as AssertConditionCommand\n        val evaluatedAssert = assertCommand.evaluateScripts(jsEngine)\n        assertThat(evaluatedAssert.label).isNull()\n        assertThat(evaluatedAssert.description()).isEqualTo(\"Assert that \\\"Hello Alice\\\" is visible\")\n        assertThat(evaluatedAssert.originalDescription).isEqualTo(\"Assert that \\\"Hello Alice\\\" is visible\")\n\n        jsEngine.close()\n    }\n\n    @Test\n    fun `description uses label when available`(\n        @YamlFile(\"029_command_descriptions.yaml\") commands: List<Command>\n    ) {\n        val jsEngine = GraalJsEngine(platform = \"ios\")\n        jsEngine.putEnv(\"username\", \"Bob\")\n\n        // Tap command with label\n        val tapCommand = commands[1] as TapOnElementCommand\n        assertThat(tapCommand.label).isEqualTo(\"Scroll to find the maybe-later button\")\n        assertThat(tapCommand.description()).isEqualTo(\"Scroll to find the maybe-later button\") \n        assertThat(tapCommand.originalDescription).isEqualTo(\"Tap on \\\"maybe-later\\\"\")\n\n        // Input command without label\n        val inputCommand = commands[2] as InputTextCommand\n        val evaluatedInput = inputCommand.evaluateScripts(jsEngine)\n        assertThat(evaluatedInput.label).isNull()\n        assertThat(evaluatedInput.description()).isEqualTo(\"Input text Bob\")\n        assertThat(evaluatedInput.originalDescription).isEqualTo(\"Input text Bob\")\n\n        jsEngine.close()\n    }\n\n    @Test\n    fun `description evaluates script variables`(\n        @YamlFile(\"029_command_descriptions.yaml\") commands: List<Command>\n    ) {\n        val jsEngine = GraalJsEngine(platform = \"ios\")\n        jsEngine.putEnv(\"username\", \"Charlie\")\n\n        // Tap command with label (should be unchanged by evaluation)\n        val tapCommand = commands[1] as TapOnElementCommand\n        assertThat(tapCommand.originalDescription).isEqualTo(\"Tap on \\\"maybe-later\\\"\")\n        val evaluatedTap = tapCommand.evaluateScripts(jsEngine)\n        assertThat(evaluatedTap.label).isEqualTo(\"Scroll to find the maybe-later button\")\n        assertThat(evaluatedTap.description()).isEqualTo(\"Scroll to find the maybe-later button\")\n        assertThat(evaluatedTap.originalDescription).isEqualTo(\"Tap on \\\"maybe-later\\\"\")\n\n        // Input command with variable\n        val inputCommand = commands[2] as InputTextCommand\n        assertThat(inputCommand.originalDescription).isEqualTo(\"Input text \\${username}\")\n        val evaluatedInput = inputCommand.evaluateScripts(jsEngine)\n        assertThat(evaluatedInput.label).isNull()\n        assertThat(evaluatedInput.description()).isEqualTo(\"Input text Charlie\")\n        assertThat(evaluatedInput.originalDescription).isEqualTo(\"Input text Charlie\")\n\n        // Assert command with variable\n        val assertCommand = commands[3] as AssertConditionCommand\n        assertThat(assertCommand.originalDescription).isEqualTo(\"Assert that \\\"Hello \\${username}\\\" is visible\")\n        val evaluatedAssert = assertCommand.evaluateScripts(jsEngine)\n        assertThat(evaluatedAssert.label).isNull()\n        assertThat(evaluatedAssert.description()).isEqualTo(\"Assert that \\\"Hello Charlie\\\" is visible\")\n        assertThat(evaluatedAssert.originalDescription).isEqualTo(\"Assert that \\\"Hello Charlie\\\" is visible\")\n\n        jsEngine.close()\n    }\n\n    @Test\n    fun `TapOnElementCommand description includes relativePoint when provided`() {\n        // given\n        val command = TapOnElementCommand(\n            selector = ElementSelector(textRegex = \"Submit\"),\n            relativePoint = \"50%, 90%\",\n            label = \"Tap Submit Button\"\n        )\n\n        // when & then\n        assertThat(command.originalDescription).isEqualTo(\"Tap on \\\"Submit\\\" at 50%, 90%\")\n        assertThat(command.description()).isEqualTo(\"Tap Submit Button\")\n    }\n\n    @Test\n    fun `TapOnElementCommand description without relativePoint`() {\n        // given\n        val command = TapOnElementCommand(\n            selector = ElementSelector(textRegex = \"Cancel\"),\n            label = \"Tap Cancel Button\"\n        )\n\n        // when & then\n        assertThat(command.originalDescription).isEqualTo(\"Tap on \\\"Cancel\\\"\")\n        assertThat(command.description()).isEqualTo(\"Tap Cancel Button\")\n    }\n\n    @Test\n    fun `TapOnElementCommand description with absolute coordinates`() {\n        // given\n        val command = TapOnElementCommand(\n            selector = ElementSelector(idRegex = \"submit-btn\"),\n            relativePoint = \"10, 5\",\n            label = \"Tap Submit at specific position\"\n        )\n\n        // when & then\n        assertThat(command.originalDescription).isEqualTo(\"Tap on id: submit-btn at 10, 5\")\n        assertThat(command.description()).isEqualTo(\"Tap Submit at specific position\")\n    }\n\n    @Test\n    fun `TapOnElementCommand description with CSS selector`() {\n        // given\n        val command = TapOnElementCommand(\n            selector = ElementSelector(css = \".submit-button\"),\n            relativePoint = \"50%, 90%\",\n            label = \"Tap CSS element at specific position\"\n        )\n\n        // when & then\n        assertThat(command.originalDescription).isEqualTo(\"Tap on CSS: .submit-button at 50%, 90%\")\n        assertThat(command.description()).isEqualTo(\"Tap CSS element at specific position\")\n    }\n\n    @Test\n    fun `TapOnElementCommand description with size selector`() {\n        // given\n        val command = TapOnElementCommand(\n            selector = ElementSelector(size = ElementSelector.SizeSelector(width = 100, height = 50)),\n            relativePoint = \"25%, 75%\",\n            label = \"Tap sized element at specific position\"\n        )\n\n        // when & then\n        assertThat(command.originalDescription).isEqualTo(\"Tap on Size: 100x50 at 25%, 75%\")\n        assertThat(command.description()).isEqualTo(\"Tap sized element at specific position\")\n    }\n\n    @Test\n    fun `description evaluates scripts in labels - GraalJS`(\n        @YamlFile(\"029_command_descriptions.yaml\") commands: List<Command>\n    ) {\n        val jsEngine = GraalJsEngine(platform = \"ios\")\n\n        // Assert command with variable\n        val assertCommand = commands[4] as AssertConditionCommand\n        assertThat(assertCommand.originalDescription).isEqualTo(\"Assert that \\${true} is true\")\n        assertThat(assertCommand.label).isEqualTo(\"\\${\\\"Check that\\\".concat(\\\" \\\", \\\"true is still true\\\")}\")\n        val evaluatedAssert = assertCommand.evaluateScripts(jsEngine)\n        assertThat(evaluatedAssert.label).isEqualTo(\"Check that true is still true\")\n        assertThat(evaluatedAssert.description()).isEqualTo(\"Check that true is still true\")\n        assertThat(evaluatedAssert.originalDescription).isEqualTo(\"Assert that true is true\")\n\n        jsEngine.close()\n    }\n\n    @Test\n    fun `description evaluates scripts in labels - RhinoJS`(\n        @YamlFile(\"029_command_descriptions.yaml\") commands: List<Command>\n    ) {\n        val jsEngine = RhinoJsEngine(platform = \"ios\")\n\n        // Assert command with variable\n        val assertCommand = commands[4] as AssertConditionCommand\n        assertThat(assertCommand.originalDescription).isEqualTo(\"Assert that \\${true} is true\")\n        assertThat(assertCommand.label).isEqualTo(\"\\${\\\"Check that\\\".concat(\\\" \\\", \\\"true is still true\\\")}\")\n        val evaluatedAssert = assertCommand.evaluateScripts(jsEngine)\n        assertThat(evaluatedAssert.label).isEqualTo(\"Check that true is still true\")\n        assertThat(evaluatedAssert.description()).isEqualTo(\"Check that true is still true\")\n        assertThat(evaluatedAssert.originalDescription).isEqualTo(\"Assert that true is true\")\n\n        jsEngine.close()\n    }\n\n    @Test\n    fun `setOrientation description evaluates script values`() {\n        // given\n        val jsEngine = GraalJsEngine(platform = \"ios\")\n        jsEngine.putEnv(\"orientation\", \"LANDSCAPE_LEFT\")\n\n        val command = SetOrientationCommand(\n            orientation = $$\"${orientation}\"\n        )\n\n        // when & then\n        assertThat(command.originalDescription).isEqualTo($$\"Set orientation ${orientation}\")\n        val evaluatedCommand = command.evaluateScripts(jsEngine)\n        assertThat(evaluatedCommand.description()).isEqualTo(\"Set orientation LANDSCAPE_LEFT\")\n        assertThat(evaluatedCommand.originalDescription).isEqualTo(\"Set orientation LANDSCAPE_LEFT\")\n\n        jsEngine.close()\n    }\n}"
  },
  {
    "path": "maestro-orchestra/src/test/java/maestro/orchestra/LaunchArgumentsTest.kt",
    "content": "package maestro.orchestra\n\nimport com.google.common.truth.Truth.assertThat\nimport maestro.MaestroException\nimport maestro.orchestra.filter.LaunchArguments.toSanitizedLaunchArguments\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.assertThrows\n\nclass LaunchArgumentsTest {\n\n    @Test\n    fun `sanitize the launchArguments without spaces`() {\n        // given\n        val launchArguments = listOf(\"isCartPresent\", \"cartValue=2\", \"cartColor = orange\", \"cartCategory= sales\")\n\n        // when\n        val sanitizedLaunchArguments = launchArguments.toSanitizedLaunchArguments(\"com.example.appId\")\n\n        // then\n        assertThat(sanitizedLaunchArguments).containsExactly(\n            \"isCartPresent\",\n            \"cartValue=2\",\n            \"cartColor=orange\",\n            \"cartCategory=sales\"\n        )\n    }\n\n    @Test\n    fun `raises an exception when there are more than 1 pair`() {\n        // given\n        val launchArguments = listOf(\"argumentA=argumentB=argumentAValue\")\n\n        // when, then\n        assertThrows<MaestroException.UnableToLaunchApp> {\n            launchArguments.toSanitizedLaunchArguments(\"com.example.appId\")\n        }\n    }\n}"
  },
  {
    "path": "maestro-orchestra/src/test/java/maestro/orchestra/MaestroCommandSerializationTest.kt",
    "content": "package maestro.orchestra\n\nimport com.fasterxml.jackson.annotation.JsonInclude.Include\nimport com.fasterxml.jackson.databind.ObjectMapper\nimport com.fasterxml.jackson.module.kotlin.KotlinModule\nimport com.google.common.truth.Truth.assertThat\nimport maestro.device.DeviceOrientation\nimport maestro.KeyCode\nimport maestro.Point\nimport org.intellij.lang.annotations.Language\nimport org.junit.jupiter.api.Test\n\ninternal class MaestroCommandSerializationTest {\n    @Test\n    fun `serialize TapOnElementCommand`() {\n        // given\n        val command = MaestroCommand(\n            command = TapOnElementCommand(\n                selector = ElementSelector(textRegex = \"[A-f0-9]\"),\n                retryIfNoChange = false,\n                waitUntilVisible = true,\n                longPress = false,\n                label = \"My Tap\"\n            )\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"tapOnElement\" : {\n                \"selector\" : {\n                  \"textRegex\" : \"[A-f0-9]\",\n                  \"optional\" : false\n                },\n                \"retryIfNoChange\" : false,\n                \"waitUntilVisible\" : true,\n                \"longPress\" : false,\n                \"label\" : \"My Tap\",\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize TapOnElementCommand with relativePoint`() {\n        // given\n        val command = MaestroCommand(\n            command = TapOnElementCommand(\n                selector = ElementSelector(textRegex = \"Submit\"),\n                retryIfNoChange = false,\n                waitUntilVisible = true,\n                longPress = false,\n                relativePoint = \"50%, 90%\",\n                label = \"Tap Submit Button\"\n            )\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"tapOnElement\" : {\n                \"selector\" : {\n                  \"textRegex\" : \"Submit\",\n                  \"optional\" : false\n                },\n                \"retryIfNoChange\" : false,\n                \"waitUntilVisible\" : true,\n                \"longPress\" : false,\n                \"relativePoint\" : \"50%, 90%\",\n                \"label\" : \"Tap Submit Button\",\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize TapOnPointCommand`() {\n        // given\n        val command = MaestroCommand(\n            TapOnPointCommand(\n                x = 100,\n                y = 100,\n                retryIfNoChange = false,\n                waitUntilVisible = true,\n                longPress = false,\n                label = \"My TapOnPoint\"\n            )\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"tapOnPoint\" : {\n                \"x\" : 100,\n                \"y\" : 100,\n                \"retryIfNoChange\" : false,\n                \"waitUntilVisible\" : true,\n                \"longPress\" : false,\n                \"label\" : \"My TapOnPoint\",\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize TapOnPointV2Command`() {\n        // given\n        val command = MaestroCommand(\n            TapOnPointV2Command(\n                point = \"20,30\",\n                retryIfNoChange = false,\n                longPress = false,\n                label = \"My TapOnPointV2\"\n            )\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"tapOnPointV2Command\" : {\n                \"point\" : \"20,30\",\n                \"retryIfNoChange\" : false,\n                \"longPress\" : false,\n                \"label\" : \"My TapOnPointV2\",\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize ScrollCommand`() {\n        // given\n        val command = MaestroCommand(\n            ScrollCommand()\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"scrollCommand\" : {\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize SwipeCommand`() {\n        // given\n        val command = MaestroCommand(\n            SwipeCommand(\n                startPoint = Point(10, 10),\n                endPoint = Point(100, 100),\n            )\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"swipeCommand\" : {\n                \"startPoint\" : {\n                  \"x\" : 10,\n                  \"y\" : 10\n                },\n                \"endPoint\" : {\n                  \"x\" : 100,\n                  \"y\" : 100\n                },\n                \"duration\" : 400,\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize BackPressCommand`() {\n        // given\n        val command = MaestroCommand(\n            BackPressCommand()\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"backPressCommand\" : {\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize AssertCommand`() {\n        // given\n        val command = MaestroCommand(\n            AssertCommand(\n                ElementSelector(textRegex = \"[A-f0-9]\"),\n                ElementSelector(textRegex = \"\\\\s\")\n            )\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"assertCommand\" : {\n                \"visible\" : {\n                  \"textRegex\" : \"[A-f0-9]\",\n                  \"optional\" : false\n                },\n                \"notVisible\" : {\n                  \"textRegex\" : \"\\\\s\",\n                  \"optional\" : false\n                },\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize InputTextCommand`() {\n        // given\n        val command = MaestroCommand(\n            InputTextCommand(\"Hello, world!\")\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"inputTextCommand\" : {\n                \"text\" : \"Hello, world!\",\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize LaunchAppCommand`() {\n        // given\n        val command = MaestroCommand(\n            LaunchAppCommand(\"com.twitter.android\")\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"launchAppCommand\" : {\n                \"appId\" : \"com.twitter.android\",\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize SetPermissionsCommand`() {\n        // given\n        val command = MaestroCommand(\n            SetPermissionsCommand(\"com.twitter.android\", permissions = mapOf(\"all\" to \"deny\", \"notifications\" to \"unset\"))\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"setPermissionsCommand\" : {\n                \"appId\" : \"com.twitter.android\",\n                \"permissions\" : {\n                  \"all\" : \"deny\",\n                  \"notifications\" : \"unset\"\n                },\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize ApplyConfigurationCommand`() {\n        // given\n        val command = MaestroCommand(\n            ApplyConfigurationCommand(\n                MaestroConfig(\n                    appId = \"com.twitter.android\",\n                    name = \"Twitter\",\n                )\n            )\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"applyConfigurationCommand\" : {\n                \"config\" : {\n                  \"appId\" : \"com.twitter.android\",\n                  \"name\" : \"Twitter\",\n                  \"tags\" : [ ],\n                  \"ext\" : { },\n                  \"properties\" : { }\n                },\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize OpenLinkCommand`() {\n        // given\n        val command = MaestroCommand(\n            OpenLinkCommand(\"https://mobile.dev\")\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"openLinkCommand\" : {\n                \"link\" : \"https://mobile.dev\",\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize PressKeyCommand`() {\n        // given\n        val command = MaestroCommand(\n            PressKeyCommand(KeyCode.ENTER)\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"pressKeyCommand\" : {\n                \"code\" : \"ENTER\",\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize EraseTextCommand`() {\n        // given\n        val command = MaestroCommand(\n            EraseTextCommand(128)\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"eraseTextCommand\" : {\n                \"charactersToErase\" : 128,\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize TakeScreenshotCommand`() {\n        // given\n        val command = MaestroCommand(\n            TakeScreenshotCommand(\"screenshot.png\", cropOn = ElementSelector(textRegex = \"[A-f0-9]\"))\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"takeScreenshotCommand\" : {\n                \"path\" : \"screenshot.png\",\n                \"cropOn\" : {\n                  \"textRegex\" : \"[A-f0-9]\",\n                  \"optional\" : false\n                },\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize InputRandomCommand with text`() {\n        // given\n        val command = MaestroCommand(\n            InputRandomCommand(InputRandomType.TEXT, 2)\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"inputRandomTextCommand\" : {\n                \"inputType\" : \"TEXT\",\n                \"length\" : 2,\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize InputRandomCommand with number`() {\n        // given\n        val command = MaestroCommand(\n            InputRandomCommand(InputRandomType.NUMBER, 3)\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"inputRandomTextCommand\" : {\n                \"inputType\" : \"NUMBER\",\n                \"length\" : 3,\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize InputRandomCommand with email`() {\n        // given\n        val command = MaestroCommand(\n            InputRandomCommand(InputRandomType.TEXT_EMAIL_ADDRESS)\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"inputRandomTextCommand\" : {\n                \"inputType\" : \"TEXT_EMAIL_ADDRESS\",\n                \"length\" : 8,\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize InputRandomCommand with person name`() {\n        // given\n        val command = MaestroCommand(\n            InputRandomCommand(InputRandomType.TEXT_PERSON_NAME, optional = true)\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"inputRandomTextCommand\" : {\n                \"inputType\" : \"TEXT_PERSON_NAME\",\n                \"length\" : 8,\n                \"optional\" : true\n              }\n            }\n          \"\"\".trimIndent()\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize WaitForAnimationToEndCommand`() {\n        // given\n        val command = MaestroCommand(\n            WaitForAnimationToEndCommand(timeout = 9)\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"waitForAnimationToEndCommand\" : {\n                \"timeout\" : 9,\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    @Test\n    fun `serialize SetOrientationCommand`() {\n        // given\n        val command = MaestroCommand(\n            SetOrientationCommand(DeviceOrientation.PORTRAIT)\n        )\n\n        // when\n        val serializedCommandJson = command.toJson()\n        val deserializedCommand = objectMapper.readValue(serializedCommandJson, MaestroCommand::class.java)\n\n        // then\n        @Language(\"json\")\n        val expectedJson = \"\"\"\n            {\n              \"setOrientationCommand\" : {\n                \"orientation\" : \"PORTRAIT\",\n                \"optional\" : false\n              }\n            }\n          \"\"\".trimIndent()\n        assertThat(serializedCommandJson)\n            .isEqualTo(expectedJson)\n        assertThat(deserializedCommand)\n            .isEqualTo(command)\n    }\n\n    private fun MaestroCommand.toJson(): String =\n        objectMapper\n            .writerWithDefaultPrettyPrinter()\n            .writeValueAsString(this)\n\n    private val objectMapper = ObjectMapper()\n        .setSerializationInclusion(Include.NON_NULL)\n        .registerModule(KotlinModule.Builder().build())\n}\n"
  },
  {
    "path": "maestro-orchestra/src/test/java/maestro/orchestra/MaestroCommandTest.kt",
    "content": "package maestro.orchestra\n\nimport com.google.common.truth.Truth.assertThat\nimport org.junit.jupiter.api.Test\n\ninternal class MaestroCommandTest {\n    @Test\n    fun `description (no commands)`() {\n        // given\n        val maestroCommand = MaestroCommand(null)\n\n        // when\n        val description = maestroCommand.description()\n\n        // then\n        assertThat(description)\n            .isEqualTo(\"No op\")\n    }\n\n    @Test\n    fun `description (at least one command)`() {\n        // given\n        val maestroCommand = MaestroCommand(BackPressCommand())\n\n        // when\n        val description = maestroCommand.description()\n\n        // then\n        assertThat(description)\n            .isEqualTo(\"Press back\")\n    }\n\n    @Test\n    fun `description (with a label)`() {\n        // given\n        val maestroCommand = MaestroCommand(SetLocationCommand(\"12.5266\", \"78.2150\", \"Set Location to Test Laboratory\"))\n\n        // when\n        val description = maestroCommand.description()\n\n        // then\n        assertThat(description)\n            .isEqualTo(\"Set Location to Test Laboratory\")\n    }\n\n    @Test\n    fun `description (negative coordinates)`() {\n        // given\n        val maestroCommand = MaestroCommand(SetLocationCommand(\"-12.5266\", \"-78.2150\", \"Set location with negative coordinates\"))\n\n        // when\n        val description = maestroCommand.description()\n\n        // then\n        assertThat(description)\n            .isEqualTo(\"Set location with negative coordinates\")\n    }\n\n    @Test\n    fun `toString (no commands)`() {\n        // given\n        val maestroCommand = MaestroCommand(null)\n\n        // when\n        val toString = maestroCommand.toString()\n\n        // then\n        assertThat(toString).isEqualTo(\"MaestroCommand()\")\n    }\n\n    @Test\n    fun `toString (at least one command)`() {\n        // given\n        val command = BackPressCommand()\n        val maestroCommand = MaestroCommand(command)\n\n        // when\n        val toString = maestroCommand.toString()\n\n        // then\n        assertThat(toString).isEqualTo(\"MaestroCommand(backPressCommand=$command)\")\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/test/java/maestro/orchestra/android/AndroidMediaStoreTest.kt",
    "content": "package maestro.orchestra.android\n\nimport com.google.common.truth.Truth.assertThat\nimport dadb.Dadb\nimport kotlinx.coroutines.runBlocking\nimport maestro.Maestro\nimport maestro.drivers.AndroidDriver\nimport maestro.orchestra.Orchestra\nimport maestro.orchestra.yaml.YamlCommandReader\nimport org.junit.jupiter.api.Disabled\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.params.ParameterizedTest\nimport org.junit.jupiter.params.provider.MethodSource\nimport java.nio.file.Paths\n\n@Disabled\nclass AndroidMediaStoreTest {\n\n    @ParameterizedTest\n    @MethodSource(\"provideMediaFlows\")\n    fun `it should add media for android and its visible in google photos`(mediaMap: Map<String, String>) {\n        runBlocking {\n            // given\n            val expectedMediaPath = mediaMap.values.first()\n            val mediaFlow = mediaMap.keys.first()\n            val dadb = Dadb.create(\"localhost\", 5555)\n            val maestro = Maestro.android(AndroidDriver(dadb))\n            val maestroCommands = YamlCommandReader.readCommands(Paths.get(mediaFlow))\n\n            // when\n            Orchestra(maestro).runFlow(maestroCommands)\n\n            // then\n            val exists = dadb.fileExists(expectedMediaPath)\n            assertThat(exists).isTrue()\n        }\n    }\n\n    @Test\n    fun `it should add multiple media files`() {\n        runBlocking {\n            // given\n            val flowPath = Paths.get(\"./src/test/resources/media/android/add_multiple_media.yaml\")\n            val dadb = Dadb.create(\"localhost\", 5555)\n            val maestro = Maestro.android(AndroidDriver(dadb))\n            val maestroCommands = YamlCommandReader.readCommands(flowPath)\n\n            // when\n            Orchestra(maestro).runFlow(maestroCommands)\n\n            // then\n            val pngExists = dadb.fileExists(\"/sdcard/Pictures/android.png\")\n            val gifExists = dadb.fileExists(\"/sdcard/Pictures/android_gif.gif\")\n            val mp4Exists = dadb.fileExists(\"/sdcard/Movies/sample_video.mp4\")\n            assertThat(pngExists).isTrue()\n            assertThat(mp4Exists).isTrue()\n            assertThat(gifExists).isTrue()\n        }\n    }\n\n    companion object {\n        @JvmStatic\n        fun provideMediaFlows(): List<Map<String, String>> {\n            return listOf(\n                mapOf(\"./src/test/resources/media/android/add_media_png.yaml\" to \"/sdcard/Pictures/android.png\"),\n                mapOf(\"./src/test/resources/media/android/add_media_jpeg.yaml\" to \"/sdcard/Pictures/android_jpeg.jpeg\"),\n                mapOf(\"./src/test/resources/media/android/add_media_jpg.yaml\" to \"/sdcard/Pictures/android_jpg.jpg\"),\n                mapOf(\"./src/test/resources/media/android/add_media_gif.yaml\" to \"/sdcard/Pictures/android_gif.gif\"),\n                mapOf(\"./src/test/resources/media/android/add_media_mp4.yaml\" to \"/sdcard/Movies/sample_video.mp4\"),\n            )\n        }\n    }\n}"
  },
  {
    "path": "maestro-orchestra/src/test/java/maestro/orchestra/android/DadbExt.kt",
    "content": "package maestro.orchestra.android\n\nimport dadb.AdbShellResponse\nimport dadb.Dadb\nimport java.io.IOException\n\nfun Dadb.fileExists(filePath: String): Boolean {\n    return tryShell(\"[ -f $filePath ] && echo 1 || echo 0\").contains(\"1\")\n}\n\nprivate fun Dadb.tryShell(command: String): String {\n    val response: AdbShellResponse = try {\n        shell(command)\n    } catch (e: IOException) {\n        throw ShellException(e.message, e)\n    }\n    if (response.exitCode != 0) {\n        throw ShellException(response.allOutput)\n    }\n    return response.output\n}\n\nclass ShellException: Throwable {\n    internal constructor(message: String?) : super(message)\n    internal constructor(message: String?, e: Exception?) : super(message, e)\n}"
  },
  {
    "path": "maestro-orchestra/src/test/java/maestro/orchestra/util/AppMetadataAnalyzerTest.kt",
    "content": "package maestro.orchestra.util\n\nimport com.google.common.truth.Truth.assertThat\nimport maestro.device.Platform\nimport maestro.orchestra.validation.AppMetadata\nimport maestro.orchestra.validation.AppMetadataAnalyzer\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.assertThrows\nimport org.junit.jupiter.api.io.TempDir\nimport java.io.File\nimport java.util.zip.ZipEntry\nimport java.util.zip.ZipOutputStream\n\nclass AppMetadataAnalyzerTest {\n\n    @TempDir\n    lateinit var tempDir: File\n\n    // Minimal XML plist with the required keys for iOS detection\n    private fun iosPlistXml(\n        bundleId: String = \"com.example.app\",\n        platformName: String = \"iphonesimulator\",\n        minimumOSVersion: String = \"16.0\",\n        bundleName: String = \"ExampleApp\",\n        appVersion: String = \"1.0.0\",\n        bundleVersion: String = \"42\",\n    ) = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n    <key>CFBundleIdentifier</key><string>$bundleId</string>\n    <key>CFBundleName</key><string>$bundleName</string>\n    <key>DTPlatformName</key><string>$platformName</string>\n    <key>MinimumOSVersion</key><string>$minimumOSVersion</string>\n    <key>CFBundleShortVersionString</key><string>$appVersion</string>\n    <key>CFBundleVersion</key><string>$bundleVersion</string>\n</dict>\n</plist>\"\"\".toByteArray()\n\n    private fun makeIosZip(entryPath: String = \"Payload/ExampleApp.app/Info.plist\"): File {\n        val zip = File(tempDir, \"app.ipa\")\n        ZipOutputStream(zip.outputStream()).use { zos ->\n            zos.putNextEntry(ZipEntry(entryPath))\n            zos.write(iosPlistXml())\n            zos.closeEntry()\n        }\n        return zip\n    }\n\n    private fun makeWebJson(url: String = \"https://example.com\"): File {\n        return File(tempDir, \"web.json\").also { it.writeText(\"\"\"{\"url\":\"$url\"}\"\"\") }\n    }\n\n    private fun makeUnknownFile(): File {\n        return File(tempDir, \"unknown.bin\").also { it.writeBytes(ByteArray(64) { i -> i.toByte() }) }\n    }\n\n    // ---- validateAppFile ----\n\n    @Test\n    fun `validateAppFile returns AppMetadata for iOS zip`() {\n        val result = AppMetadataAnalyzer.validateAppFile(makeIosZip())\n        assertThat(result).isNotNull()\n        assertThat(result!!.platform).isEqualTo(Platform.IOS)\n        assertThat(result.appIdentifier).isEqualTo(\"com.example.app\")\n    }\n\n    @Test\n    fun `validateAppFile returns AppMetadata for web JSON`() {\n        val result = AppMetadataAnalyzer.validateAppFile(makeWebJson())\n        assertThat(result).isNotNull()\n        assertThat(result!!.platform).isEqualTo(Platform.WEB)\n        assertThat(result.appIdentifier).isEqualTo(\"https://example.com\")\n    }\n\n    @Test\n    fun `validateAppFile returns null for unrecognized file`() {\n        assertThat(AppMetadataAnalyzer.validateAppFile(makeUnknownFile())).isNull()\n    }\n\n    // ---- getIosAppMetadata ----\n\n    @Test\n    fun `getIosAppMetadata extracts bundleId and platformName from plist in zip`() {\n        val result = AppMetadataAnalyzer.getIosAppMetadata(makeIosZip())\n\n        assertThat(result).isNotNull()\n        assertThat(result!!.bundleId).isEqualTo(\"com.example.app\")\n        assertThat(result.platformName).isEqualTo(\"iphonesimulator\")\n        assertThat(result.minimumOSVersion).isEqualTo(\"16.0\")\n        assertThat(result.appVersion).isEqualTo(\"1.0.0\")\n        assertThat(result.bundleVersion).isEqualTo(\"42\")\n    }\n\n    @Test\n    fun `getIosAppMetadata ignores Watch bundle Info plist`() {\n        val zip = File(tempDir, \"app_watch.ipa\")\n        ZipOutputStream(zip.outputStream()).use { zos ->\n            // Real app plist at depth 2\n            zos.putNextEntry(ZipEntry(\"Payload/App.app/Info.plist\"))\n            zos.write(iosPlistXml(bundleId = \"com.real.app\"))\n            zos.closeEntry()\n            // Watch plist — should be ignored\n            zos.putNextEntry(ZipEntry(\"Payload/App.app/Watch/Companion.app/Info.plist\"))\n            zos.write(iosPlistXml(bundleId = \"com.watch.app\"))\n            zos.closeEntry()\n        }\n        val result = AppMetadataAnalyzer.getIosAppMetadata(zip)\n        assertThat(result!!.bundleId).isEqualTo(\"com.real.app\")\n    }\n\n    @Test\n    fun `getIosAppMetadata returns null for non-zip file`() {\n        assertThat(AppMetadataAnalyzer.getIosAppMetadata(makeUnknownFile())).isNull()\n    }\n\n    @Test\n    fun `getIosAppMetadata populates AppMetadata base fields correctly`() {\n        val result = AppMetadataAnalyzer.getIosAppMetadata(makeIosZip())\n\n        assertThat(result).isNotNull()\n        assertThat(result!!.appIdentifier).isEqualTo(\"com.example.app\")\n        assertThat(result.version).isEqualTo(\"1.0.0\")\n        assertThat(result.internalVersion).isEqualTo(\"42\")\n    }\n\n    @Test\n    fun `getIosAppMetadata defaults appVersion and bundleVersion to empty when missing`() {\n        val plistWithoutVersions = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n    <key>CFBundleIdentifier</key><string>com.example.app</string>\n    <key>CFBundleName</key><string>ExampleApp</string>\n    <key>DTPlatformName</key><string>iphonesimulator</string>\n    <key>MinimumOSVersion</key><string>16.0</string>\n</dict>\n</plist>\"\"\".toByteArray()\n\n        val zip = File(tempDir, \"no_versions.ipa\")\n        java.util.zip.ZipOutputStream(zip.outputStream()).use { zos ->\n            zos.putNextEntry(java.util.zip.ZipEntry(\"Payload/App.app/Info.plist\"))\n            zos.write(plistWithoutVersions)\n            zos.closeEntry()\n        }\n        val result = AppMetadataAnalyzer.getIosAppMetadata(zip)\n        assertThat(result).isNotNull()\n        assertThat(result!!.appVersion).isEqualTo(\"\")\n        assertThat(result.bundleVersion).isEqualTo(\"\")\n    }\n\n    // ---- getWebMetadata ----\n\n    @Test\n    fun `getWebMetadata returns url from JSON file`() {\n        val result = AppMetadataAnalyzer.getWebMetadata(makeWebJson(\"https://example.com\"))\n        assertThat(result!!.url).isEqualTo(\"https://example.com\")\n    }\n\n    @Test\n    fun `getWebMetadata returns null for non-JSON file`() {\n        assertThat(AppMetadataAnalyzer.getWebMetadata(makeUnknownFile())).isNull()\n    }\n\n    @Test\n    fun `validateAppFile rejects iOS app with iphoneos platform name`() {\n        val zip = File(tempDir, \"device.ipa\")\n        ZipOutputStream(zip.outputStream()).use { zos ->\n            zos.putNextEntry(ZipEntry(\"Payload/App.app/Info.plist\"))\n            zos.write(iosPlistXml(platformName = \"iphoneos\"))\n            zos.closeEntry()\n        }\n        assertThrows<IllegalArgumentException> {\n          AppMetadataAnalyzer.validateAppFile(zip)\n        }\n    }\n\n    @Test\n    fun `getIosAppMetadata picks shallowest plist when multiple app bundles present`() {\n        val zip = File(tempDir, \"multi.ipa\")\n        ZipOutputStream(zip.outputStream()).use { zos ->\n            // Deeper entry first\n            zos.putNextEntry(ZipEntry(\"Payload/Outer.app/Inner.app/Info.plist\"))\n            zos.write(iosPlistXml(bundleId = \"com.inner.app\"))\n            zos.closeEntry()\n            // Shallower entry — this should win\n            zos.putNextEntry(ZipEntry(\"Payload/Outer.app/Info.plist\"))\n            zos.write(iosPlistXml(bundleId = \"com.outer.app\"))\n            zos.closeEntry()\n        }\n        val result = AppMetadataAnalyzer.getIosAppMetadata(zip)\n        assertThat(result!!.bundleId).isEqualTo(\"com.outer.app\")\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/test/java/maestro/orchestra/util/ElementCoordinateUtilTest.kt",
    "content": "package maestro.orchestra.util\n\nimport com.google.common.truth.Truth.assertThat\nimport maestro.Bounds\nimport maestro.TreeNode\nimport maestro.UiElement\nimport maestro.orchestra.ElementSelector\nimport maestro.orchestra.TapOnElementCommand\nimport org.junit.jupiter.api.Test\n\n/**\n * Unit tests for element coordinate calculation utility functions.\n * Tests the business logic for calculating element-relative points.\n */\ninternal class ElementCoordinateUtilTest {\n\n    @Test\n    fun `test coordinate calculation with percentage coordinates`() {\n        // Given: Real TapOnElementCommand with relativePoint\n        val command = TapOnElementCommand(\n            selector = ElementSelector(textRegex = \"Test\"),\n            relativePoint = \"50%, 90%\"\n        )\n\n        // When: We test the real coordinate calculation\n        val testElement = createTestUiElement()\n        val calculatedPoint = calculateElementRelativePoint(testElement, command.relativePoint!!)\n\n        // Then: Verify the real calculation works correctly\n        assertThat(calculatedPoint.x).isEqualTo(150) // 100 + (100 * 50 / 100) = 150\n        assertThat(calculatedPoint.y).isEqualTo(190) // 100 + (100 * 90 / 100) = 190\n    }\n\n    @Test\n    fun `test coordinate calculation with absolute coordinates`() {\n        // Given: Real TapOnElementCommand with absolute coordinates\n        val command = TapOnElementCommand(\n            selector = ElementSelector(textRegex = \"Test\"),\n            relativePoint = \"25, 75\"\n        )\n\n        // When: We test the real coordinate calculation\n        val testElement = createTestUiElement()\n        val calculatedPoint = calculateElementRelativePoint(testElement, command.relativePoint!!)\n\n        // Then: Verify the real calculation works correctly\n        assertThat(calculatedPoint.x).isEqualTo(125) // 100 + 25 = 125\n        assertThat(calculatedPoint.y).isEqualTo(175) // 100 + 75 = 175\n    }\n\n    @Test\n    fun `test coordinate calculation edge cases`() {\n        val testElement = createTestUiElement()\n        \n        // Test 0%, 0% (top-left)\n        val topLeft = calculateElementRelativePoint(testElement, \"0%, 0%\")\n        assertThat(topLeft.x).isEqualTo(100) // 100 + (100 * 0 / 100) = 100\n        assertThat(topLeft.y).isEqualTo(100) // 100 + (100 * 0 / 100) = 100\n\n        // Test 100%, 100% (bottom-right)\n        val bottomRight = calculateElementRelativePoint(testElement, \"100%, 100%\")\n        assertThat(bottomRight.x).isEqualTo(200) // 100 + (100 * 100 / 100) = 200\n        assertThat(bottomRight.y).isEqualTo(200) // 100 + (100 * 100 / 100) = 200\n\n        // Test 25%, 75% (quarter from left, three-quarters from top)\n        val quarterPoint = calculateElementRelativePoint(testElement, \"25%, 75%\")\n        assertThat(quarterPoint.x).isEqualTo(125) // 100 + (100 * 25 / 100) = 125\n        assertThat(quarterPoint.y).isEqualTo(175) // 100 + (100 * 75 / 100) = 175\n    }\n\n    // Helper function to create a test UiElement (same structure as real Maestro)\n    private fun createTestUiElement(): UiElement {\n        val treeNode = TreeNode(\n            attributes = mutableMapOf(\n                \"bounds\" to \"[100,100][200,200]\",\n                \"text\" to \"Test Button\",\n                \"resource-id\" to \"test-button\",\n                \"class\" to \"android.widget.Button\"\n            )\n        )\n        return UiElement(\n            treeNode = treeNode,\n            bounds = Bounds(x = 100, y = 100, width = 100, height = 100)\n        )\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/test/java/maestro/orchestra/validation/AppValidatorTest.kt",
    "content": "package maestro.orchestra.validation\n\nimport com.google.common.truth.Truth.assertThat\nimport maestro.device.DeviceSpec\nimport maestro.device.DeviceSpecRequest\nimport maestro.device.Platform\nimport maestro.orchestra.validation.AndroidAppMetadata\nimport maestro.orchestra.validation.IosAppMetadata\nimport maestro.orchestra.validation.WebAppMetadata\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.assertThrows\nimport java.io.File\n\nclass AppValidatorTest {\n\n    private val androidResult = AndroidAppMetadata(\n        name = \"Example\",\n        packageId = \"com.example.app\",\n        supportedArchitectures = listOf(\"arm64-v8a\"),\n        versionName = \"1.0\",\n        versionCode = 1L,\n    )\n    private val iosResult = IosAppMetadata(\n        name = \"Example iOS\",\n        bundleId = \"com.example.ios\",\n        platformName = \"iphonesimulator\",\n        minimumOSVersion = \"16.0\",\n        appVersion = \"1.0\",\n        bundleVersion = \"1\",\n    )\n    private val webResult = WebAppMetadata(url = \"https://example.com\")\n\n    @Test\n    fun `validates local app file successfully`() {\n        val appFile = File(\"app.apk\")\n        val validator = AppValidator(appFileValidator = { androidResult })\n\n        val result = validator.validate(appFile = appFile, appBinaryId = null)\n\n        assertThat(result).isEqualTo(androidResult)\n    }\n\n    @Test\n    fun `throws UnrecognizedAppFile when local app file validation returns null`() {\n        val validator = AppValidator(appFileValidator = { null })\n\n        assertThrows<AppValidationException.UnrecognizedAppFile> {\n            validator.validate(appFile = File(\"unknown.bin\"), appBinaryId = null)\n        }\n    }\n\n    @Test\n    fun `validates app binary id from provider successfully`() {\n        val validator = AppValidator(\n            appFileValidator = { null },\n            appBinaryInfoProvider = { id ->\n                AppValidator.AppBinaryInfoResult(id, \"Android\", \"com.example.app\")\n            },\n        )\n\n        val result = validator.validate(appFile = null, appBinaryId = \"bin_123\")\n\n        assertThat(result.platform).isEqualTo(Platform.ANDROID)\n        assertThat(result.appIdentifier).isEqualTo(\"com.example.app\")\n    }\n\n    @Test\n    fun `validates iOS app binary id from provider`() {\n        val validator = AppValidator(\n            appFileValidator = { null },\n            appBinaryInfoProvider = { id ->\n                AppValidator.AppBinaryInfoResult(id, \"iOS\", \"com.example.ios\")\n            },\n        )\n\n        val result = validator.validate(appFile = null, appBinaryId = \"bin_456\")\n\n        assertThat(result.platform).isEqualTo(Platform.IOS)\n        assertThat(result.appIdentifier).isEqualTo(\"com.example.ios\")\n    }\n\n    @Test\n    fun `throws UnsupportedPlatform when provider returns unknown platform`() {\n        val validator = AppValidator(\n            appFileValidator = { null },\n            appBinaryInfoProvider = { id ->\n                AppValidator.AppBinaryInfoResult(id, \"Symbian\", \"com.example.app\")\n            },\n        )\n\n        val error = assertThrows<AppValidationException.UnsupportedPlatform> {\n            validator.validate(appFile = null, appBinaryId = \"bin_bad\")\n        }\n        assertThat(error.platform).isEqualTo(\"Symbian\")\n    }\n\n    @Test\n    fun `throws MissingAppSource when appBinaryId given but no provider`() {\n        val validator = AppValidator(appFileValidator = { null })\n\n        assertThrows<AppValidationException.MissingAppSource> {\n            validator.validate(appFile = null, appBinaryId = \"bin_123\")\n        }\n    }\n\n    @Test\n    fun `validates web flow via manifest provider`() {\n        val webManifest = File.createTempFile(\"manifest\", \".json\").also {\n            it.writeText(\"\"\"{\"url\": \"https://example.com\"}\"\"\")\n            it.deleteOnExit()\n        }\n\n        val validator = AppValidator(\n            appFileValidator = { webResult },\n            webManifestProvider = { webManifest },\n        )\n\n        val result = validator.validate(appFile = null, appBinaryId = null)\n\n        assertThat(result.platform).isEqualTo(Platform.WEB)\n        assertThat(result.appIdentifier).isEqualTo(\"https://example.com\")\n    }\n\n    @Test\n    fun `throws MissingAppSource when no app file, no binary id, and no web manifest provider`() {\n        val validator = AppValidator(appFileValidator = { null })\n\n        assertThrows<AppValidationException.MissingAppSource> {\n            validator.validate(appFile = null, appBinaryId = null)\n        }\n    }\n\n    @Test\n    fun `throws UnrecognizedAppFile when web manifest provider returns null`() {\n        val validator = AppValidator(\n            appFileValidator = { null },\n            webManifestProvider = { null },\n        )\n\n        assertThrows<AppValidationException.UnrecognizedAppFile> {\n            validator.validate(appFile = null, appBinaryId = null)\n        }\n    }\n\n    // ---- validateDeviceCompatibility tests ----\n\n    private fun iosDeviceSpec(os: String = \"iOS-18-2\"): DeviceSpec =\n        DeviceSpec.fromRequest(DeviceSpecRequest.Ios(os = os))\n\n    private fun androidDeviceSpec(os: String = \"android-33\"): DeviceSpec =\n        DeviceSpec.fromRequest(DeviceSpecRequest.Android(os = os))\n\n    private fun webDeviceSpec(): DeviceSpec =\n        DeviceSpec.fromRequest(DeviceSpecRequest.Web())\n\n    @Test\n    fun `validateDeviceCompatibility passes when iOS app min version is below device version`() {\n        val appFile = File(\"app.ipa\")\n        val validator = AppValidator(\n            appFileValidator = { iosResult },\n            iosMinOSVersionProvider = { AppValidator.IosMinOSVersion(major = 16, full = \"16.0\") },\n        )\n\n        validator.validateDeviceCompatibility(\n            appFile = appFile,\n            deviceSpec = iosDeviceSpec(\"iOS-18-2\"),\n        )\n    }\n\n    @Test\n    fun `validateDeviceCompatibility passes when iOS app min version equals device version`() {\n        val appFile = File(\"app.ipa\")\n        val validator = AppValidator(\n            appFileValidator = { iosResult },\n            iosMinOSVersionProvider = { AppValidator.IosMinOSVersion(major = 18, full = \"18.0\") },\n        )\n\n        validator.validateDeviceCompatibility(\n            appFile = appFile,\n            deviceSpec = iosDeviceSpec(\"iOS-18-2\"),\n        )\n    }\n\n    @Test\n    fun `validateDeviceCompatibility throws IncompatibleIOSVersion when app requires higher OS`() {\n        val appFile = File(\"app.ipa\")\n        val validator = AppValidator(\n            appFileValidator = { iosResult },\n            iosMinOSVersionProvider = { AppValidator.IosMinOSVersion(major = 18, full = \"18.0\") },\n        )\n\n        val error = assertThrows<AppValidationException.IncompatibleIOSVersion> {\n            validator.validateDeviceCompatibility(\n                appFile = appFile,\n                deviceSpec = iosDeviceSpec(\"iOS-16-2\"),\n            )\n        }\n        assertThat(error.appMinVersion).isEqualTo(\"18.0\")\n        assertThat(error.deviceOsVersion).isEqualTo(16)\n    }\n\n    @Test\n    fun `validateDeviceCompatibility skips iOS check when appFile is null`() {\n        val validator = AppValidator(\n            appFileValidator = { iosResult },\n            iosMinOSVersionProvider = { AppValidator.IosMinOSVersion(major = 99, full = \"99.0\") },\n        )\n\n        // Should not throw even though min version (99) > device version (18)\n        validator.validateDeviceCompatibility(\n            appFile = null,\n            deviceSpec = iosDeviceSpec(\"iOS-18-2\"),\n        )\n    }\n\n    @Test\n    fun `validateDeviceCompatibility skips iOS check when provider returns null`() {\n        val validator = AppValidator(\n            appFileValidator = { iosResult },\n            iosMinOSVersionProvider = { null },\n        )\n\n        // Should not throw — provider can't extract min OS version from this binary\n        validator.validateDeviceCompatibility(\n            appFile = File(\"app.ipa\"),\n            deviceSpec = iosDeviceSpec(\"iOS-16-2\"),\n        )\n    }\n\n    @Test\n    fun `validateDeviceCompatibility skips iOS check when no provider injected`() {\n        val validator = AppValidator(appFileValidator = { iosResult })\n\n        // Should not throw — no iosMinOSVersionProvider\n        validator.validateDeviceCompatibility(\n            appFile = File(\"app.ipa\"),\n            deviceSpec = iosDeviceSpec(\"iOS-16-2\"),\n        )\n    }\n\n    @Test\n    fun `validateDeviceCompatibility is a no-op for Android`() {\n        val validator = AppValidator(appFileValidator = { androidResult })\n\n        // Should not throw — Android API level validation is handled by DeviceSpecValidator\n        validator.validateDeviceCompatibility(\n            appFile = File(\"app.apk\"),\n            deviceSpec = androidDeviceSpec(\"android-33\"),\n        )\n    }\n\n    @Test\n    fun `validateDeviceCompatibility is a no-op for Web`() {\n        val validator = AppValidator(appFileValidator = { webResult })\n\n        // Should not throw for any configuration\n        validator.validateDeviceCompatibility(\n            appFile = null,\n            deviceSpec = webDeviceSpec(),\n        )\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/test/java/maestro/orchestra/validation/WorkspaceValidatorTest.kt",
    "content": "package maestro.orchestra.validation\n\nimport com.github.michaelbull.result.Err\nimport com.github.michaelbull.result.Ok\nimport com.google.common.truth.Truth.assertThat\nimport io.mockk.every\nimport io.mockk.mockkObject\nimport io.mockk.unmockkObject\nimport maestro.orchestra.WorkspaceConfig\nimport maestro.orchestra.workspace.WorkspaceValidationError\nimport maestro.orchestra.workspace.WorkspaceValidationResult\nimport org.junit.jupiter.api.AfterEach\nimport org.junit.jupiter.api.BeforeEach\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.assertThrows\nimport java.io.File\nimport maestro.orchestra.workspace.WorkspaceValidator as OrchestraWorkspaceValidator\n\nclass WorkspaceValidatorTest {\n\n    private lateinit var validator: WorkspaceValidator\n\n    private val dummyWorkspace = File(\"workspace.zip\")\n    private val dummyAppId = \"com.example.app\"\n    private val dummyEnv = emptyMap<String, String>()\n    private val dummyIncludeTags = emptyList<String>()\n    private val dummyExcludeTags = emptyList<String>()\n\n    @BeforeEach\n    fun setUp() {\n        validator = WorkspaceValidator()\n        mockkObject(OrchestraWorkspaceValidator)\n    }\n\n    @AfterEach\n    fun tearDown() {\n        unmockkObject(OrchestraWorkspaceValidator)\n    }\n\n    @Test\n    fun `returns WorkspaceValidationResult on success`() {\n        val expectedResult = WorkspaceValidationResult(\n            workspaceConfig = WorkspaceConfig(),\n            flows = emptyList(),\n        )\n        every {\n            OrchestraWorkspaceValidator.validate(\n                workspace = dummyWorkspace,\n                appId = dummyAppId,\n                envParameters = dummyEnv,\n                includeTags = dummyIncludeTags,\n                excludeTags = dummyExcludeTags,\n            )\n        } returns Ok(expectedResult)\n\n        val result = validator.validate(\n            workspace = dummyWorkspace,\n            appId = dummyAppId,\n            env = dummyEnv,\n            includeTags = dummyIncludeTags,\n            excludeTags = dummyExcludeTags,\n        )\n\n        assertThat(result).isEqualTo(expectedResult)\n    }\n\n    @Test\n    fun `throws WorkspaceValidationException for NoFlowsMatchingAppId`() {\n        every {\n            OrchestraWorkspaceValidator.validate(any(), any(), any(), any(), any())\n        } returns Err(WorkspaceValidationError.NoFlowsMatchingAppId(\"com.example.app\", setOf(\"com.other.app\")))\n\n        val error = assertThrows<WorkspaceValidationException> {\n            validator.validate(dummyWorkspace, dummyAppId, dummyEnv, dummyIncludeTags, dummyExcludeTags)\n        }\n        assertThat(error.message).contains(\"No flows in workspace match app ID 'com.example.app'\")\n        assertThat(error.message).contains(\"com.other.app\")\n    }\n\n    @Test\n    fun `throws WorkspaceValidationException with none when NoFlowsMatchingAppId has empty found ids`() {\n        every {\n            OrchestraWorkspaceValidator.validate(any(), any(), any(), any(), any())\n        } returns Err(WorkspaceValidationError.NoFlowsMatchingAppId(\"com.example.app\", emptySet()))\n\n        val error = assertThrows<WorkspaceValidationException> {\n            validator.validate(dummyWorkspace, dummyAppId, dummyEnv, dummyIncludeTags, dummyExcludeTags)\n        }\n        assertThat(error.message).contains(\"none\")\n    }\n\n    @Test\n    fun `throws WorkspaceValidationException for NameConflict`() {\n        every {\n            OrchestraWorkspaceValidator.validate(any(), any(), any(), any(), any())\n        } returns Err(WorkspaceValidationError.NameConflict(\"loginFlow\"))\n\n        val error = assertThrows<WorkspaceValidationException> {\n            validator.validate(dummyWorkspace, dummyAppId, dummyEnv, dummyIncludeTags, dummyExcludeTags)\n        }\n        assertThat(error.message).contains(\"Duplicate flow name 'loginFlow'\")\n    }\n\n    @Test\n    fun `throws WorkspaceValidationException for SyntaxError`() {\n        every {\n            OrchestraWorkspaceValidator.validate(any(), any(), any(), any(), any())\n        } returns Err(WorkspaceValidationError.SyntaxError(\"unexpected token\"))\n\n        val error = assertThrows<WorkspaceValidationException> {\n            validator.validate(dummyWorkspace, dummyAppId, dummyEnv, dummyIncludeTags, dummyExcludeTags)\n        }\n        assertThat(error.message).contains(\"Workspace syntax error: unexpected token\")\n    }\n\n    @Test\n    fun `throws WorkspaceValidationException for InvalidFlowFile`() {\n        every {\n            OrchestraWorkspaceValidator.validate(any(), any(), any(), any(), any())\n        } returns Err(WorkspaceValidationError.InvalidFlowFile(\"bad flow content\"))\n\n        val error = assertThrows<WorkspaceValidationException> {\n            validator.validate(dummyWorkspace, dummyAppId, dummyEnv, dummyIncludeTags, dummyExcludeTags)\n        }\n        assertThat(error.message).contains(\"bad flow content\")\n    }\n\n    @Test\n    fun `throws WorkspaceValidationException for EmptyWorkspace`() {\n        every {\n            OrchestraWorkspaceValidator.validate(any(), any(), any(), any(), any())\n        } returns Err(WorkspaceValidationError.EmptyWorkspace)\n\n        val error = assertThrows<WorkspaceValidationException> {\n            validator.validate(dummyWorkspace, dummyAppId, dummyEnv, dummyIncludeTags, dummyExcludeTags)\n        }\n        assertThat(error.message).contains(\"Workspace contains no flows\")\n    }\n\n    @Test\n    fun `throws WorkspaceValidationException for MissingLaunchApp`() {\n        every {\n            OrchestraWorkspaceValidator.validate(any(), any(), any(), any(), any())\n        } returns Err(WorkspaceValidationError.MissingLaunchApp(listOf(\"flow1\", \"flow2\")))\n\n        val error = assertThrows<WorkspaceValidationException> {\n            validator.validate(dummyWorkspace, dummyAppId, dummyEnv, dummyIncludeTags, dummyExcludeTags)\n        }\n        assertThat(error.message).contains(\"flow1, flow2\")\n        assertThat(error.message).contains(\"missing a launchApp command\")\n    }\n\n    @Test\n    fun `throws WorkspaceValidationException for InvalidWorkspaceFile`() {\n        every {\n            OrchestraWorkspaceValidator.validate(any(), any(), any(), any(), any())\n        } returns Err(WorkspaceValidationError.InvalidWorkspaceFile)\n\n        val error = assertThrows<WorkspaceValidationException> {\n            validator.validate(dummyWorkspace, dummyAppId, dummyEnv, dummyIncludeTags, dummyExcludeTags)\n        }\n        assertThat(error.message).contains(\"Workspace is not a valid zip archive\")\n    }\n\n    @Test\n    fun `throws WorkspaceValidationException for GenericError`() {\n        every {\n            OrchestraWorkspaceValidator.validate(any(), any(), any(), any(), any())\n        } returns Err(WorkspaceValidationError.GenericError(\"something went wrong\"))\n\n        val error = assertThrows<WorkspaceValidationException> {\n            validator.validate(dummyWorkspace, dummyAppId, dummyEnv, dummyIncludeTags, dummyExcludeTags)\n        }\n        assertThat(error.message).contains(\"something went wrong\")\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/test/java/maestro/orchestra/workspace/ExecutionOrderPlannerTest.kt",
    "content": "package maestro.orchestra.workspace\n\nimport com.google.common.truth.Truth.assertThat\nimport maestro.orchestra.workspace.ExecutionOrderPlanner.getFlowsToRunInSequence\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.assertThrows\nimport java.lang.IllegalStateException\nimport kotlin.io.path.Path\n\ninternal class ExecutionOrderPlannerTest {\n\n    @Test\n    fun `if the paths are already in sequence it should return the sequence`() {\n        val paths = mapOf(\"flowA\" to Path(\"flowA\"), \"flowB\" to Path(\"flowB\"))\n        val flowOrder = listOf(\"flowA\", \"flowB\")\n        val expected = listOf(Path(\"flowA\"), Path(\"flowB\"))\n\n        val result = getFlowsToRunInSequence(paths, flowOrder)\n        assertThat(result).isEqualTo(expected)\n    }\n\n    @Test\n    fun `if the paths are not in sequence it should return in the correct sequence`() {\n        val paths = mapOf(\"flowA\" to Path(\"flowA\"), \"flowB\" to Path(\"flowB\"))\n        val flowOrder = listOf(\"flowB\", \"flowA\")\n        val expected = listOf(Path(\"flowB\"), Path(\"flowA\"))\n\n        val result = getFlowsToRunInSequence(paths, flowOrder)\n        assertThat(result).isEqualTo(expected)\n    }\n\n    @Test\n    fun `if there are more paths then the sequence it should return in only those in the sequence in the correct order`() {\n        val paths = mapOf(\"flowA\" to Path(\"flowA\"), \"flowB\" to Path(\"flowB\"), \"flowC\" to Path(\"flowC\"))\n        val flowOrder = listOf(\"flowC\", \"flowA\")\n        val expected = listOf(Path(\"flowC\"), Path(\"flowA\"))\n\n        val result = getFlowsToRunInSequence(paths, flowOrder)\n        assertThat(result).isEqualTo(expected)\n    }\n\n    @Test\n    fun `if there are less paths then the sequence it should return in only those in the sequence in the correct order if the missing are after all the paths in the sequence`() {\n        val paths = mapOf(\"flowA\" to Path(\"flowA\"), \"flowB\" to Path(\"flowB\"), \"flowC\" to Path(\"flowC\"))\n        val flowOrder = listOf(\"flowC\", \"flowA\", \"flowD\")\n        val expected = listOf(Path(\"flowC\"), Path(\"flowA\"))\n\n        val result = getFlowsToRunInSequence(paths, flowOrder)\n        assertThat(result).isEqualTo(expected)\n    }\n\n    @Test\n    fun `if there are less paths then the sequence it should return error if the missing are not after all present`() {\n        val pathsX = mapOf(\"flowA\" to Path(\"flowA\"), \"flowB\" to Path(\"flowB\"), \"flowC\" to Path(\"flowC\"))\n        val flowOrderX = listOf(\"flowG\", \"flowC\", \"flowD\", \"flowA\")\n\n        val pathsY = mapOf(\"flowA\" to Path(\"flowA\"))\n        val flowOrderY = listOf(\"flowE\", \"flowC\", \"flowD\", \"flowA\")\n\n        val exceptionX = assertThrows<IllegalStateException> {\n            getFlowsToRunInSequence(pathsX, flowOrderX)\n        }\n        val exceptionY = assertThrows<IllegalStateException> {\n            getFlowsToRunInSequence(pathsY, flowOrderY)\n        }\n        assertThat(exceptionX.message).isEqualTo(\"Could not find flows needed for execution in order: flowG, flowD\")\n        assertThat(exceptionY.message).isEqualTo(\"Could not find flows needed for execution in order: flowE, flowC, flowD\")\n    }\n\n    @Test\n    fun `if the sequence is empty it should return an empty list`() {\n        val paths = mapOf(\"flowA\" to Path(\"flowA\"), \"flowB\" to Path(\"flowB\"), \"flowC\" to Path(\"flowC\"))\n        val flowOrder = emptyList<String>()\n        val expected = emptyList<String>()\n\n        val result = getFlowsToRunInSequence(paths, flowOrder)\n        assertThat(result).isEqualTo(expected)\n    }\n\n    @Test\n    fun `if no paths are present in the sequence it should return an empty list`() {\n        val paths = mapOf(\"flowA\" to Path(\"flowA\"), \"flowB\" to Path(\"flowB\"), \"flowC\" to Path(\"flowC\"))\n        val flowOrder = listOf(\"flowE\", \"flowD\")\n        val expected = emptyList<String>()\n\n        val result = getFlowsToRunInSequence(paths, flowOrder)\n        assertThat(result).isEqualTo(expected)\n    }\n\n}\n"
  },
  {
    "path": "maestro-orchestra/src/test/java/maestro/orchestra/workspace/WorkspaceExecutionPlannerErrorsTest.kt",
    "content": "package maestro.orchestra.workspace\n\nimport com.google.common.truth.Truth.assertThat\nimport com.google.common.truth.Truth.assertWithMessage\nimport maestro.orchestra.error.ValidationError\nimport org.junit.jupiter.params.ParameterizedTest\nimport org.junit.jupiter.params.provider.Arguments\nimport org.junit.jupiter.params.provider.MethodSource\nimport java.io.File\nimport java.nio.file.Path\nimport java.nio.file.Paths\nimport kotlin.io.path.*\n\n/**\n * How to add a new error test case:\n *\n * 1. Create a new workspace directory eg. resources/workspaces/e###_test_case_name\n * 2. Run GENERATE_ERRORS=true ./gradlew :maestro-orchestra:test --tests \"maestro.orchestra.workspace.WorkspaceExecutionPlannerErrorsTest\"\n * 3. Error messages for all test cases will be regenerated and saved to resources/workspaces/e###_test_case_name/error.txt\n * 4. Manually validate that the generated error messages are correct.\n * 5. Run the tests without the GENERATE_ERRORS env var and ensure this passes: ./gradlew :maestro-orchestra:test --tests \"maestro.orchestra.workspace.WorkspaceExecutionPlannerErrorsTest\"\n * 6. Commit your changes.\n *\n *\n * Test case files:\n *\n *   workspace/: The workspace directory passed into WorkspaceExecutionPlanner.plan()\n *   error.txt: The expected error message for the test case\n *   includeTags.txt: Include tags (one per line) to be passed into WorkspaceExecutionPlanner.plan()\n *   excludeTags.txt: Exclude tags (one per line) to be passed into WorkspaceExecutionPlanner.plan()\n *   singleFlow.txt: Indicates that the test should pass the path to the specified flow file instead of the workspace/ directory\n *\n */\ninternal class WorkspaceExecutionPlannerErrorsTest {\n\n    @ParameterizedTest(name = \"{0}\")\n    @MethodSource(\"provideTestCases\")\n    fun test(testCaseName: String, originalPath: Path) {\n        val workspace = File(\"/tmp/WorkspaceExecutionPlannerErrorsTest_workspace\").apply { deleteRecursively() }\n        originalPath.toFile().copyRecursively(workspace)\n        val path = workspace.toPath()\n\n        val workspacePath = path.resolve(\"workspace\")\n        val singleFlowFilePath = path.resolve(\"singleFlow.txt\").takeIf { it.isRegularFile() }?.readText()\n        val expectedErrorPath = path.resolve(\"error.txt\")\n        val expectedError = expectedErrorPath.takeIf { it.isRegularFile() }?.readText()\n        val includeTags = path.resolve(\"includeTags.txt\").takeIf { it.isRegularFile() }?.readLines() ?: emptyList()\n        val excludeTags = path.resolve(\"excludeTags.txt\").takeIf { it.isRegularFile() }?.readLines() ?: emptyList()\n        try {\n            val inputPath = singleFlowFilePath?.let { workspacePath.resolve(it) } ?: workspacePath\n            WorkspaceExecutionPlanner.plan(\n                input = setOf(inputPath),\n                includeTags = includeTags,\n                excludeTags = excludeTags,\n                config = null,\n            )\n            assertWithMessage(\"No exception was not thrown. Ensure this test case triggers a ValidationError.\").fail()\n        } catch (e: Exception) {\n            if (e !is ValidationError) {\n                e.printStackTrace()\n                return assertWithMessage(\"An exception was thrown but it was not a ValidationError. Ensure this test case triggers a ValidationError. Found: ${e::class.java.name}\").fail()\n            }\n\n            val actualError = e.message\n\n            if (System.getenv(\"GENERATE_ERRORS\") == \"true\") {\n                originalPath.resolve(\"error.txt\").writeText(actualError)\n            } else if (expectedError != actualError) {\n                System.err.println(\"Expected and actual error messages differ. If actual error message is preferred, rerun this test with GENERATE_ERRORS=true\")\n                assertThat(actualError).isEqualTo(expectedError)\n            }\n        }\n    }\n\n    companion object {\n\n        private val PROJECT_DIR = System.getenv(\"PROJECT_DIR\")?.let { Paths.get(it).absolutePathString().trimEnd('/') } ?: throw RuntimeException(\"Enable to determine project directory\")\n\n        @JvmStatic\n        private fun provideTestCases(): List<Arguments> {\n            return Paths.get(PROJECT_DIR)\n                .resolve(\"src/test/resources/workspaces\")\n                .listDirectoryEntries()\n                .filter { it.isDirectory() && it.name.startsWith(\"e\") }\n                .map { Arguments.of(it.name, it) }\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/test/java/maestro/orchestra/workspace/WorkspaceExecutionPlannerTest.kt",
    "content": "package maestro.orchestra.workspace\n\nimport com.google.common.truth.Truth.assertThat\nimport maestro.orchestra.WorkspaceConfig\nimport maestro.orchestra.WorkspaceConfig.*\nimport org.junit.jupiter.api.Test\nimport java.nio.file.Path\nimport java.nio.file.Paths\n\ninternal class WorkspaceExecutionPlannerTest {\n\n    @Test\n    internal fun `000 - Individual file`() {\n        // When\n        val plan = WorkspaceExecutionPlanner.plan(\n            input = paths(\"/workspaces/000_individual_file/flow.yaml\"),\n            includeTags = listOf(),\n            excludeTags = listOf(),\n            config = null,\n        )\n\n        // Then\n        assertThat(plan.flowsToRun).containsExactly(\n            path(\"/workspaces/000_individual_file/flow.yaml\"),\n        )\n    }\n\n    @Test\n    internal fun `001 - Simple workspace`() {\n        // When\n        val plan = WorkspaceExecutionPlanner.plan(\n            input = paths(\"/workspaces/001_simple\"),\n            includeTags = listOf(),\n            excludeTags = listOf(),\n            config = null,\n        )\n\n        // Then\n        assertThat(plan.flowsToRun).containsExactly(\n            path(\"/workspaces/001_simple/flowA.yaml\"),\n            path(\"/workspaces/001_simple/flowB.yaml\"),\n        )\n    }\n\n    @Test\n    internal fun `001 - Multiple files`() {\n        // When\n        val plan = WorkspaceExecutionPlanner.plan(\n            input = paths(\n                \"/workspaces/001_simple/flowA.yaml\",\n                \"/workspaces/001_simple/flowB.yaml\"\n            ),\n            includeTags = listOf(),\n            excludeTags = listOf(),\n            config = null,\n        )\n\n        // Then\n        assertThat(plan.flowsToRun).containsExactly(\n            path(\"/workspaces/001_simple/flowA.yaml\"),\n            path(\"/workspaces/001_simple/flowB.yaml\"),\n        )\n    }\n\n    @Test\n    internal fun `002 - Workspace with subflows`() {\n        // When\n        val plan = WorkspaceExecutionPlanner.plan(\n            input = paths(\"/workspaces/002_subflows\"),\n            includeTags = listOf(),\n            excludeTags = listOf(),\n            config = null,\n        )\n\n        // Then\n        assertThat(plan.flowsToRun).containsExactly(\n            path(\"/workspaces/002_subflows/flowA.yaml\"),\n            path(\"/workspaces/002_subflows/flowB.yaml\"),\n        )\n    }\n\n    @Test\n    internal fun `002 - Multiple folders`() {\n        // When\n        val plan = WorkspaceExecutionPlanner.plan(\n            input = paths(\n                \"/workspaces/001_simple\",\n                \"/workspaces/002_subflows\"\n            ),\n            includeTags = listOf(),\n            excludeTags = listOf(),\n            config = null,\n        )\n\n        // Then\n        assertThat(plan.flowsToRun).containsExactly(\n            path(\"/workspaces/001_simple/flowA.yaml\"),\n            path(\"/workspaces/001_simple/flowB.yaml\"),\n            path(\"/workspaces/002_subflows/flowA.yaml\"),\n            path(\"/workspaces/002_subflows/flowB.yaml\"),\n        )\n    }\n\n    @Test\n    internal fun `002 - Multiple files and folders`() {\n        // When\n        val plan = WorkspaceExecutionPlanner.plan(\n            input = paths(\n                \"/workspaces/000_individual_file/flow.yaml\",\n                \"/workspaces/001_simple\",\n                \"/workspaces/002_subflows\",\n                \"/workspaces/003_include_tags/flowC.yaml\",\n            ),\n            includeTags = listOf(),\n            excludeTags = listOf(),\n            config = null,\n        )\n\n        // Then\n        assertThat(plan.flowsToRun).containsExactly(\n            path(\"/workspaces/000_individual_file/flow.yaml\"),\n            path(\"/workspaces/001_simple/flowA.yaml\"),\n            path(\"/workspaces/001_simple/flowB.yaml\"),\n            path(\"/workspaces/002_subflows/flowA.yaml\"),\n            path(\"/workspaces/002_subflows/flowB.yaml\"),\n            path(\"/workspaces/003_include_tags/flowC.yaml\"),\n        )\n    }\n\n    @Test\n    internal fun `003 - Include tags`() {\n        // When\n        val plan = WorkspaceExecutionPlanner.plan(\n            input = paths(\"/workspaces/003_include_tags\"),\n            includeTags = listOf(\"included\"),\n            excludeTags = listOf(),\n            config = null,\n        )\n\n        // Then\n        assertThat(plan.flowsToRun).containsExactly(\n            path(\"/workspaces/003_include_tags/flowA.yaml\"),\n        )\n    }\n\n    @Test\n    internal fun `004 - Exclude tags`() {\n        // When\n        val plan = WorkspaceExecutionPlanner.plan(\n            input = paths(\"/workspaces/004_exclude_tags\"),\n            includeTags = listOf(),\n            excludeTags = listOf(\"excluded\"),\n            config = null,\n        )\n\n        // Then\n        assertThat(plan.flowsToRun).containsExactly(\n            path(\"/workspaces/004_exclude_tags/flowA.yaml\"),\n            path(\"/workspaces/004_exclude_tags/flowC.yaml\"),\n        )\n    }\n\n    @Test\n    internal fun `005 - Custom include pattern`() {\n        // When\n        val plan = WorkspaceExecutionPlanner.plan(\n            input = paths(\"/workspaces/005_custom_include_pattern\"),\n            includeTags = listOf(),\n            excludeTags = listOf(),\n            config = null,\n        )\n\n        // Then\n        assertThat(plan.flowsToRun).containsExactly(\n            path(\"/workspaces/005_custom_include_pattern/featureA/flowA.yaml\"),\n            path(\"/workspaces/005_custom_include_pattern/featureB/flowB.yaml\"),\n        )\n    }\n\n    @Test\n    internal fun `006 - Include subfolders`() {\n        // When\n        val plan = WorkspaceExecutionPlanner.plan(\n            input = paths(\"/workspaces/006_include_subfolders\"),\n            includeTags = listOf(),\n            excludeTags = listOf(),\n            config = null,\n        )\n\n        // Then\n        assertThat(plan.flowsToRun).containsExactly(\n            path(\"/workspaces/006_include_subfolders/featureA/flowA.yaml\"),\n            path(\"/workspaces/006_include_subfolders/featureB/flowB.yaml\"),\n            path(\"/workspaces/006_include_subfolders/featureC/subfolder/flowC.yaml\"),\n            path(\"/workspaces/006_include_subfolders/flowD.yaml\"),\n        )\n    }\n\n    @Test\n    internal fun `007 - Empty config`() {\n        // When\n        val plan = WorkspaceExecutionPlanner.plan(\n            input = paths(\"/workspaces/007_empty_config\"),\n            includeTags = listOf(),\n            excludeTags = listOf(),\n            config = null,\n        )\n\n        // Then\n        assertThat(plan.flowsToRun).containsExactly(\n            path(\"/workspaces/007_empty_config/flowA.yaml\"),\n            path(\"/workspaces/007_empty_config/flowB.yaml\"),\n        )\n    }\n\n    @Test\n    internal fun `008 - Literal pattern`() {\n        // When\n        val plan = WorkspaceExecutionPlanner.plan(\n            input = paths(\"/workspaces/008_literal_pattern\"),\n            includeTags = listOf(),\n            excludeTags = listOf(),\n            config = null,\n        )\n\n        // Then\n        assertThat(plan.flowsToRun).containsExactly(\n            path(\"/workspaces/008_literal_pattern/featureA/flowA.yaml\"),\n        )\n    }\n\n    @Test\n    internal fun `009 - Custom fields in config`() {\n        // When\n        val plan = WorkspaceExecutionPlanner.plan(\n            input = paths(\"/workspaces/009_custom_config_fields\"),\n            includeTags = listOf(),\n            excludeTags = listOf(),\n            config = null,\n        )\n\n        // Then\n        assertThat(plan.flowsToRun).containsExactly(\n            path(\"/workspaces/009_custom_config_fields/flowA.yaml\"),\n            path(\"/workspaces/009_custom_config_fields/flowB.yaml\"),\n        )\n    }\n\n    @Test\n    internal fun `010 - Global include tags`() {\n        // When\n        val plan = WorkspaceExecutionPlanner.plan(\n            input = paths(\"/workspaces/010_global_include_tags\"),\n            includeTags = listOf(\"featureB\"),\n            excludeTags = listOf(),\n            config = null,\n        )\n\n        // Then\n        assertThat(plan.flowsToRun).containsExactly(\n            path(\"/workspaces/010_global_include_tags/flowA.yaml\"),\n            path(\"/workspaces/010_global_include_tags/flowA_subflow.yaml\"),\n            path(\"/workspaces/010_global_include_tags/flowB.yaml\"),\n        )\n    }\n\n    @Test\n    internal fun `011 - Global exclude tags`() {\n        // When\n        val plan = WorkspaceExecutionPlanner.plan(\n            input = paths(\"/workspaces/011_global_exclude_tags\"),\n            includeTags = listOf(),\n            excludeTags = listOf(\"featureA\"),\n            config = null,\n        )\n\n        // Then\n        assertThat(plan.flowsToRun).containsExactly(\n            path(\"/workspaces/011_global_exclude_tags/flowB.yaml\"),\n            path(\"/workspaces/011_global_exclude_tags/flowC.yaml\"),\n            path(\"/workspaces/011_global_exclude_tags/flowE.yaml\"),\n        )\n    }\n\n    //012 - Deterministic order for local tests - removed\n\n    @Test\n    internal fun `013 - Execution order is respected`() {\n        // When\n        val plan = WorkspaceExecutionPlanner.plan(\n            input = paths(\"/workspaces/013_execution_order\"),\n            includeTags = listOf(),\n            excludeTags = listOf(),\n            config = null,\n        )\n\n        // Then\n        assertThat(plan.flowsToRun).containsExactly(\n            path(\"/workspaces/013_execution_order/flowA.yaml\"),\n        )\n\n        // Then\n        assertThat(plan.sequence).isNotNull()\n        assertThat(plan.sequence.flows).containsExactly(\n            path(\"/workspaces/013_execution_order/flowB.yaml\"),\n            path(\"/workspaces/013_execution_order/flowCWithCustomName.yaml\"),\n            path(\"/workspaces/013_execution_order/flowD.yaml\"),\n        ).inOrder()\n    }\n\n    @Test\n    internal fun `014 - Config not null`() {\n        // When\n        val plan = WorkspaceExecutionPlanner.plan(\n            input = paths(\"/workspaces/014_config_not_null\"),\n            includeTags = listOf(),\n            excludeTags = listOf(),\n            config = path(\"/workspaces/014_config_not_null/config/another_config.yaml\"),\n        )\n\n        // Then\n        assertThat(plan.flowsToRun).containsExactly(\n            path(\"/workspaces/014_config_not_null/flowA.yaml\"),\n        )\n    }\n\n    @Test\n    internal fun `017 - Upload configs on local and cloud both are supported`() {\n        // when\n        val plan = WorkspaceExecutionPlanner.plan(\n            input = paths(\"/workspaces/015_workspace_cloud_configs\"),\n            includeTags = listOf(\"included\"),\n            excludeTags = listOf(\"notIncluded\"),\n            config = null\n        )\n\n        assertThat(plan.workspaceConfig.notifications?.email?.recipients).containsExactly(\"abc@mobile.dev\")\n        assertThat(plan.workspaceConfig.notifications?.slack?.channels).containsExactly(\"e2e-testing\")\n        assertThat(plan.workspaceConfig.executionOrder?.flowsOrder).containsExactly(\"flowA\", \"flowB\")\n        assertThat(plan.workspaceConfig.disableRetries).isTrue()\n    }\n\n    @Test\n    internal fun `017 - Upload platform configs on are supported`() {\n        // when\n        val plan = WorkspaceExecutionPlanner.plan(\n            input = paths(\"/workspaces/015_workspace_cloud_configs\"),\n            includeTags = listOf(\"included\"),\n            excludeTags = listOf(\"notIncluded\"),\n            config = null\n        )\n\n        val platformConfiguration = plan.workspaceConfig.platform\n        assertThat(platformConfiguration).isEqualTo(\n            PlatformConfiguration(\n                android = PlatformConfiguration.AndroidConfiguration(disableAnimations = true),\n                ios = PlatformConfiguration.IOSConfiguration(disableAnimations = true, snapshotKeyHonorModalViews = false)\n            )\n        )\n    }\n\n    private fun path(path: String): Path? {\n        val clazz = WorkspaceExecutionPlannerTest::class.java\n        val resource = clazz.getResource(path)?.toURI()\n        return resource?.let { Paths.get(it) }\n    }\n\n    private fun paths(vararg paths: String): Set<Path> = paths.mapNotNull(::path).toSet()\n}\n"
  },
  {
    "path": "maestro-orchestra/src/test/java/maestro/orchestra/workspace/WorkspaceValidatorTest.kt",
    "content": "package maestro.orchestra.workspace\n\nimport com.google.common.truth.Truth.assertThat\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.io.TempDir\nimport java.io.File\nimport java.util.zip.ZipEntry\nimport java.util.zip.ZipOutputStream\n\nclass WorkspaceValidatorTest {\n\n    @TempDir\n    lateinit var tempDir: File\n\n    private val baseFlowContent = WorkspaceValidatorTest::class.java\n        .getResource(\"/workspaces/workspace_validator_flow.yaml\")!!\n        .readText()\n\n    private fun makeWorkspaceZip(vararg entries: Pair<String, String>): File {\n        val zip = File(tempDir, \"workspace_${System.nanoTime()}.zip\")\n        ZipOutputStream(zip.outputStream()).use { zos ->\n            entries.forEach { (name, content) ->\n                zos.putNextEntry(ZipEntry(name))\n                zos.write(content.toByteArray())\n                zos.closeEntry()\n            }\n        }\n        return zip\n    }\n\n    private fun flowWithName(name: String): String {\n        return baseFlowContent.replace(\"appId:\", \"name: $name\\nappId:\")\n    }\n\n    @Test\n    fun `validate returns workspaceConfig and matching flows for appId`() {\n        val result = WorkspaceValidator.validate(\n            workspace = makeWorkspaceZip(\"flow.yaml\" to baseFlowContent),\n            appId = \"com.example.app\",\n            envParameters = mapOf(\"APP_ID\" to \"com.example.app\"),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n        )\n\n        assertThat(result.isOk).isTrue()\n        assertThat(result.value.flows).hasSize(1)\n    }\n\n    @Test\n    fun `validate only returns flows matching the given appId`() {\n        val result = WorkspaceValidator.validate(\n            workspace = makeWorkspaceZip(\n                \"flow_a.yaml\" to baseFlowContent,\n                \"flow_b.yaml\" to baseFlowContent,\n            ),\n            appId = \"com.example.app\",\n            envParameters = mapOf(\"APP_ID\" to \"com.example.app\"),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n        )\n\n        assertThat(result.isOk).isTrue()\n        assertThat(result.value.flows).hasSize(2)\n    }\n\n    @Test\n    fun `validate returns NoFlowsMatchingAppId when no flows match`() {\n        val result = WorkspaceValidator.validate(\n            workspace = makeWorkspaceZip(\"flow.yaml\" to baseFlowContent),\n            appId = \"com.nonexistent.app\",\n            envParameters = mapOf(\"APP_ID\" to \"com.example.app\"),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n        )\n\n        assertThat(result.isErr).isTrue()\n        assertThat(result.error).isInstanceOf(WorkspaceValidationError.NoFlowsMatchingAppId::class.java)\n    }\n\n    @Test\n    fun `validate returns NameConflict when two matching flows have the same name`() {\n        val result = WorkspaceValidator.validate(\n            workspace = makeWorkspaceZip(\n                \"flow_a.yaml\" to flowWithName(\"Login\"),\n                \"flow_b.yaml\" to flowWithName(\"Login\"),\n            ),\n            appId = \"com.example.app\",\n            envParameters = mapOf(\"APP_ID\" to \"com.example.app\"),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n        )\n\n        assertThat(result.isErr).isTrue()\n        assertThat(result.error).isInstanceOf(WorkspaceValidationError.NameConflict::class.java)\n    }\n\n    @Test\n    fun `validate returns InvalidWorkspaceFile for a non-zip file`() {\n        val notAZip = File(tempDir, \"bad.zip\").also { it.writeText(\"not a zip\") }\n\n        val result = WorkspaceValidator.validate(\n            workspace = notAZip,\n            appId = \"com.example.app\",\n            envParameters = emptyMap(),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n        )\n\n        assertThat(result.isErr).isTrue()\n        assertThat(result.error).isInstanceOf(WorkspaceValidationError.InvalidWorkspaceFile::class.java)\n    }\n\n    @Test\n    fun `validate resolves env variables with rhino engine when explicitly requested`() {\n        val rhinoFlow = \"\"\"\n            appId: ${'$'}{APP_ID}\n            ext:\n              jsEngine: rhino\n            ---\n            - launchApp\n        \"\"\".trimIndent()\n\n        val result = WorkspaceValidator.validate(\n            workspace = makeWorkspaceZip(\"flow.yaml\" to rhinoFlow),\n            appId = \"com.example.app\",\n            envParameters = mapOf(\"APP_ID\" to \"com.example.app\"),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n        )\n\n        assertThat(result.isOk).isTrue()\n        assertThat(result.value.flows).hasSize(1)\n        assertThat(result.value.flows.first().appId).isEqualTo(\"com.example.app\")\n    }\n\n    @Test\n    fun `validate returns EmptyWorkspace when zip has no yaml flows`() {\n        val result = WorkspaceValidator.validate(\n            workspace = makeWorkspaceZip(\"README.txt\" to \"nothing here\"),\n            appId = \"com.example.app\",\n            envParameters = emptyMap(),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n        )\n\n        assertThat(result.isErr).isTrue()\n        assertThat(result.error).isInstanceOf(WorkspaceValidationError.EmptyWorkspace::class.java)\n    }\n\n    @Test\n    fun `validate returns MissingLaunchApp when flow has no launchApp command`() {\n        val flowWithoutLaunch = \"\"\"\n            appId: com.example.app\n            ---\n            - tapOn: \"some button\"\n        \"\"\".trimIndent()\n\n        val result = WorkspaceValidator.validate(\n            workspace = makeWorkspaceZip(\"flow.yaml\" to flowWithoutLaunch),\n            appId = \"com.example.app\",\n            envParameters = emptyMap(),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n        )\n\n        assertThat(result.isErr).isTrue()\n        assertThat(result.error).isInstanceOf(WorkspaceValidationError.MissingLaunchApp::class.java)\n        val error = result.error as WorkspaceValidationError.MissingLaunchApp\n        assertThat(error.flowNames).containsExactly(\"flow\")\n    }\n\n    @Test\n    fun `validate returns MissingLaunchApp when root flow has no launchApp and is not referenced as subflow`() {\n        val flowWithLaunch = \"\"\"\n            appId: com.example.app\n            ---\n            - launchApp\n        \"\"\".trimIndent()\n\n        val flowWithoutLaunch = \"\"\"\n            appId: com.example.app\n            ---\n            - tapOn: \"some button\"\n        \"\"\".trimIndent()\n\n        val result = WorkspaceValidator.validate(\n            workspace = makeWorkspaceZip(\n                \"flow_a.yaml\" to flowWithLaunch,\n                \"flow_b.yaml\" to flowWithoutLaunch,\n            ),\n            appId = \"com.example.app\",\n            envParameters = emptyMap(),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n        )\n\n        assertThat(result.isErr).isTrue()\n        assertThat(result.error).isInstanceOf(WorkspaceValidationError.MissingLaunchApp::class.java)\n        val error = result.error as WorkspaceValidationError.MissingLaunchApp\n        assertThat(error.flowNames).containsExactly(\"flow_b\")\n    }\n\n    @Test\n    fun `validate accepts flow when runFlow references subflow containing launchApp`() {\n        val launchFlow = \"\"\"\n            appId: com.example.app\n            ---\n            - launchApp\n        \"\"\".trimIndent()\n\n        val mainFlow = \"\"\"\n            appId: com.example.app\n            ---\n            - runFlow:\n                file: launch.yaml\n            - tapOn: \"some button\"\n        \"\"\".trimIndent()\n\n        val result = WorkspaceValidator.validate(\n            workspace = makeWorkspaceZip(\n                \"main_flow.yaml\" to mainFlow,\n                \"launch.yaml\" to launchFlow,\n            ),\n            appId = \"com.example.app\",\n            envParameters = emptyMap(),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n        )\n\n        assertThat(result.isOk).isTrue()\n        assertThat(result.value.flows).hasSize(2)\n    }\n\n    @Test\n    fun `validate accepts flow when onFlowStart hook contains launchApp`() {\n        val flowWithOnFlowStartLaunch = \"\"\"\n            appId: com.example.app\n            onFlowStart:\n              - launchApp\n            ---\n            - tapOn: \"some button\"\n        \"\"\".trimIndent()\n\n        val result = WorkspaceValidator.validate(\n            workspace = makeWorkspaceZip(\"flow.yaml\" to flowWithOnFlowStartLaunch),\n            appId = \"com.example.app\",\n            envParameters = emptyMap(),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n        )\n\n        assertThat(result.isOk).isTrue()\n        assertThat(result.value.flows).hasSize(1)\n    }\n\n    @Test\n    fun `validate accepts flow when onFlowStart hook has runFlow referencing subflow with launchApp`() {\n        val launchFlow = \"\"\"\n            appId: com.example.app\n            ---\n            - launchApp\n        \"\"\".trimIndent()\n\n        val mainFlow = \"\"\"\n            appId: com.example.app\n            onFlowStart:\n              - runFlow:\n                  file: launch.yaml\n            ---\n            - tapOn: \"some button\"\n        \"\"\".trimIndent()\n\n        val result = WorkspaceValidator.validate(\n            workspace = makeWorkspaceZip(\n                \"main_flow.yaml\" to mainFlow,\n                \"launch.yaml\" to launchFlow,\n            ),\n            appId = \"com.example.app\",\n            envParameters = emptyMap(),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n        )\n\n        assertThat(result.isOk).isTrue()\n        assertThat(result.value.flows).hasSize(2)\n    }\n\n    @Test\n    fun `validate accepts flow when retry references subflow containing launchApp`() {\n        val launchFlow = \"\"\"\n            appId: com.example.app\n            ---\n            - launchApp\n        \"\"\".trimIndent()\n\n        val mainFlow = \"\"\"\n            appId: com.example.app\n            ---\n            - retry:\n                file: launch.yaml\n            - tapOn: \"some button\"\n        \"\"\".trimIndent()\n\n        val result = WorkspaceValidator.validate(\n            workspace = makeWorkspaceZip(\n                \"main_flow.yaml\" to mainFlow,\n                \"launch.yaml\" to launchFlow,\n            ),\n            appId = \"com.example.app\",\n            envParameters = emptyMap(),\n            includeTags = emptyList(),\n            excludeTags = emptyList(),\n        )\n\n        assertThat(result.isOk).isTrue()\n        assertThat(result.value.flows).hasSize(2)\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/test/java/maestro/orchestra/yaml/YamlCommandReaderTest.kt",
    "content": "package maestro.orchestra.yaml\n\nimport com.google.common.truth.Truth.assertThat\nimport maestro.KeyCode\nimport maestro.Point\nimport maestro.ScrollDirection\nimport maestro.SwipeDirection\nimport maestro.TapRepeat\nimport maestro.device.DeviceOrientation\nimport maestro.orchestra.AddMediaCommand\nimport maestro.orchestra.AirplaneValue\nimport maestro.orchestra.ApplyConfigurationCommand\nimport maestro.orchestra.AssertConditionCommand\nimport maestro.orchestra.BackPressCommand\nimport maestro.orchestra.ClearKeychainCommand\nimport maestro.orchestra.ClearStateCommand\nimport maestro.orchestra.Command\nimport maestro.orchestra.Condition\nimport maestro.orchestra.CopyTextFromCommand\nimport maestro.orchestra.DefineVariablesCommand\nimport maestro.orchestra.ElementSelector\nimport maestro.orchestra.EraseTextCommand\nimport maestro.orchestra.EvalScriptCommand\nimport maestro.orchestra.HideKeyboardCommand\nimport maestro.orchestra.InputRandomCommand\nimport maestro.orchestra.InputRandomType\nimport maestro.orchestra.InputTextCommand\nimport maestro.orchestra.KillAppCommand\nimport maestro.orchestra.LaunchAppCommand\nimport maestro.orchestra.MaestroCommand\nimport maestro.orchestra.MaestroConfig\nimport maestro.orchestra.MaestroOnFlowComplete\nimport maestro.orchestra.MaestroOnFlowStart\nimport maestro.orchestra.OpenLinkCommand\nimport maestro.orchestra.PasteTextCommand\nimport maestro.orchestra.PressKeyCommand\nimport maestro.orchestra.RepeatCommand\nimport maestro.orchestra.RunFlowCommand\nimport maestro.orchestra.RunScriptCommand\nimport maestro.orchestra.ScrollCommand\nimport maestro.orchestra.ScrollUntilVisibleCommand\nimport maestro.orchestra.SetAirplaneModeCommand\nimport maestro.orchestra.SetLocationCommand\nimport maestro.orchestra.SetOrientationCommand\nimport maestro.orchestra.SetPermissionsCommand\nimport maestro.orchestra.StartRecordingCommand\nimport maestro.orchestra.StopAppCommand\nimport maestro.orchestra.StopRecordingCommand\nimport maestro.orchestra.SwipeCommand\nimport maestro.orchestra.TakeScreenshotCommand\nimport maestro.orchestra.TapOnElementCommand\nimport maestro.orchestra.TapOnPointV2Command\nimport maestro.orchestra.ToggleAirplaneModeCommand\nimport maestro.orchestra.TravelCommand\nimport maestro.orchestra.WaitForAnimationToEndCommand\nimport maestro.orchestra.yaml.junit.YamlCommandsExtension\nimport maestro.orchestra.yaml.junit.YamlFile\nimport org.junit.Assert.assertThrows\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.extension.ExtendWith\nimport java.nio.file.FileSystems\nimport java.nio.file.Paths\n\n@Suppress(\"JUnitMalformedDeclaration\")\n@ExtendWith(YamlCommandsExtension::class)\ninternal class YamlCommandReaderTest {\n\n    @Test\n    fun launchApp(\n        @YamlFile(\"002_launchApp.yaml\") commands: List<Command>,\n    ) {\n        assertThat(commands).containsExactly(\n                ApplyConfigurationCommand(MaestroConfig(\n                    appId = \"com.example.app\"\n                )),\n                LaunchAppCommand(\n                    appId = \"com.example.app\"\n                ),\n        )\n    }\n\n    @Test\n    fun launchApp_withClearState(\n        @YamlFile(\"003_launchApp_withClearState.yaml\") commands: List<Command>\n    ) {\n        assertThat(commands).containsExactly(\n            ApplyConfigurationCommand(MaestroConfig(\n                appId = \"com.example.app\",\n            )),\n            LaunchAppCommand(\n                appId = \"com.example.app\",\n                clearState = true,\n            ),\n        )\n    }\n\n    @Test\n    fun config_unknownKeys(\n        @YamlFile(\"008_config_unknownKeys.yaml\") commands: List<Command>,\n    ) {\n        assertThat(commands).containsExactly(\n            ApplyConfigurationCommand(MaestroConfig(\n                appId = \"com.example.app\",\n                ext = mapOf(\n                    \"extra\" to true,\n                    \"extraMap\" to mapOf(\n                        \"keyA\" to \"valueB\"\n                    ),\n                    \"extraArray\" to listOf(\"itemA\")\n                )\n            )),\n            LaunchAppCommand(\n                appId = \"com.example.app\",\n            ),\n        )\n    }\n\n    @Test\n    fun launchApp_otherPackage(\n        @YamlFile(\"017_launchApp_otherPackage.yaml\") commands: List<Command>,\n    ) {\n        assertThat(commands).containsExactly(\n            ApplyConfigurationCommand(MaestroConfig(\n                appId = \"com.example.app\",\n            )),\n            LaunchAppCommand(\n                appId = \"com.other.app\"\n            ),\n        )\n    }\n\n    @Test\n    fun backPress_string(\n        @YamlFile(\"018_backPress_string.yaml\") commands: List<Command>,\n    ) {\n        assertThat(commands).containsExactly(\n            ApplyConfigurationCommand(MaestroConfig(\n                appId = \"com.example.app\",\n            )),\n            BackPressCommand(),\n        )\n    }\n\n    @Test\n    fun scroll_string(\n        @YamlFile(\"019_scroll_string.yaml\") commands: List<Command>,\n    ) {\n        assertThat(commands).containsExactly(\n            ApplyConfigurationCommand(MaestroConfig(\n                appId = \"com.example.app\",\n            )),\n            ScrollCommand(),\n        )\n    }\n\n    @Test\n    fun config_name(\n        @YamlFile(\"020_config_name.yaml\") commands: List<Command>,\n    ) {\n        assertThat(commands).containsExactly(\n            ApplyConfigurationCommand(MaestroConfig(\n                appId = \"com.example.app\",\n                name = \"Example Flow\"\n            )),\n            LaunchAppCommand(\n                appId = \"com.example.app\"\n            ),\n        )\n    }\n\n    // Misc. tests\n\n    @Test\n    fun readFromZip() {\n        val resource = this::class.java.getResource(\"/YamlCommandReaderTest/flow.zip\")!!.toURI()\n        assertThat(resource.scheme).isEqualTo(\"file\")\n\n        val commands = FileSystems.newFileSystem(Paths.get(resource), null as ClassLoader?).use { fs ->\n            YamlCommandReader.readCommands(fs.getPath(\"flow.yaml\"))\n        }\n\n        assertThat(commands).isEqualTo(commands(\n            ApplyConfigurationCommand(\n                config = MaestroConfig(\n                    appId = \"com.example.app\"\n                )\n            ),\n            LaunchAppCommand(\n                appId = \"com.example.app\"\n            )\n        ))\n    }\n\n    @Test\n    fun onFlowStartCompleteHooks(\n        @YamlFile(\"022_on_flow_start_complete.yaml\") commands: List<Command>,\n    ) {\n        assertThat(commands).containsExactly(\n            ApplyConfigurationCommand(\n                config = MaestroConfig(\n                    appId = \"com.example.app\",\n                    onFlowStart = MaestroOnFlowStart(\n                        commands = commands(\n                            BackPressCommand()\n                        )\n                    ),\n                    onFlowComplete = MaestroOnFlowComplete(\n                        commands = commands(\n                            ScrollCommand()\n                        )\n                    )\n                )\n            ),\n            LaunchAppCommand(\n                appId = \"com.example.app\"\n            )\n        )\n    }\n\n    @Test\n    fun labels(\n        @YamlFile(\"023_labels.yaml\") commands: List<Command>,\n    ) {\n        // Compute expected absolute path for runScript command\n        val testResourcesPath = YamlCommandReaderTest::class.java.classLoader.getResource(\"YamlCommandReaderTest/023_runScript_test.js\")?.toURI()\n        val expectedScriptPath = testResourcesPath?.let { java.nio.file.Paths.get(it).toString() } ?: \"023_runScript_test.js\"\n        \n        assertThat(commands).containsExactly(\n            ApplyConfigurationCommand(\n                config=MaestroConfig(\n                    appId=\"com.example.app\"\n                )\n            ),\n\n            // Taps\n            TapOnElementCommand(\n                selector = ElementSelector(idRegex = \"foo\"),\n                retryIfNoChange = false,\n                waitUntilVisible = false,\n                longPress = false,\n                label = \"Tap on the important button\"\n            ),\n            TapOnElementCommand(\n                selector = ElementSelector(idRegex = \"foo\"),\n                retryIfNoChange = false,\n                waitUntilVisible = false,\n                longPress = false,\n                repeat = TapRepeat(\n                    repeat = 2,\n                    delay = 100L\n                ),\n                label = \"Tap on the important button twice\"\n            ),\n            TapOnElementCommand(\n                selector = ElementSelector(idRegex = \"foo\"),\n                retryIfNoChange = false,\n                waitUntilVisible = false,\n                longPress = true,\n                label = \"Press and hold the important button\"\n            ),\n            TapOnPointV2Command(\n                point = \"50%,50%\",\n                retryIfNoChange = false,\n                longPress = false,\n                label = \"Tap on the middle of the screen\"\n            ),\n\n            //Assertions\n            AssertConditionCommand(\n                condition = Condition(visible = ElementSelector(idRegex = \"bar\")),\n                label = \"Check that the important number is visible\"\n            ),\n            AssertConditionCommand(\n                condition = Condition(notVisible = ElementSelector(idRegex = \"bar2\")),\n                label = \"Check that the secret number is invisible\"\n            ),\n            AssertConditionCommand(\n                condition = Condition(\n                    scriptCondition = \"\\${5 == 5}\"\n                ),\n                label = \"Check that five is still what we think it is\"\n            ),\n\n\n            // Inputs\n            InputTextCommand(\n                text = \"correct horse battery staple\",\n                label = \"Enter my secret password\"\n            ),\n            InputRandomCommand(\n                inputType = InputRandomType.TEXT_EMAIL_ADDRESS,\n                label = \"Enter a random email address\"\n            ),\n            InputRandomCommand(\n                inputType = InputRandomType.TEXT_PERSON_NAME,\n                length = 8,\n                label = \"Enter a random person's name\"\n            ),\n            InputRandomCommand(\n                inputType = InputRandomType.NUMBER,\n                length = 5,\n                label = \"Enter a random number\"\n            ),\n            InputRandomCommand(\n                inputType = InputRandomType.TEXT,\n                length = 20,\n                label = \"Enter a random string\"\n            ),\n            PressKeyCommand(\n                code = KeyCode.ENTER,\n                label = \"Press the enter key\"\n            ),\n\n            // Other\n            BackPressCommand(\n                label = \"Go back to the previous screen\"\n            ),\n            ClearKeychainCommand(\n                label = \"Clear the keychain\"\n            ),\n            ClearStateCommand(\n                appId = \"com.example.app\",\n                label = \"Wipe the app state\"\n            ),\n            CopyTextFromCommand(\n                selector = ElementSelector(idRegex = \"foo\"),\n                label = \"Copy the important text\"\n            ),\n            EraseTextCommand(\n                charactersToErase = 5,\n                label = \"Erase the last 5 characters\"\n            ),\n            AssertConditionCommand(\n                condition = Condition(visible = ElementSelector(textRegex=\"Some important text\")),\n                timeout = \"1000\",\n                label = \"Wait until the important text is visible\"\n            ),\n            EvalScriptCommand(\n                scriptString = \"return 5;\",\n                label = \"Get the number 5\"\n            ),\n            HideKeyboardCommand(\n                label = \"Hide the keyboard\"\n            ),\n            LaunchAppCommand(\n                appId = \"com.some.other\",\n                clearState = true,\n                label = \"Launch some other app\"\n            ),\n            OpenLinkCommand(\n                link = \"https://www.example.com\",\n                autoVerify = false,\n                browser = false,\n                label = \"Open the example website\"\n            ),\n            PasteTextCommand(\n                label = \"Paste the important text\"\n            ),\n            RunFlowCommand(\n                config = null,\n                commands = commands(\n                    AssertConditionCommand(\n                        condition = Condition(scriptCondition = \"\\${5 == 5}\")\n                    )\n                ),\n                label = \"Check that five is still what we think it is\"\n            ),\n            RunScriptCommand(\n                script = \"const myNumber = 1 + 1;\",\n                condition = null,\n                sourceDescription = expectedScriptPath,\n                label = \"Run some special calculations\"\n            ),\n            SetOrientationCommand(\n                orientation = DeviceOrientation.LANDSCAPE_LEFT,\n                label = \"Set the device orientation\"\n            ),\n            ScrollCommand(\n                label = \"Scroll down\"\n            ),\n            ScrollUntilVisibleCommand(\n                selector = ElementSelector(textRegex = \"Footer\"),\n                direction = ScrollDirection.DOWN,\n                timeout = \"20000\",\n                scrollDuration = \"40\",\n                visibilityPercentage = 100,\n                label = \"Scroll to the bottom\",\n                centerElement = false\n            ),\n            SetLocationCommand(\n                latitude = \"12.5266\",\n                longitude = \"78.2150\",\n                label = \"Set Location to Test Laboratory\"\n            ),\n            StartRecordingCommand(\n                path = \"recording.mp4\",\n                label = \"Start recording a video\"\n            ),\n            StopAppCommand(\n                appId = \"com.some.other\",\n                label = \"Stop that other app from running\"\n            ),\n            StopRecordingCommand(\n                label = \"Stop recording the video\"\n            ),\n            TakeScreenshotCommand(\n                path = \"baz\",\n                label = \"Snap this for later evaluation\"\n            ),\n            TravelCommand(\n                points = listOf(\n                    TravelCommand.GeoPoint(\"0.0\",\"0.0\"),\n                    TravelCommand.GeoPoint(\"0.1\",\"0.0\"),\n                    TravelCommand.GeoPoint(\"0.1\",\"0.1\"),\n                    TravelCommand.GeoPoint(\"0.0\",\"0.1\"),\n                ),\n                speedMPS = 2000.0,\n                label = \"Run around the north pole\"\n            ),\n            WaitForAnimationToEndCommand(\n                timeout = 4000,\n                label = \"Wait for the thing to stop spinning\"\n            ),\n            SwipeCommand(\n                direction = SwipeDirection.DOWN,\n                label = \"Swipe down a bit\"\n            ),\n            AddMediaCommand(\n                mediaPaths = listOf(Paths.get(\"build/resources/test/YamlCommandReaderTest/023_image.png\").toAbsolutePath().toString()),\n                label = \"Add a picture to the device\"\n            ),\n            SetAirplaneModeCommand(\n                value = AirplaneValue.Enable,\n                label = \"Turn on airplane mode for testing\"\n            ),\n            ToggleAirplaneModeCommand(\n                label = \"Toggle airplane mode for testing\"\n            ),\n            RepeatCommand(\n                condition = Condition(visible = ElementSelector(textRegex = \"Some important text\")),\n                commands = listOf(\n                    MaestroCommand(\n                        command = TapOnElementCommand(\n                            selector = ElementSelector(idRegex = \"foo\"),\n                            retryIfNoChange = false,\n                            waitUntilVisible = false,\n                            longPress = false,\n                            label = \"Tap on the important button\"\n                        )\n                    ),\n                    MaestroCommand(\n                        command = TapOnElementCommand(\n                            selector = ElementSelector(idRegex = \"bar\"),\n                            retryIfNoChange = false,\n                            waitUntilVisible = false,\n                            longPress = false,\n                            label = \"Tap on the other important button\"\n                        )\n                    )\n                ),\n                label = \"Tap the 2 buttons until the text goes away\"\n            ),\n        )\n    }\n\n    @Test\n    fun commands_with_string_non_string(@YamlFile(\"024_string_non_string_commands.yaml\") commands: List<Command>,) {\n        assertThat(commands).containsExactly(\n            ApplyConfigurationCommand(\n                config= MaestroConfig(appId= \"com.example.app\")\n            ),\n            InputTextCommand(text = \"correct horse battery staple\"),\n            InputTextCommand(text = \"correct horse battery staple\"),\n            InputTextCommand(text = \"4\"),\n            InputTextCommand(text = \"false\"),\n            InputTextCommand(text = \"1683113805263\"),\n            InputTextCommand(text = \"4.12\"),\n            AssertConditionCommand(\n                condition = Condition(\n                    scriptCondition = \"true\"\n                )\n            ),\n            AssertConditionCommand(\n                condition = Condition(\n                    scriptCondition = \"323\"\n                )\n            ),\n            EvalScriptCommand(\n                scriptString = \"true\"\n            ),\n            EvalScriptCommand(\n                scriptString = \"2 + 1\"\n            ),\n            EvalScriptCommand(\n                scriptString = \"2\"\n            ),\n            EvalScriptCommand(\n                scriptString = \"false == false\"\n            ),\n            TapOnElementCommand(\n                ElementSelector(\n                    textRegex = \"Hello\",\n                ),\n                retryIfNoChange = false,\n                waitUntilVisible = false,\n                longPress = false\n            ),\n            TapOnElementCommand(\n                selector = ElementSelector(textRegex = \"Hello\"),\n                repeat = TapRepeat(2, TapOnElementCommand.DEFAULT_REPEAT_DELAY),\n                retryIfNoChange = false,\n                waitUntilVisible = false,\n                longPress = false\n            ),\n            TapOnElementCommand(\n                selector = ElementSelector(textRegex = \"Hello\"),\n                longPress = true,\n                retryIfNoChange = false,\n                waitUntilVisible = false\n            ),\n            AssertConditionCommand(\n                condition = Condition(\n                    visible = ElementSelector(textRegex = \"Hello\"),\n                ),\n            ),\n            CopyTextFromCommand(ElementSelector(textRegex = \"Hello\")),\n            BackPressCommand(),\n            BackPressCommand(),\n            HideKeyboardCommand(),\n            HideKeyboardCommand(),\n            ScrollCommand(),\n            ScrollCommand(),\n            ClearKeychainCommand(),\n            ClearKeychainCommand(),\n            PasteTextCommand(),\n            PasteTextCommand(),\n        )\n    }\n\n    @Test\n    fun killApp(\n        @YamlFile(\"025_killApp.yaml\") commands: List<Command>,\n    ) {\n        assertThat(commands).containsExactly(\n            ApplyConfigurationCommand(MaestroConfig(\n                appId = \"com.example.app\"\n            )),\n            KillAppCommand(\n                appId = \"com.example.app\"\n            ),\n        )\n    }\n\n    @Test\n    fun waitToSettleTimeoutMsCommands(\n        @YamlFile(\"027_waitToSettleTimeoutMs.yaml\") commands: List<Command>\n    ) {\n        assertThat(commands).containsExactly(\n            ApplyConfigurationCommand(MaestroConfig(\n                appId = \"com.example.app\"\n            )),\n            ScrollUntilVisibleCommand(\n                selector = ElementSelector(idRegex = \"maybe-later\"),\n                direction = ScrollDirection.DOWN,\n                waitToSettleTimeoutMs = 50,\n                centerElement = false,\n                visibilityPercentage = 100\n            ),\n            SwipeCommand(\n                startRelative = \"90%, 50%\",\n                endRelative = \"10%, 50%\",\n                waitToSettleTimeoutMs = 50\n            ),\n            SwipeCommand(\n                direction = SwipeDirection.LEFT,\n                duration = 400L,\n                waitToSettleTimeoutMs = 50\n            ),\n            SwipeCommand(\n                direction = SwipeDirection.LEFT,\n                duration = 400L,\n                elementSelector = ElementSelector(idRegex = \"feeditem_identifier\"),\n                waitToSettleTimeoutMs = 50,\n            ),\n            SwipeCommand(\n                startPoint = Point(x = 100, y = 200),\n                endPoint = Point(x = 300, y = 400),\n                waitToSettleTimeoutMs = 50,\n                duration = 400L\n            )\n        )\n    }\n\n    // Element-relative tap tests\n    @Test\n    fun `element-relative tap with text selector and percentage coordinates`(\n        @YamlFile(\"029_element_relative_tap_text_percentage.yaml\") commands: List<Command>\n    ) {\n        // Given: YAML command parsed by real YamlCommandReader\n        val tapCommand = commands[1] as TapOnElementCommand\n\n        // Then: Verify the real command structure\n        assertThat(tapCommand.selector.textRegex).isEqualTo(\"Submit\")\n        assertThat(tapCommand.relativePoint).isEqualTo(\"50%, 90%\")\n        assertThat(tapCommand.retryIfNoChange).isFalse() // YAML parsing sets default values\n        assertThat(tapCommand.waitUntilVisible).isFalse() // YAML parsing sets default values\n        assertThat(tapCommand.longPress).isFalse() // YAML parsing sets default values\n        assertThat(tapCommand.optional).isFalse()\n\n        // Verify the original description includes the point\n        assertThat(tapCommand.originalDescription).isEqualTo(\"Tap on \\\"Submit\\\" at 50%, 90%\")\n    }\n\n    @Test\n    fun `element-relative tap with ID selector and absolute coordinates`(\n        @YamlFile(\"029_element_relative_tap_id_absolute.yaml\") commands: List<Command>\n    ) {\n        // Given: YAML command parsed by real YamlCommandReader\n        val tapCommand = commands[1] as TapOnElementCommand\n\n        // Then: Verify the real command structure\n        assertThat(tapCommand.selector.idRegex).isEqualTo(\"submit-btn\")\n        assertThat(tapCommand.relativePoint).isEqualTo(\"25, 75\")\n        assertThat(tapCommand.originalDescription).isEqualTo(\"Tap on id: submit-btn at 25, 75\")\n    }\n\n    @Test\n    fun `element-relative tap with CSS selector`(\n        @YamlFile(\"029_element_relative_tap_css.yaml\") commands: List<Command>\n    ) {\n        // Given: YAML command parsed by real YamlCommandReader\n        val tapCommand = commands[1] as TapOnElementCommand\n\n        // Then: Verify the real command structure\n        assertThat(tapCommand.selector.css).isEqualTo(\".submit-button\")\n        assertThat(tapCommand.relativePoint).isEqualTo(\"75%, 25%\")\n        assertThat(tapCommand.originalDescription).isEqualTo(\"Tap on CSS: .submit-button at 75%, 25%\")\n    }\n\n    @Test\n    fun `element-relative tap with size selector`(\n        @YamlFile(\"029_element_relative_tap_size.yaml\") commands: List<Command>\n    ) {\n        // Given: YAML command parsed by real YamlCommandReader\n        val tapCommand = commands[1] as TapOnElementCommand\n\n        // Then: Verify the real command structure\n        assertThat(tapCommand.selector.size?.width).isEqualTo(200)\n        assertThat(tapCommand.selector.size?.height).isEqualTo(50)\n        assertThat(tapCommand.relativePoint).isEqualTo(\"50%, 50%\")\n        assertThat(tapCommand.originalDescription).isEqualTo(\"Tap on Size: 200x50 at 50%, 50%\")\n    }\n\n    @Test\n    fun `element-relative tap with enabled selector`(\n        @YamlFile(\"029_element_relative_tap_enabled.yaml\") commands: List<Command>\n    ) {\n        // Given: YAML command parsed by real YamlCommandReader\n        val tapCommand = commands[1] as TapOnElementCommand\n\n        // Then: Verify the real command structure\n        assertThat(tapCommand.selector.textRegex).isEqualTo(\"Submit\")\n        assertThat(tapCommand.selector.enabled).isTrue()\n        assertThat(tapCommand.relativePoint).isEqualTo(\"25%, 75%\")\n        assertThat(tapCommand.originalDescription).isEqualTo(\"Tap on \\\"Submit\\\", enabled at 25%, 75%\")\n    }\n\n    @Test\n    fun `element-relative tap with index selector`(\n        @YamlFile(\"029_element_relative_tap_index.yaml\") commands: List<Command>\n    ) {\n        // Given: YAML command parsed by real YamlCommandReader\n        val tapCommand = commands[1] as TapOnElementCommand\n\n        // Then: Verify the real command structure\n        assertThat(tapCommand.selector.textRegex).isEqualTo(\"Button\")\n        assertThat(tapCommand.selector.index).isEqualTo(\"2\")\n        assertThat(tapCommand.relativePoint).isEqualTo(\"50%, 90%\")\n        assertThat(tapCommand.originalDescription).isEqualTo(\"Tap on \\\"Button\\\", Index: 2 at 50%, 90%\")\n    }\n\n    @Test\n    fun `element-relative tap with label`(\n        @YamlFile(\"029_element_relative_tap_label.yaml\") commands: List<Command>\n    ) {\n        // Given: YAML command parsed by real YamlCommandReader\n        val tapCommand = commands[1] as TapOnElementCommand\n\n        // Then: Verify the real command structure\n        assertThat(tapCommand.selector.textRegex).isEqualTo(\"Login\")\n        assertThat(tapCommand.relativePoint).isEqualTo(\"50%, 90%\")\n        assertThat(tapCommand.label).isEqualTo(\"Tap Login Button at Bottom\")\n        assertThat(tapCommand.originalDescription).isEqualTo(\"Tap on \\\"Login\\\" at 50%, 90%\")\n        assertThat(tapCommand.description()).isEqualTo(\"Tap Login Button at Bottom\")\n    }\n\n    @Test\n    fun `pure point tap (no element selector) - should create TapOnPointV2Command`(\n        @YamlFile(\"029_pure_point_tap.yaml\") commands: List<Command>\n    ) {\n        // Given: YAML command parsed by real YamlCommandReader\n        val pointCommand = commands[1] as TapOnPointV2Command\n\n        // Then: Verify the real command structure\n        assertThat(pointCommand.point).isEqualTo(\"50%, 90%\")\n        assertThat(pointCommand.retryIfNoChange).isFalse() // YAML parsing sets default values\n        assertThat(pointCommand.longPress).isFalse() // YAML parsing sets default values\n        assertThat(pointCommand.originalDescription).isEqualTo(\"Tap on point (50%, 90%)\")\n    }\n\n    @Test\n    fun `regular element tap (no point) - should create TapOnElementCommand without relativePoint`(\n        @YamlFile(\"029_regular_element_tap.yaml\") commands: List<Command>\n    ) {\n        // Given: YAML command parsed by real YamlCommandReader\n        val tapCommand = commands[1] as TapOnElementCommand\n\n        // Then: Verify the real command structure\n        assertThat(tapCommand.selector.textRegex).isEqualTo(\"Submit\")\n        assertThat(tapCommand.relativePoint).isNull()\n        assertThat(tapCommand.originalDescription).isEqualTo(\"Tap on \\\"Submit\\\"\")\n    }\n\n    @Test\n    fun `element-relative tap with repeat - should support both relativePoint and repeat`(\n        @YamlFile(\"029_element_relative_tap_with_repeat.yaml\") commands: List<Command>\n    ) {\n        // Given: YAML command parsed by real YamlCommandReader\n        val tapCommand = commands[1] as TapOnElementCommand\n\n        // Then: Verify the real command structure\n        assertThat(tapCommand.selector.textRegex).isEqualTo(\"Submit\")\n        assertThat(tapCommand.relativePoint).isEqualTo(\"50%, 90%\")\n        assertThat(tapCommand.repeat).isNotNull()\n        assertThat(tapCommand.repeat?.repeat).isEqualTo(3)\n        assertThat(tapCommand.repeat?.delay).isEqualTo(100L)\n        assertThat(tapCommand.retryIfNoChange).isFalse()\n        assertThat(tapCommand.waitUntilVisible).isFalse()\n        assertThat(tapCommand.longPress).isFalse()\n        assertThat(tapCommand.optional).isFalse()\n\n        // Verify the original description includes both the point and repeat info\n        assertThat(tapCommand.originalDescription).isEqualTo(\"Tap x3 on \\\"Submit\\\" at 50%, 90%\")\n    }\n\n    @Test\n    fun `doubleTapOn with element-relative coordinates - should support both doubleTap and relativePoint`(\n        @YamlFile(\"029_double_tap_element_relative.yaml\") commands: List<Command>\n    ) {\n        // Given: YAML command parsed by real YamlCommandReader\n        val tapCommand = commands[1] as TapOnElementCommand\n\n        // Then: Verify the real command structure\n        assertThat(tapCommand.selector.textRegex).isEqualTo(\"Submit\")\n        assertThat(tapCommand.relativePoint).isEqualTo(\"50%, 90%\")\n        assertThat(tapCommand.repeat).isNotNull()\n        assertThat(tapCommand.repeat?.repeat).isEqualTo(2)\n        assertThat(tapCommand.repeat?.delay).isEqualTo(TapOnElementCommand.DEFAULT_REPEAT_DELAY)\n        assertThat(tapCommand.retryIfNoChange).isFalse()\n        assertThat(tapCommand.waitUntilVisible).isFalse()\n        assertThat(tapCommand.longPress).isFalse()\n        assertThat(tapCommand.optional).isFalse()\n\n        // Verify the original description includes both the point and double-tap info\n        assertThat(tapCommand.originalDescription).isEqualTo(\"Double tap on \\\"Submit\\\" at 50%, 90%\")\n    }\n\n    @Test\n    fun setPermissions(\n        @YamlFile(\"030_setPermissions.yaml\") commands: List<Command>\n    ) {\n        assertThat(commands).containsExactly(\n            ApplyConfigurationCommand(MaestroConfig(\n                appId = \"com.example.app\",\n            )),\n            SetPermissionsCommand(\n                appId = \"com.example.app\",\n                permissions = mapOf(\"all\" to \"deny\", \"notifications\" to \"unset\")\n            ),\n        )\n    }\n\n    @Test\n    fun `setOrientation with literal and env variable value`(\n        @YamlFile(\"031_setOrientation.yaml\") commands: List<Command>\n    ) {\n        assertThat(commands).containsExactly(\n            ApplyConfigurationCommand(MaestroConfig(\n                appId = \"com.example.app\",\n            )),\n            DefineVariablesCommand(\n                env = mapOf(\"orientation_portrait\" to \"PORTRAIT\")\n            ),\n            SetOrientationCommand(\n                orientation = $$\"${orientation_portrait}\"\n            ),\n            SetOrientationCommand(\n                orientation = DeviceOrientation.LANDSCAPE_LEFT\n            ),\n        )\n\n        assertThat((commands[3] as SetOrientationCommand).resolvedOrientation())\n            .isEqualTo(DeviceOrientation.LANDSCAPE_LEFT)\n    }\n\n    @Test\n    fun `setOrientation with invalid env variable value`(\n        @YamlFile(\"032_setOrientation_error.yaml\") commands: List<Command>\n    ) {\n        assertThat(commands).containsExactly(\n            ApplyConfigurationCommand(MaestroConfig(\n                appId = \"com.example.app\",\n            )),\n            DefineVariablesCommand(\n                env = mapOf(\"orientation\" to \"invalid_orientation\")\n            ),\n            SetOrientationCommand(\n                orientation = $$\"${orientation}\"\n            )\n        )\n\n        val error = assertThrows(IllegalStateException::class.java) {\n            (commands[2] as SetOrientationCommand).resolvedOrientation()\n        }\n        assertThat(error).hasMessageThat().contains(\"Unknown orientation: \\${orientation}\")\n    }\n\n\n    private fun commands(vararg commands: Command): List<MaestroCommand> =\n        commands.map(::MaestroCommand).toList()\n}\n"
  },
  {
    "path": "maestro-orchestra/src/test/java/maestro/orchestra/yaml/junit/YamlCommandsExtension.kt",
    "content": "package maestro.orchestra.yaml.junit\n\nimport maestro.orchestra.Command\nimport maestro.orchestra.MaestroCommand\nimport maestro.orchestra.yaml.YamlCommandReader\nimport org.junit.jupiter.api.extension.ExtensionContext\nimport org.junit.jupiter.api.extension.ParameterContext\nimport org.junit.jupiter.api.extension.ParameterResolver\n\nclass YamlCommandsExtension : ParameterResolver {\n    private interface ListOfCommands : List<Command>\n\n    override fun supportsParameter(\n        parameterContext: ParameterContext,\n        extensionContext: ExtensionContext,\n    ): Boolean {\n        val parameterizedType = parameterContext.parameter\n            .parameterizedType.typeName.replace(\"? extends \", \"\")\n        val supportedType = ListOfCommands::class.java\n            .genericInterfaces.first().typeName\n        return parameterizedType == supportedType\n    }\n\n    override fun resolveParameter(\n        parameterContext: ParameterContext,\n        extensionContext: ExtensionContext,\n    ): Any {\n        val yamlFileAnnotation = parameterContext.findAnnotation(YamlFile::class.java)\n            .orElseThrow { IllegalArgumentException(\"No @YamlFile annotation found\") }\n\n        return YamlCommandReader.readCommands(YamlResourceFile(yamlFileAnnotation.name).path)\n            .map(MaestroCommand::asCommand)\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/test/java/maestro/orchestra/yaml/junit/YamlFile.kt",
    "content": "package maestro.orchestra.yaml.junit\n\n@Retention(AnnotationRetention.RUNTIME)\n@Target(AnnotationTarget.VALUE_PARAMETER)\nannotation class YamlFile(val name: String)\n"
  },
  {
    "path": "maestro-orchestra/src/test/java/maestro/orchestra/yaml/junit/YamlResourceFile.kt",
    "content": "package maestro.orchestra.yaml.junit\n\nimport java.nio.file.Path\nimport java.nio.file.Paths\n\nclass YamlResourceFile(val name: String) {\n    val path: Path get() {\n        val resource = this::class.java.getResource(\"/YamlCommandReaderTest/${name}\")!!\n        return Paths.get(resource.toURI())\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/002_launchApp.yaml",
    "content": "appId: com.example.app\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/003_launchApp_withClearState.yaml",
    "content": "appId: com.example.app\n---\n- launchApp:\n    clearState: true\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/008_config_unknownKeys.yaml",
    "content": "appId: com.example.app\nextra: True\nextraMap:\n  keyA: valueB\nextraArray:\n  - itemA\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/017_launchApp_otherPackage.yaml",
    "content": "appId: com.example.app\n---\n- launchApp: com.other.app\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/018_backPress_string.yaml",
    "content": "appId: com.example.app\n---\n- back\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/019_scroll_string.yaml",
    "content": "appId: com.example.app\n---\n- scroll\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/020_config_name.yaml",
    "content": "name: Example Flow\nappId: com.example.app\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/022_on_flow_start_complete.yaml",
    "content": "appId: com.example.app\nonFlowStart:\n  - back\nonFlowComplete:\n  - scroll\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/023_labels.yaml",
    "content": "appId: com.example.app\n---\n# Taps\n- tapOn:\n    id: \"foo\"\n    label: \"Tap on the important button\"\n- doubleTapOn:\n    id: \"foo\"\n    label: \"Tap on the important button twice\"\n- longPressOn:\n    id: \"foo\"\n    label: \"Press and hold the important button\"\n- tapOn:\n    point: 50%,50%\n    label: \"Tap on the middle of the screen\"\n\n# Assertions\n- assertVisible:\n    id: \"bar\"\n    label: \"Check that the important number is visible\"\n- assertNotVisible:\n    id: \"bar2\"\n    label: \"Check that the secret number is invisible\"\n- assertTrue:\n    condition: ${5 == 5}\n    label: \"Check that five is still what we think it is\"\n\n# Inputs\n- inputText:\n      text: \"correct horse battery staple\"\n      label: \"Enter my secret password\"\n- inputRandomEmail:\n      label: \"Enter a random email address\"\n- inputRandomPersonName:\n      label: \"Enter a random person's name\"\n- inputRandomNumber:\n      length: 5\n      label: \"Enter a random number\"\n- inputRandomText:\n      length: 20\n      label: \"Enter a random string\"\n- pressKey:\n      key: \"enter\"\n      label: \"Press the enter key\"\n\n# Other\n- back:\n    label: \"Go back to the previous screen\"\n- clearKeychain:\n    label: \"Clear the keychain\"\n- clearState:\n    label: \"Wipe the app state\"\n- copyTextFrom:\n    id: \"foo\"\n    label: \"Copy the important text\"\n- eraseText:\n    charactersToErase: 5\n    label: \"Erase the last 5 characters\"\n- extendedWaitUntil:\n    visible: \"Some important text\"\n    timeout: 1000\n    label: \"Wait until the important text is visible\"\n- evalScript:\n    script: \"return 5;\"\n    label: \"Get the number 5\"\n- hideKeyboard:\n    label: \"Hide the keyboard\"\n- launchApp:\n    appId: \"com.some.other\"\n    clearState: true\n    label: \"Launch some other app\"\n- openLink:\n    link: \"https://www.example.com\"\n    label: \"Open the example website\"\n- pasteText:\n    label: \"Paste the important text\"\n- runFlow:\n      commands:\n          - assertTrue: ${5 == 5}\n      label: \"Check that five is still what we think it is\"\n- runScript:\n    file: \"023_runScript_test.js\"\n    label: \"Run some special calculations\"\n- setOrientation:\n    orientation: \"LANDSCAPE_LEFT\"\n    label: \"Set the device orientation\"\n- scroll:\n    label: \"Scroll down\"\n- scrollUntilVisible:\n    element: \"Footer\"\n    label: \"Scroll to the bottom\"\n- setLocation:\n    latitude: 12.5266\n    longitude: 78.2150\n    label: \"Set Location to Test Laboratory\"\n- startRecording:\n    path: \"recording.mp4\"\n    label: \"Start recording a video\"\n- stopApp:\n    appId: \"com.some.other\"\n    label: \"Stop that other app from running\"\n- stopRecording:\n    label: \"Stop recording the video\"\n- takeScreenshot:\n    path: \"baz\"\n    label: \"Snap this for later evaluation\"\n- travel:\n      points:\n          - 0.0,0.0\n          - 0.1,0.0\n          - 0.1,0.1\n          - 0.0,0.1\n      speed: 2000\n      label: \"Run around the north pole\"\n- waitForAnimationToEnd:\n    timeout: 4000\n    label: \"Wait for the thing to stop spinning\"\n- swipe:\n    direction: DOWN\n    label: \"Swipe down a bit\"\n- addMedia:\n      files:\n          - \"023_image.png\"\n      label: \"Add a picture to the device\"\n- setAirplaneMode:\n      value: enabled\n      label: \"Turn on airplane mode for testing\"\n- toggleAirplaneMode:\n      label: \"Toggle airplane mode for testing\"\n\n# Repeats\n- repeat:\n    while:\n      visible: \"Some important text\"\n    commands:\n        - tapOn:\n            id: \"foo\"\n            label: \"Tap on the important button\"\n        - tapOn:\n            id: \"bar\"\n            label: \"Tap on the other important button\"\n    label: \"Tap the 2 buttons until the text goes away\"\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/023_runScript_test.js",
    "content": "const myNumber = 1 + 1;"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/024_string_non_string_commands.yaml",
    "content": "appId: com.example.app\n---\n- inputText: \"correct horse battery staple\"\n- inputText: correct horse battery staple\n- inputText: 4\n- inputText: false\n- inputText: 1683113805263\n- inputText: 4.12\n- assertTrue: true\n- assertTrue: 323\n- evalScript: true\n- evalScript: 2 + 1\n- evalScript: 2\n- evalScript: false == false\n- tapOn: \"Hello\"\n- doubleTapOn: \"Hello\"\n- longPressOn: \"Hello\"\n- assertVisible: \"Hello\"\n- copyTextFrom: \"Hello\"\n- back\n- action: back\n- hideKeyboard\n- action: hideKeyboard\n- scroll\n- action: scroll\n- clearKeychain\n- action: clearKeychain\n- pasteText\n- action: pasteText\n\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/025_killApp.yaml",
    "content": "appId: com.example.app\n---\n- killApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/027_waitToSettleTimeoutMs.yaml",
    "content": "appId: com.example.app\n---\n- scrollUntilVisible:\n    element:\n      id: \"maybe-later\"\n    waitToSettleTimeoutMs: 50\n    direction: DOWN\n- swipe:\n    start: 90%, 50%\n    end: 10%, 50%\n    waitToSettleTimeoutMs: 50\n- swipe:\n    direction: LEFT\n    waitToSettleTimeoutMs: 50\n- swipe:\n    from:\n      id: \"feeditem_identifier\"\n    direction: LEFT\n    waitToSettleTimeoutMs: 50\n- swipe:\n    start: 100, 200\n    end: 300, 400\n    waitToSettleTimeoutMs: 50\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/028_inputRandomAnimal.yaml",
    "content": "appId: com.example.app\n---\n- inputRandom:\n      type: 'animal.name'\n      label: 'Input a random animal name'"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/029_command_descriptions.yaml",
    "content": "appId: com.example.app\n---\n- tapOn:\n    text: \"maybe-later\"\n    label: \"Scroll to find the maybe-later button\"\n\n- inputText:\n    text: ${username}\n\n- assertVisible:\n    text: \"Hello ${username}\"\n\n- assertTrue:\n    condition: ${true}\n    label: ${\"Check that\".concat(\" \", \"true is still true\")}"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/029_double_tap_element_relative.yaml",
    "content": "appId: com.example.test\n---\n- doubleTapOn:\n    text: Submit\n    point: 50%, 90%\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/029_element_relative_tap_css.yaml",
    "content": "appId: com.example.test\n---\n- tapOn:\n    css: .submit-button\n    point: 75%, 25%\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/029_element_relative_tap_enabled.yaml",
    "content": "appId: com.example.test\n---\n- tapOn:\n    text: Submit\n    enabled: true\n    point: 25%, 75%\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/029_element_relative_tap_id_absolute.yaml",
    "content": "appId: com.example.test\n---\n- tapOn:\n    id: submit-btn\n    point: 25, 75\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/029_element_relative_tap_index.yaml",
    "content": "appId: com.example.test\n---\n- tapOn:\n    text: Button\n    index: 2\n    point: 50%, 90%\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/029_element_relative_tap_label.yaml",
    "content": "appId: com.example.test\n---\n- tapOn:\n    text: Login\n    point: 50%, 90%\n    label: 'Tap Login Button at Bottom'\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/029_element_relative_tap_size.yaml",
    "content": "appId: com.example.test\n---\n- tapOn:\n    width: 200\n    height: 50\n    point: 50%, 50%\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/029_element_relative_tap_text_percentage.yaml",
    "content": "appId: com.example.test\n---\n- tapOn:\n    text: Submit\n    point: 50%, 90%\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/029_element_relative_tap_with_repeat.yaml",
    "content": "appId: com.example.test\n---\n- tapOn:\n    text: Submit\n    point: 50%, 90%\n    repeat: 3\n    delay: 100\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/029_pure_point_tap.yaml",
    "content": "appId: com.example.test\n---\n- tapOn:\n    point: 50%, 90%\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/029_regular_element_tap.yaml",
    "content": "appId: com.example.test\n---\n- tapOn:\n    text: Submit\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/030_setPermissions.yaml",
    "content": "appId: com.example.app\n---\n- setPermissions:\n    permissions:\n      all: deny\n      notifications: unset\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/031_setOrientation.yaml",
    "content": "appId: com.example.app\nenv:\n  orientation_portrait: PORTRAIT\n---\n- setOrientation: ${orientation_portrait}\n- setOrientation: LANDSCAPE_LEFT"
  },
  {
    "path": "maestro-orchestra/src/test/resources/YamlCommandReaderTest/032_setOrientation_error.yaml",
    "content": "appId: com.example.app\nenv:\n  orientation: invalid_orientation\n---\n- setOrientation: ${orientation}"
  },
  {
    "path": "maestro-orchestra/src/test/resources/media/android/add_media_gif.yaml",
    "content": "appId: com.google.android.apps.photos\n---\n- addMedia:\n    - \"../assets/android_gif.gif\"\n- launchApp:\n    appId: com.google.android.apps.photos\n- runFlow:\n    when:\n      visible: Update Now\n    commands:\n      - tapOn:\n          text: Update Now\n          optional: true\n      - back\n# assert that photo is taken\n- assertVisible: \"Photo taken on.*\"\n- tapOn: \"Photo taken on.*\""
  },
  {
    "path": "maestro-orchestra/src/test/resources/media/android/add_media_jpeg.yaml",
    "content": "appId: com.google.android.apps.photos\n---\n- addMedia:\n    - \"../assets/android_jpeg.jpeg\"\n- launchApp:\n    appId: com.google.android.apps.photos\n- runFlow:\n    when:\n      visible: Update Now\n    commands:\n      - tapOn:\n          text: Update Now\n          optional: true\n      - back\n# assert that photo is taken\n- assertVisible: \"Photo taken on.*\"\n- tapOn: \"Photo taken on.*\""
  },
  {
    "path": "maestro-orchestra/src/test/resources/media/android/add_media_jpg.yaml",
    "content": "appId: com.google.android.apps.photos\n---\n- addMedia:\n    - \"../assets/android_jpg.jpg\"\n- launchApp:\n    appId: com.google.android.apps.photos\n- runFlow:\n    when:\n      visible: Update Now\n    commands:\n      - tapOn:\n          text: Update Now\n          optional: true\n      - back\n# assert that photo is taken\n- tapOn: \"Photo taken on.*\""
  },
  {
    "path": "maestro-orchestra/src/test/resources/media/android/add_media_mp4.yaml",
    "content": "appId: com.google.android.apps.photos\n---\n- addMedia:\n    - \"../assets/sample_video.mp4\"\n- launchApp:\n    appId: com.google.android.apps.photos\n- runFlow:\n    when:\n      visible: Update Now\n    commands:\n      - tapOn:\n          text: Update Now\n          optional: true\n      - back\n# assert that photo is taken\n- assertVisible: \"Video taken on.*\"\n- tapOn: \"Video taken on.*\""
  },
  {
    "path": "maestro-orchestra/src/test/resources/media/android/add_media_png.yaml",
    "content": "appId: com.google.android.apps.photos\n---\n- addMedia:\n    - \"../assets/android.png\"\n- launchApp:\n    appId: com.google.android.apps.photos\n- runFlow:\n      when:\n          visible: Update Now\n      commands:\n          - tapOn:\n                text: Update Now\n                optional: true\n          - back\n# assert that photo is taken\n- assertVisible: \"Photo taken on.*\"\n- tapOn: \"Photo taken on.*\""
  },
  {
    "path": "maestro-orchestra/src/test/resources/media/android/add_multiple_media.yaml",
    "content": "appId: com.google.android.apps.photos\n---\n- addMedia:\n  - \"../assets/android.png\"\n  - \"../assets/android_gif.gif\"\n  - \"../assets/sample_video.mp4\"\n- launchApp:\n    appId: com.google.android.apps.photos\n- runFlow:\n    when:\n      visible: Update Now\n    commands:\n      - tapOn:\n          text: Update Now\n          optional: true\n      - back\n# assert that photo is taken\n- assertVisible:\n    text: \"Photo taken on.*\"\n    optional: true\n- tapOn:\n    text: \"Photo taken on.*\"\n    optional: true\n- back\n- assertVisible:\n    text: \"Photo taken on.*\"\n    optional: true\n- tapOn:\n    text: \"Photo taken on.*\"\n    optional: true\n- back\n- assertVisible:\n    text: \"Video taken on.*\"\n    optional: true\n- tapOn:\n    text: \"Video taken on.*\"\n    optional: true"
  },
  {
    "path": "maestro-orchestra/src/test/resources/media/ios/add_media_gif.yaml",
    "content": "appId: com.apple.mobileslideshow\n---\n- addMedia:\n    - \"./maestro-orchestra/src/test/resources/media/assets/android.gif\"\n- launchApp\n- assertVisible: All Photos"
  },
  {
    "path": "maestro-orchestra/src/test/resources/media/ios/add_media_jpeg.yaml",
    "content": "appId: com.apple.mobileslideshow\n---\n- addMedia:\n    - \"../assets/android_jpeg.jpeg\"\n- launchApp\n- assertVisible: All Photos"
  },
  {
    "path": "maestro-orchestra/src/test/resources/media/ios/add_media_jpg.yaml",
    "content": "appId: com.apple.mobileslideshow\n---\n- addMedia:\n    - \"./maestro-orchestra/src/test/resources/media/assets/android_jpg.jpg\"\n- launchApp\n- assertVisible: All Photos"
  },
  {
    "path": "maestro-orchestra/src/test/resources/media/ios/add_media_mp4.yaml",
    "content": "appId: com.apple.mobileslideshow\n---\n- addMedia:\n    - \"./maestro-orchestra/src/test/resources/media/assets/sample_video.mp4\"\n- launchApp\n- assertVisible: All Photos"
  },
  {
    "path": "maestro-orchestra/src/test/resources/media/ios/add_media_png.yaml",
    "content": "appId: com.apple.mobileslideshow\n---\n- addMedia:\n    - \"./maestro-orchestra/src/test/resources/media/assets/android.png\"\n- launchApp\n- assertVisible: All Photos"
  },
  {
    "path": "maestro-orchestra/src/test/resources/media/ios/add_multiple_media.yaml",
    "content": "appId: com.apple.mobileslideshow\n---\n- addMedia:\n    - \"./maestro-orchestra/src/test/resources/media/assets/android.gif\"\n    - \"./maestro-orchestra/src/test/resources/media/assets/android_jpeg.jpeg\"\n    - \"./maestro-orchestra/src/test/resources/media/assets/sample_video.mp4\"\n    - \"./maestro-orchestra/src/test/resources/media/assets/android.png\"\n- launchApp\n- assertVisible: All Photos\n- tapOn:\n      point: \"16%,19%\"\n- tapOn:\n      point: \"50%,19%\"\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/.gitignore",
    "content": "error.actual.txt"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/000_individual_file/flow.yaml",
    "content": "appId: com.example.app\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/001_simple/flowA.yaml",
    "content": "appId: com.example.app\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/001_simple/flowB.yaml",
    "content": "appId: com.example.app\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/001_simple/notAFlow.txt",
    "content": "This file is not a flow"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/002_subflows/flowA.yaml",
    "content": "appId: com.example.app\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/002_subflows/flowB.yaml",
    "content": "appId: com.example.app\n---\n- launchApp\n- runFlow: subflows/subflow.yaml"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/002_subflows/subflows/subflow.yaml",
    "content": "appId: com.example.app\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/003_include_tags/flowA.yaml",
    "content": "appId: com.example.app\ntags:\n  - included\n  - someOtherTag\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/003_include_tags/flowB.yaml",
    "content": "appId: com.example.app\ntags:\n  - notIncluded\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/003_include_tags/flowC.yaml",
    "content": "appId: com.example.app\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/004_exclude_tags/flowA.yaml",
    "content": "appId: com.example.app\ntags:\n  - included\n  - someOtherTag\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/004_exclude_tags/flowB.yaml",
    "content": "appId: com.example.app\ntags:\n  - excluded\n  - someOtherTag\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/004_exclude_tags/flowC.yaml",
    "content": "appId: com.example.app\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/005_custom_include_pattern/config.yaml",
    "content": "flows:\n  - featureA/*\n  - featureB/*"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/005_custom_include_pattern/featureA/flowA.yaml",
    "content": "appId: com.example.app\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/005_custom_include_pattern/featureB/flowB.yaml",
    "content": "appId: com.example.app\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/005_custom_include_pattern/featureC/flowC.yaml",
    "content": "appId: com.example.app\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/005_custom_include_pattern/flowD.yaml",
    "content": "appId: com.example.app\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/006_include_subfolders/config.yaml",
    "content": "flows:\n  - \"**\""
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/006_include_subfolders/featureA/flowA.yaml",
    "content": "appId: com.example.app\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/006_include_subfolders/featureB/flowB.yaml",
    "content": "appId: com.example.app\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/006_include_subfolders/featureC/subfolder/flowC.yaml",
    "content": "appId: com.example.app\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/006_include_subfolders/flowD.yaml",
    "content": "appId: com.example.app\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/007_empty_config/config.yml",
    "content": ""
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/007_empty_config/flowA.yaml",
    "content": "appId: com.example.app\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/007_empty_config/flowB.yaml",
    "content": "appId: com.example.app\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/008_literal_pattern/config.yaml",
    "content": "flows: featureA/*"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/008_literal_pattern/featureA/flowA.yaml",
    "content": "appId: com.example.app\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/008_literal_pattern/featureB/flowB.yaml",
    "content": "appId: com.example.app\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/009_custom_config_fields/config.yml",
    "content": "customField: Custom Value"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/009_custom_config_fields/flowA.yaml",
    "content": "appId: com.example.app\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/009_custom_config_fields/flowB.yaml",
    "content": "appId: com.example.app\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/010_global_include_tags/config.yaml",
    "content": "includeTags:\n  - featureA"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/010_global_include_tags/flowA.yaml",
    "content": "appId: com.example.app\ntags:\n  - included\n  - featureA\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/010_global_include_tags/flowA_subflow.yaml",
    "content": "appId: com.example.app\ntags:\n  - featureA\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/010_global_include_tags/flowB.yaml",
    "content": "appId: com.example.app\ntags:\n  - included\n  - featureB\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/010_global_include_tags/flowC.yaml",
    "content": "appId: com.example.app\ntags:\n  - included\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/010_global_include_tags/flowD.yaml",
    "content": "appId: com.example.app\ntags:\n  - notIncluded\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/010_global_include_tags/flowE.yaml",
    "content": "appId: com.example.app\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/011_global_exclude_tags/config.yaml",
    "content": "excludeTags: notIncluded"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/011_global_exclude_tags/flowA.yaml",
    "content": "appId: com.example.app\ntags:\n  - included\n  - featureA\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/011_global_exclude_tags/flowA_subflow.yaml",
    "content": "appId: com.example.app\ntags:\n  - featureA\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/011_global_exclude_tags/flowB.yaml",
    "content": "appId: com.example.app\ntags:\n  - included\n  - featureB\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/011_global_exclude_tags/flowC.yaml",
    "content": "appId: com.example.app\ntags:\n  - included\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/011_global_exclude_tags/flowD.yaml",
    "content": "appId: com.example.app\ntags:\n  - notIncluded\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/011_global_exclude_tags/flowE.yaml",
    "content": "appId: com.example.app\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/013_execution_order/config.yaml",
    "content": "executionOrder:\n  flowsOrder:\n    - flowB\n    - flowC\n    - flowD"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/013_execution_order/flowA.yaml",
    "content": "appId: com.example.app\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/013_execution_order/flowB.yaml",
    "content": "appId: com.example.app\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/013_execution_order/flowCWithCustomName.yaml",
    "content": "appId: com.example.app\nname: flowC\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/013_execution_order/flowD.yaml",
    "content": "appId: com.example.app\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/014_config_not_null/config/another_config.yaml",
    "content": "includeTags:\n  - included\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/014_config_not_null/config.yaml",
    "content": "includeTags:\n  - included\n  - excluded\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/014_config_not_null/flowA.yaml",
    "content": "appId: com.example.app\ntags:\n  - included\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/014_config_not_null/flowB.yaml",
    "content": "appId: com.example.app\ntags:\n  - excluded\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/015_workspace_cloud_configs/config.yaml",
    "content": "excludeTags: notIncluded\nincludeTags: included\nnotifications:\n  email:\n      recipients:\n        - \"abc@mobile.dev\"\n  slack:\n      channels:\n        - \"e2e-testing\"\n      apiKey: \"slack-api-key\"\ndisableRetries: true\nexecutionOrder:\n  flowsOrder:\n    - flowA\n    - flowB\nplatform:\n  ios:\n    disableAnimations: true\n    snapshotKeyHonorModalViews: false\n  android:\n    disableAnimations: true"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/015_workspace_cloud_configs/flowA.yaml",
    "content": "appId: com.example.app\ntags:\n  - included\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/015_workspace_cloud_configs/flowB.yaml",
    "content": "appId: com.example.app\ntags:\n  - included\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e000_flow_path_does_not_exist/error.txt",
    "content": "Flow path does not exist: /tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e001_directory_does_not_contain_flow_files/error.txt",
    "content": "Flow directories do not contain any Flow files: /tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e001_directory_does_not_contain_flow_files/workspace/dummy",
    "content": ""
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e002_top_level_directory_does_not_contain_flow_files/error.txt",
    "content": "Top-level directories do not contain any Flows: /tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace\nTo configure Maestro to run Flows in subdirectories, check out the following resources:\n  * https://maestro.mobile.dev/cli/test-suites-and-reports#inclusion-patterns\n  * https://blog.mobile.dev/maestro-best-practices-structuring-your-test-suite-54ec390c5c82"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e002_top_level_directory_does_not_contain_flow_files/workspace/subdir/Flow.yaml",
    "content": "appId: com.example\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e003_flow_inclusion_pattern_does_not_match_any_flow_files/error.txt",
    "content": "Flow inclusion pattern(s) did not match any Flow files:\n- FlowA.yaml\n- FlowB.yaml"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e003_flow_inclusion_pattern_does_not_match_any_flow_files/workspace/FlowC.yaml",
    "content": "appId: com.example\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e003_flow_inclusion_pattern_does_not_match_any_flow_files/workspace/config.yaml",
    "content": "flows:\n  - FlowA.yaml\n  - FlowB.yaml"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e004_tags_config_does_not_match_any_flow_files/error.txt",
    "content": "Include / Exclude tags did not match any Flows:\n\nInclude Tags:\n- parameterInclude\n- configInclude\n\nExclude Tags:\n- parameterExclude\n- configExclude"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e004_tags_config_does_not_match_any_flow_files/excludeTags.txt",
    "content": "parameterExclude"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e004_tags_config_does_not_match_any_flow_files/includeTags.txt",
    "content": "parameterInclude"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e004_tags_config_does_not_match_any_flow_files/workspace/ConfigExclude.yaml",
    "content": "tags:\n  - configInclude\n  - configExclude\nappId: com.example\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e004_tags_config_does_not_match_any_flow_files/workspace/ParameterExclude.yaml",
    "content": "tags:\n  - parameterInclude\n  - parameterExclude\nappId: com.example\n---\n- launchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e004_tags_config_does_not_match_any_flow_files/workspace/config.yaml",
    "content": "includeTags:\n  - configInclude\nexcludeTags:\n  - configExclude"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e005_single_flow_does_not_exist/error.txt",
    "content": "Flow path does not exist: /tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e005_single_flow_does_not_exist/singleFlow.txt",
    "content": "Flow.yaml"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e006_single_flow_invalid_string_command/error.txt",
    "content": "> Invalid Command: invalidCommand\n\n/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml:3\n╭───────────────────────────────────────────────────────╮\n│ 1 | appId: com.example                                │\n│ 2 | ---                                               │\n│ 3 | - invalidCommand                                  │\n│                     ^                                 │\n│ ╭───────────────────────────────────────────────────╮ │\n│ │ `invalidCommand` is not a valid command.          │ │\n│ │                                                   │ │\n│ │ > https://docs.maestro.dev/api-reference/commands │ │\n│ ╰───────────────────────────────────────────────────╯ │\n╰───────────────────────────────────────────────────────╯"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e006_single_flow_invalid_string_command/singleFlow.txt",
    "content": "Flow.yaml"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e006_single_flow_invalid_string_command/workspace/Flow.yaml",
    "content": "appId: com.example\n---\n- invalidCommand"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e007_single_flow_malformatted_command/error.txt",
    "content": "> Unknown Property: invalidOption\n\n/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml:4\n╭────────────────────────────────────────────────────╮\n│ 2 | ---                                            │\n│ 3 | - launchApp:                                   │\n│ 4 |     invalidOption: true                        │\n│                            ^                       │\n│ ╭────────────────────────────────────────────────╮ │\n│ │ The property `invalidOption` is not recognized │ │\n│ ╰────────────────────────────────────────────────╯ │\n╰────────────────────────────────────────────────────╯"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e007_single_flow_malformatted_command/singleFlow.txt",
    "content": "Flow.yaml"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e007_single_flow_malformatted_command/workspace/Flow.yaml",
    "content": "appId: com.example\n---\n- launchApp:\n    invalidOption: true"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e008_subflow_invalid_string_command/error.txt",
    "content": "> Invalid Command: invalidCommand\n\n/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/./subflow/SubFlow.yaml:3\n╭───────────────────────────────────────────────────────╮\n│ 1 | appId: com.example                                │\n│ 2 | ---                                               │\n│ 3 | - invalidCommand                                  │\n│                     ^                                 │\n│ ╭───────────────────────────────────────────────────╮ │\n│ │ `invalidCommand` is not a valid command.          │ │\n│ │                                                   │ │\n│ │ > https://docs.maestro.dev/api-reference/commands │ │\n│ ╰───────────────────────────────────────────────────╯ │\n╰───────────────────────────────────────────────────────╯"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e008_subflow_invalid_string_command/workspace/Flow.yaml",
    "content": "appId: com.example\n---\n- runFlow: ./subflow/SubFlow.yaml\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e008_subflow_invalid_string_command/workspace/subflow/SubFlow.yaml",
    "content": "appId: com.example\n---\n- invalidCommand"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e009_nested_subflow_invalid_string_command/error.txt",
    "content": "> Invalid Command: invalidCommand\n\n/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/./subflow/./SubFlowB.yaml:3\n╭───────────────────────────────────────────────────────╮\n│ 1 | appId: com.example                                │\n│ 2 | ---                                               │\n│ 3 | - invalidCommand                                  │\n│                     ^                                 │\n│ ╭───────────────────────────────────────────────────╮ │\n│ │ `invalidCommand` is not a valid command.          │ │\n│ │                                                   │ │\n│ │ > https://docs.maestro.dev/api-reference/commands │ │\n│ ╰───────────────────────────────────────────────────╯ │\n╰───────────────────────────────────────────────────────╯"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e009_nested_subflow_invalid_string_command/workspace/Flow.yaml",
    "content": "appId: com.example\n---\n- runFlow: ./subflow/SubFlowA.yaml\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e009_nested_subflow_invalid_string_command/workspace/subflow/SubFlowA.yaml",
    "content": "appId: com.example\n---\n- runFlow: ./SubFlowB.yaml"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e009_nested_subflow_invalid_string_command/workspace/subflow/SubFlowB.yaml",
    "content": "appId: com.example\n---\n- invalidCommand"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e010_missing_config_section/error.txt",
    "content": "> Config Section Required\n\n/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml:1\n╭────────────────────────────────────────────────────────────────────────╮\n│ 1 | - launchApp                                                        │\n│     ^                                                                  │\n│ ╭────────────────────────────────────────────────────────────────────╮ │\n│ │ Flow files must start with a config section. Eg:                   │ │\n│ │                                                                    │ │\n│ │ ```yaml                                                            │ │\n│ │ appId: com.example.app # <-- config section                        │ │\n│ │ ---                                                                │ │\n│ │ - launchApp                                                        │ │\n│ │ ```                                                                │ │\n│ │                                                                    │ │\n│ │ > https://docs.maestro.dev/getting-started/writing-your-first-flow │ │\n│ ╰────────────────────────────────────────────────────────────────────╯ │\n│ 2 |                                                                    │\n╰────────────────────────────────────────────────────────────────────────╯"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e010_missing_config_section/workspace/Flow.yaml",
    "content": "- launchApp\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e011_missing_dashes/error.txt",
    "content": "> Commands Section Required\n\n/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml:3\n╭───────────────────────────────────────────────────────────────────────────╮\n│ 1 | appId: com.example                                                    │\n│ 2 | ---                                                                   │\n│ 3 | launchApp                                                             │\n│              ^                                                            │\n│ ╭───────────────────────────────────────────────────────────────────────╮ │\n│ │ Flow files must have a list of commands after the config section. Eg: │ │\n│ │                                                                       │ │\n│ │ ```yaml                                                               │ │\n│ │ appId: com.example.app                                                │ │\n│ │ ---                                                                   │ │\n│ │ - launchApp                                                           │ │\n│ │ ```                                                                   │ │\n│ │                                                                       │ │\n│ │ > https://docs.maestro.dev/getting-started/writing-your-first-flow    │ │\n│ ╰───────────────────────────────────────────────────────────────────────╯ │\n╰───────────────────────────────────────────────────────────────────────────╯"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e011_missing_dashes/workspace/Flow.yaml",
    "content": "appId: com.example\n---\nlaunchApp"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e012_invalid_subflow_path/error.txt",
    "content": "> Invalid File Path\n\n/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml:3\n╭──────────────────────────────────────────────────────────────────────────────────╮\n│ 1 | appId: com.example                                                           │\n│ 2 | ---                                                                          │\n│ 3 | - runFlow: invalidpath.yaml                                                  │\n│       ^                                                                          │\n│ ╭──────────────────────────────────────────────────────────────────────────────╮ │\n│ │ Flow file does not exist:                                                    │ │\n│ │ file:///tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/invalidp │ │\n│ │ ath.yaml                                                                     │ │\n│ ╰──────────────────────────────────────────────────────────────────────────────╯ │\n╰──────────────────────────────────────────────────────────────────────────────────╯"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e012_invalid_subflow_path/workspace/Flow.yaml",
    "content": "appId: com.example\n---\n- runFlow: invalidpath.yaml"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e013_invalid_media_file/error.txt",
    "content": "> Media File Not Found\n\n/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml:3\n╭────────────────────────────────────────────────────────────────────────────────╮\n│ 1 | appId: com.example                                                         │\n│ 2 | ---                                                                        │\n│ 3 | - addMedia:                                                                │\n│       ^                                                                        │\n│ ╭────────────────────────────────────────────────────────────────────────────╮ │\n│ │ Media file at ./assets/invalid_android.png in flow file:                   │ │\n│ │ /tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml not │ │\n│ │ found                                                                      │ │\n│ ╰────────────────────────────────────────────────────────────────────────────╯ │\n│ 4 |   - \"./assets/invalid_android.png\"                                         │\n│ 5 | - launchApp                                                                │\n╰────────────────────────────────────────────────────────────────────────────────╯"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e013_invalid_media_file/workspace/Flow.yaml",
    "content": "appId: com.example\n---\n- addMedia:\n  - \"./assets/invalid_android.png\"\n- launchApp\n- tapOn: \"Add to Cart\"\n- assertVisible: \"Added\""
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e014_invalid_media_file_outside/error.txt",
    "content": "> Media File Not Found\n\n/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml:3\n╭─────────────────────────────────────────────────────────────────────────────────╮\n│ 1 | appId: com.example                                                          │\n│ 2 | ---                                                                         │\n│ 3 | - addMedia:                                                                 │\n│       ^                                                                         │\n│ ╭─────────────────────────────────────────────────────────────────────────────╮ │\n│ │ Media file at ../../e013_invalid_media_file/workspace/assets/android.png in │ │\n│ │ flow file:                                                                  │ │\n│ │ /tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml not  │ │\n│ │ found                                                                       │ │\n│ ╰─────────────────────────────────────────────────────────────────────────────╯ │\n│ 4 |     - \"../../e013_invalid_media_file/workspace/assets/android.png\"          │\n│ 5 | - launchApp                                                                 │\n╰─────────────────────────────────────────────────────────────────────────────────╯"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e014_invalid_media_file_outside/workspace/Flow.yaml",
    "content": "appId: com.example\n---\n- addMedia:\n    - \"../../e013_invalid_media_file/workspace/assets/android.png\"\n- launchApp\n- tapOn: \"Add to Cart\"\n- assertVisible: \"Added\""
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e015_array_command/error.txt",
    "content": "> Invalid Command\n\n/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml:3\n╭──────────────────────────────────────────────────────────────────────────────╮\n│ 1 | appId: com.example.app                                                   │\n│ 2 | ---                                                                      │\n│ 3 | - [foo, bar, baz]                                                        │\n│        ^                                                                     │\n│ ╭──────────────────────────────────────────────────────────────────────────╮ │\n│ │ Invalid command format. Expected: \"<commandName>: <options>\" eg. \"tapOn: │ │\n│ │ submit\"                                                                  │ │\n│ │                                                                          │ │\n│ │ > https://docs.maestro.dev/api-reference/commands                        │ │\n│ ╰──────────────────────────────────────────────────────────────────────────╯ │\n╰──────────────────────────────────────────────────────────────────────────────╯"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e015_array_command/workspace/Flow.yaml",
    "content": "appId: com.example.app\n---\n- [foo, bar, baz]"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e016_config_invalid_command_in_onFlowStart/error.txt",
    "content": "> Invalid Command: inp\n\n/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml:3\n╭────────────────────────────────────────────────────────────────────────────────╮\n│ 1 | appId: com.example.app                                                     │\n│ 2 | onFlowStart:                                                               │\n│ 3 |   - inp                                                                    │\n│            ^                                                                   │\n│ ╭────────────────────────────────────────────────────────────────────────────╮ │\n│ │ `inp` is not a valid command.                                              │ │\n│ │                                                                            │ │\n│ │ Did you mean one of: inputRandomText, inputRandomNumber, inputRandomEmail, │ │\n│ │ inputRandomPersonName, inputText, inputRandomCityName,                     │ │\n│ │ inputRandomCountryName, inputRandomColorName                               │ │\n│ │                                                                            │ │\n│ │ > https://docs.maestro.dev/api-reference/commands                          │ │\n│ ╰────────────────────────────────────────────────────────────────────────────╯ │\n│ 4 | ---                                                                        │\n╰────────────────────────────────────────────────────────────────────────────────╯"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e016_config_invalid_command_in_onFlowStart/workspace/Flow.yaml",
    "content": "appId: com.example.app\nonFlowStart:\n  - inp\n---"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e017_config_invalid_tags/error.txt",
    "content": "> Incorrect Format: tags\n\n/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml:2\n╭──────────────────────────────────────╮\n│ 1 | appId: com.example.app           │\n│ 2 | tags: foo, bar                   │\n│           ^                          │\n│ ╭──────────────────────────────────╮ │\n│ │ The format for tags is incorrect │ │\n│ ╰──────────────────────────────────╯ │\n│ 3 | ---                              │\n│ 4 |                                  │\n╰──────────────────────────────────────╯"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e017_config_invalid_tags/workspace/Flow.yaml",
    "content": "appId: com.example.app\ntags: foo, bar\n---\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e018_config_missing_appId/error.txt",
    "content": "> Config Field Required\n\n/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml:2\n╭────────────────────────────────────────────────────────────────────────╮\n│ 1 | name: MyFlow                                                       │\n│ 2 | ---                                                                │\n│     ^                                                                  │\n│ ╭────────────────────────────────────────────────────────────────────╮ │\n│ │ Either 'url' or 'appId' must be specified in the config section.   │ │\n│ │                                                                    │ │\n│ │ For mobile apps, use:                                              │ │\n│ │ ```yaml                                                            │ │\n│ │ appId: com.example.app                                             │ │\n│ │ ---                                                                │ │\n│ │ - launchApp                                                        │ │\n│ │ ```                                                                │ │\n│ │                                                                    │ │\n│ │ For web apps, use:                                                 │ │\n│ │ ```yaml                                                            │ │\n│ │ url: https://example.com                                           │ │\n│ │ ---                                                                │ │\n│ │ - launchApp                                                        │ │\n│ │ ```                                                                │ │\n│ │                                                                    │ │\n│ │ > https://docs.maestro.dev/getting-started/writing-your-first-flow │ │\n│ ╰────────────────────────────────────────────────────────────────────╯ │\n│ 3 |                                                                    │\n╰────────────────────────────────────────────────────────────────────────╯"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e018_config_missing_appId/workspace/Flow.yaml",
    "content": "name: MyFlow\n---\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e019_invalid_swipe_direction/error.txt",
    "content": "> Parsing Failed\n\n/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml:5\n╭─────────────────────────────────────────────────────────────────────────────────╮\n│ 3 | - swipe:                                                                    │\n│ 4 |     direction: diagonal                                                     │\n│ 5 |                                                                             │\n│     ^                                                                           │\n│ ╭─────────────────────────────────────────────────────────────────────────────╮ │\n│ │ Invalid direction provided to directional swipe: diagonal. Direction can be │ │\n│ │ either:                                                                     │ │\n│ │ 1. RIGHT or right                                                           │ │\n│ │ 2. LEFT or left                                                             │ │\n│ │ 3. UP or up                                                                 │ │\n│ │ 4. DOWN or down                                                             │ │\n│ ╰─────────────────────────────────────────────────────────────────────────────╯ │\n╰─────────────────────────────────────────────────────────────────────────────────╯"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e019_invalid_swipe_direction/workspace/Flow.yaml",
    "content": "appId: com.example.app\n---\n- swipe:\n    direction: diagonal\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e020_missing_command_options/error.txt",
    "content": "> Missing Command Options\n\n/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml:3\n╭──────────────────────────────────────────────────────╮\n│ 1 | appId: com.example.app                           │\n│ 2 | ---                                              │\n│ 3 | - tapOn                                          │\n│            ^                                         │\n│ ╭──────────────────────────────────────────────────╮ │\n│ │ The command `tapOn` requires additional options. │ │\n│ ╰──────────────────────────────────────────────────╯ │\n│ 4 |                                                  │\n╰──────────────────────────────────────────────────────╯"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e020_missing_command_options/workspace/Flow.yaml",
    "content": "appId: com.example.app\n---\n- tapOn\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e021_multiple_command_names/error.txt",
    "content": "> Invalid Command Format: tapOn\n\n/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml:4\n╭───────────────────────────────────────────────────────────────────────────────╮\n│ 2 | ---                                                                       │\n│ 3 | - tapOn: foo                                                              │\n│ 4 |   inputText: bar                                                          │\n│                ^                                                              │\n│ ╭───────────────────────────────────────────────────────────────────────────╮ │\n│ │ Found unexpected top-level field: `inputText`. Missing an indent or dash? │ │\n│ │                                                                           │ │\n│ │ Example of correctly formatted list of commands:                          │ │\n│ │ ```yaml                                                                   │ │\n│ │ - tapOn:                                                                  │ │\n│ │ text: submit                                                              │ │\n│ │ optional: true                                                            │ │\n│ │ - inputText: hello                                                        │ │\n│ │ ```                                                                       │ │\n│ ╰───────────────────────────────────────────────────────────────────────────╯ │\n│ 5 |                                                                           │\n╰───────────────────────────────────────────────────────────────────────────────╯"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e021_multiple_command_names/workspace/Flow.yaml",
    "content": "appId: com.example.app\n---\n- tapOn: foo\n  inputText: bar\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e022_top_level_option/error.txt",
    "content": "> Invalid Command Format: tapOn\n\n/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml:4\n╭──────────────────────────────────────────────────────────────────────────────╮\n│ 2 | ---                                                                      │\n│ 3 | - tapOn: foo                                                             │\n│ 4 |   optional: true                                                         │\n│               ^                                                              │\n│ ╭──────────────────────────────────────────────────────────────────────────╮ │\n│ │ Found unexpected top-level field: `optional`. Missing an indent or dash? │ │\n│ │                                                                          │ │\n│ │ Example of correctly formatted list of commands:                         │ │\n│ │ ```yaml                                                                  │ │\n│ │ - tapOn:                                                                 │ │\n│ │ text: submit                                                             │ │\n│ │ optional: true                                                           │ │\n│ │ - inputText: hello                                                       │ │\n│ │ ```                                                                      │ │\n│ ╰──────────────────────────────────────────────────────────────────────────╯ │\n│ 5 |                                                                          │\n╰──────────────────────────────────────────────────────────────────────────────╯"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e022_top_level_option/workspace/Flow.yaml",
    "content": "appId: com.example.app\n---\n- tapOn: foo\n  optional: true\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e023_empty/error.txt",
    "content": "> Config Section Required\n\n/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml:1\n╭────────────────────────────────────────────────────────────────────────╮\n│ 1 |                                                                    │\n│     ^                                                                  │\n│ ╭────────────────────────────────────────────────────────────────────╮ │\n│ │ Flow files must start with a config section. Eg:                   │ │\n│ │                                                                    │ │\n│ │ ```yaml                                                            │ │\n│ │ appId: com.example.app # <-- config section                        │ │\n│ │ ---                                                                │ │\n│ │ - launchApp                                                        │ │\n│ │ ```                                                                │ │\n│ │                                                                    │ │\n│ │ > https://docs.maestro.dev/getting-started/writing-your-first-flow │ │\n│ ╰────────────────────────────────────────────────────────────────────╯ │\n╰────────────────────────────────────────────────────────────────────────╯"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e023_empty/workspace/Flow.yaml",
    "content": ""
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e023_empty_commands/error.txt",
    "content": "> Commands Section Required\n\n/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml:2\n╭───────────────────────────────────────────────────────────────────────────╮\n│ 1 | appId: com.example.app                                                │\n│ 2 | ---                                                                   │\n│        ^                                                                  │\n│ ╭───────────────────────────────────────────────────────────────────────╮ │\n│ │ Flow files must have a list of commands after the config section. Eg: │ │\n│ │                                                                       │ │\n│ │ ```yaml                                                               │ │\n│ │ appId: com.example.app                                                │ │\n│ │ ---                                                                   │ │\n│ │ - launchApp                                                           │ │\n│ │ ```                                                                   │ │\n│ │                                                                       │ │\n│ │ > https://docs.maestro.dev/getting-started/writing-your-first-flow    │ │\n│ ╰───────────────────────────────────────────────────────────────────────╯ │\n╰───────────────────────────────────────────────────────────────────────────╯"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e023_empty_commands/workspace/Flow.yaml",
    "content": "appId: com.example.app\n---"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e023_launchApp_empty_string/error.txt",
    "content": "> Incorrect Command Format: launchApp\n\n/tmp/WorkspaceExecutionPlannerErrorsTest_workspace/workspace/Flow.yaml:3\n╭──────────────────────────────────────────────────────────╮\n│ 1 | appId: com.example.app                               │\n│ 2 | ---                                                  │\n│ 3 | - launchApp:                                         │\n│                 ^                                        │\n│ ╭──────────────────────────────────────────────────────╮ │\n│ │ The command `launchApp` requires additional options. │ │\n│ ╰──────────────────────────────────────────────────────╯ │\n│ 4 |                                                      │\n╰──────────────────────────────────────────────────────────╯"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/e023_launchApp_empty_string/workspace/Flow.yaml",
    "content": "appId: com.example.app\n---\n- launchApp:\n"
  },
  {
    "path": "maestro-orchestra/src/test/resources/workspaces/workspace_validator_flow.yaml",
    "content": "appId: ${APP_ID}\n---\n- launchApp\n"
  },
  {
    "path": "maestro-orchestra-models/build.gradle.kts",
    "content": "import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask\n\nplugins {\n    id(\"maven-publish\")\n    alias(libs.plugins.kotlin.jvm)\n    alias(libs.plugins.mavenPublish)\n}\n\nmavenPublishing {\n    publishToMavenCentral(true)\n    signAllPublications()\n}\n\njava {\n    sourceCompatibility = JavaVersion.VERSION_17\n    targetCompatibility = JavaVersion.VERSION_17\n}\n\nkotlin {\n    jvmToolchain {\n        languageVersion.set(JavaLanguageVersion.of(17))\n    }\n}\n\ntasks.named(\"compileKotlin\", KotlinCompilationTask::class.java) {\n    compilerOptions {\n        freeCompilerArgs.addAll(\"-Xjdk-release=17\")\n    }\n}\n\ndependencies {\n    implementation(project(\":maestro-client\"))\n    implementation(libs.datafaker)\n\n    api(libs.jackson.core.databind)\n    api(libs.jackson.module.kotlin)\n\n    testImplementation(libs.junit.jupiter.api)\n    testRuntimeOnly(libs.junit.jupiter.engine)\n    testImplementation(libs.google.truth)\n}\n\ntasks.named<Test>(\"test\") {\n    useJUnitPlatform()\n}\n"
  },
  {
    "path": "maestro-orchestra-models/gradle.properties",
    "content": "POM_NAME=Orchestra Models\nPOM_ARTIFACT_ID=maestro-orchestra-models\nPOM_PACKAGING=jar\n"
  },
  {
    "path": "maestro-orchestra-models/src/main/java/maestro/orchestra/Commands.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.orchestra\n\nimport maestro.device.DeviceOrientation\nimport maestro.KeyCode\nimport maestro.Point\nimport maestro.ScrollDirection\nimport maestro.SwipeDirection\nimport maestro.TapRepeat\nimport maestro.js.JsEngine\nimport maestro.orchestra.util.Env.evaluateScripts\nimport com.fasterxml.jackson.annotation.JsonIgnore\nimport java.nio.file.Path\nimport net.datafaker.Faker\n\nsealed interface Command {\n\n    @get:JsonIgnore\n    val originalDescription: String\n\n    fun description(): String = label ?: originalDescription\n\n    fun evaluateScripts(jsEngine: JsEngine): Command\n\n    fun visible(): Boolean = true\n\n    val label: String?\n\n    val optional: Boolean\n}\n\nsealed interface CompositeCommand : Command {\n\n    fun subCommands(): List<MaestroCommand>\n    fun config(): MaestroConfig?\n}\n\ndata class SwipeCommand(\n    val direction: SwipeDirection? = null,\n    val startPoint: Point? = null,\n    val endPoint: Point? = null,\n    val elementSelector: ElementSelector? = null,\n    val startRelative: String? = null,\n    val endRelative: String? = null,\n    val duration: Long = DEFAULT_DURATION_IN_MILLIS,\n    val waitToSettleTimeoutMs: Int? = null,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = when {\n            elementSelector != null && direction != null -> {\n                \"Swiping in $direction direction on ${elementSelector.description()}\"\n            }\n            direction != null -> {\n                \"Swiping in $direction direction in $duration ms\"\n            }\n            startPoint != null && endPoint != null -> {\n                \"Swipe from (${startPoint.x},${startPoint.y}) to (${endPoint.x},${endPoint.y}) in $duration ms\"\n            }\n            startRelative != null && endRelative != null -> {\n                \"Swipe from ($startRelative) to ($endRelative) in $duration ms\"\n            }\n            else -> \"Invalid input to swipe command\"\n        }\n\n    override fun evaluateScripts(jsEngine: JsEngine): SwipeCommand {\n        return copy(\n            elementSelector = elementSelector?.evaluateScripts(jsEngine),\n            startRelative = startRelative?.evaluateScripts(jsEngine),\n            endRelative = endRelative?.evaluateScripts(jsEngine),\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n\n    companion object {\n        private const val DEFAULT_DURATION_IN_MILLIS = 400L\n    }\n}\n\n/**\n * @param visibilityPercentage 0-1 Visibility within viewport bounds. 0 not within viewport and 1 fully visible within viewport.\n */\ndata class ScrollUntilVisibleCommand(\n    val selector: ElementSelector,\n    val direction: ScrollDirection,\n    val scrollDuration: String = DEFAULT_SCROLL_DURATION,\n    val visibilityPercentage: Int,\n    val timeout: String = DEFAULT_TIMEOUT_IN_MILLIS,\n    val waitToSettleTimeoutMs: Int? = null,\n    val centerElement: Boolean,\n    val originalSpeedValue: String? = scrollDuration,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    val visibilityPercentageNormalized = (visibilityPercentage / 100).toDouble()\n\n    override val originalDescription: String\n        get() {\n            val baseDescription = \"Scrolling $direction until ${selector.description()} is visible\"\n            val additionalDescription = mutableListOf<String>()\n            additionalDescription.add(\"with speed $originalSpeedValue\")\n            additionalDescription.add(\"visibility percentage $visibilityPercentage%\")\n            additionalDescription.add(\"timeout $timeout ms\")\n            waitToSettleTimeoutMs?.let {\n                additionalDescription.add(\"wait to settle $it ms\")\n            }\n            if (centerElement) {\n                additionalDescription.add(\"with centering enabled\")\n            } else {\n                additionalDescription.add(\"with centering disabled\")\n            }\n            return \"$baseDescription ${additionalDescription.joinToString(\", \")}\"\n        }\n\n    private fun String.speedToDuration(): String {\n        val duration = ((1000 * (100 - this.toLong()).toDouble() / 100).toLong() + 1)\n        return if (duration < 0) {\n            DEFAULT_SCROLL_DURATION\n        } else duration.toString()\n    }\n\n    private fun String.timeoutToMillis(): String {\n        return if (this.toLong() < 0) {\n            DEFAULT_TIMEOUT_IN_MILLIS\n        } else this\n    }\n\n    override fun evaluateScripts(jsEngine: JsEngine): ScrollUntilVisibleCommand {\n        return copy(\n            originalSpeedValue = scrollDuration,\n            selector = selector.evaluateScripts(jsEngine),\n            scrollDuration = scrollDuration.evaluateScripts(jsEngine).speedToDuration(),\n            timeout = timeout.evaluateScripts(jsEngine).timeoutToMillis(),\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n\n    companion object {\n        const val DEFAULT_TIMEOUT_IN_MILLIS = \"20000\"\n        const val DEFAULT_SCROLL_DURATION = \"40\"\n        const val DEFAULT_ELEMENT_VISIBILITY_PERCENTAGE = 100\n        const val DEFAULT_CENTER_ELEMENT = false\n    }\n}\n\ndata class ScrollCommand(\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Scroll vertically\"\n\n    override fun equals(other: Any?): Boolean {\n        if (this === other) return true\n        if (javaClass != other?.javaClass) return false\n        return true\n    }\n\n    override fun hashCode(): Int {\n        return javaClass.hashCode()\n    }\n\n    override fun toString(): String {\n        return \"ScrollCommand()\"\n    }\n\n    override fun evaluateScripts(jsEngine: JsEngine): ScrollCommand {\n        return copy(\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n}\n\ndata class BackPressCommand(\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Press back\"\n\n    override fun equals(other: Any?): Boolean {\n        if (this === other) return true\n        if (javaClass != other?.javaClass) return false\n        return true\n    }\n\n    override fun hashCode(): Int {\n        return javaClass.hashCode()\n    }\n\n    override fun toString(): String {\n        return \"BackPressCommand()\"\n    }\n\n    override fun evaluateScripts(jsEngine: JsEngine): BackPressCommand {\n        return copy(\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n}\n\ndata class HideKeyboardCommand(\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Hide Keyboard\"\n\n    override fun equals(other: Any?): Boolean {\n        if (this === other) return true\n        if (javaClass != other?.javaClass) return false\n        return true\n    }\n\n    override fun hashCode(): Int {\n        return javaClass.hashCode()\n    }\n\n    override fun toString(): String {\n        return \"HideKeyboardCommand()\"\n    }\n\n    override fun evaluateScripts(jsEngine: JsEngine): HideKeyboardCommand {\n        return copy(\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n}\n\ndata class CopyTextFromCommand(\n    val selector: ElementSelector,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Copy text from element with ${selector.description()}\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): CopyTextFromCommand {\n        return copy(\n            selector = selector.evaluateScripts(jsEngine),\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n}\n\ndata class SetClipboardCommand(\n    val text: String,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Set Maestro clipboard to $text\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): SetClipboardCommand {\n        return copy(\n            text = text.evaluateScripts(jsEngine),\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n}\n\ndata class PasteTextCommand(\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Paste text\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): PasteTextCommand {\n        return this\n    }\n}\n\ndata class TapOnElementCommand(\n    val selector: ElementSelector,\n    val retryIfNoChange: Boolean? = null,\n    val waitUntilVisible: Boolean? = null,\n    val longPress: Boolean? = null,\n    val repeat: TapRepeat? = null,\n    val waitToSettleTimeoutMs: Int? = null,\n    val relativePoint: String? = null, // New parameter for element-relative coordinates\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() {\n            val optional = if (optional || selector.optional) \"(Optional) \" else \"\"\n            val pointInfo = relativePoint?.let { \" at $it\" } ?: \"\"\n            return \"${tapOnDescription(longPress, repeat)} on $optional${selector.description()}$pointInfo\"\n        }\n\n    override fun evaluateScripts(jsEngine: JsEngine): TapOnElementCommand {\n        return copy(\n            selector = selector.evaluateScripts(jsEngine),\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n\n    companion object {\n        const val DEFAULT_REPEAT_DELAY = 100L\n        const val MAX_TIMEOUT_WAIT_TO_SETTLE_MS = 30000\n    }\n}\n\n@Deprecated(\"Use TapOnPointV2Command instead\")\ndata class TapOnPointCommand(\n    val x: Int,\n    val y: Int,\n    val retryIfNoChange: Boolean? = null,\n    val waitUntilVisible: Boolean? = null,\n    val longPress: Boolean? = null,\n    val repeat: TapRepeat? = null,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"${tapOnDescription(longPress, repeat)} on point ($x, $y)\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): TapOnPointCommand {\n        return this\n    }\n}\n\ndata class TapOnPointV2Command(\n    val point: String,\n    val retryIfNoChange: Boolean? = null,\n    val longPress: Boolean? = null,\n    val repeat: TapRepeat? = null,\n    val waitToSettleTimeoutMs: Int? = null,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"${tapOnDescription(longPress, repeat)} on point ($point)\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): TapOnPointV2Command {\n        return copy(\n            point = point.evaluateScripts(jsEngine),\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n}\n\n@Deprecated(\"Use AssertConditionCommand instead\")\ndata class AssertCommand(\n    val visible: ElementSelector? = null,\n    val notVisible: ElementSelector? = null,\n    val timeout: Long? = null,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() {\n            val timeoutStr = timeout?.let { \" within $timeout ms\" } ?: \"\"\n            return when {\n                visible != null -> \"Assert visible ${visible.description()}\" + timeoutStr\n                notVisible != null -> \"Assert not visible ${notVisible.description()}\" + timeoutStr\n                else -> \"No op\"\n            }\n        }\n\n    override fun evaluateScripts(jsEngine: JsEngine): AssertCommand {\n        return copy(\n            visible = visible?.evaluateScripts(jsEngine),\n            notVisible = notVisible?.evaluateScripts(jsEngine),\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n\n    fun toAssertConditionCommand(): AssertConditionCommand {\n        return AssertConditionCommand(\n            condition = Condition(\n                visible = visible,\n                notVisible = notVisible,\n            ),\n            timeout = timeout?.toString(),\n        )\n    }\n}\n\ndata class AssertConditionCommand(\n    val condition: Condition,\n    val timeout: String? = null,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    fun timeoutMs(): Long? {\n        return timeout?.replace(\"_\", \"\")?.toLong()\n    }\n\n    override val originalDescription: String\n        get() {\n            val optional = if (optional || condition.visible?.optional == true || condition.notVisible?.optional == true) \"(Optional) \" else \"\"\n            return \"Assert that $optional${condition.description()}\"\n        }\n\n    override fun evaluateScripts(jsEngine: JsEngine): Command {\n        return copy(\n            condition = condition.evaluateScripts(jsEngine),\n            timeout = timeout?.evaluateScripts(jsEngine),\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n}\n\ndata class AssertNoDefectsWithAICommand(\n    override val optional: Boolean = true,\n    override val label: String? = null,\n) : Command {\n    override val originalDescription: String\n        get() = \"Assert no defects with AI\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): Command = this\n}\n\ndata class AssertWithAICommand(\n    val assertion: String,\n    override val optional: Boolean = true,\n    override val label: String? = null,\n) : Command {\n    override val originalDescription: String\n        get() = \"Assert with AI: $assertion\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): Command {\n        return copy(\n            assertion = assertion.evaluateScripts(jsEngine),\n        )\n    }\n}\n\ndata class ExtractTextWithAICommand(\n    val query: String,\n    val outputVariable: String,\n    override val optional: Boolean = true,\n    override val label: String? = null\n) : Command {\n    override val originalDescription: String\n        get() = \"Extract text with AI: $query\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): Command {\n        return copy(\n            query = query.evaluateScripts(jsEngine),\n        )\n    }\n}\n\ndata class AssertScreenshotCommand(\n    val path: String,\n    val thresholdPercentage: Double,\n    val cropOn: ElementSelector? = null,\n    override val optional: Boolean = false,\n    override val label: String? = null,\n    @field:JsonIgnore val flowPath: Path? = null,\n) : Command {\n    override val originalDescription: String\n        get() {\n            val cropInfo = cropOn?.let { \" (cropped on ${it.description()})\" } ?: \"\"\n            return \"Assert screenshot matches $path (threshold: $thresholdPercentage%)$cropInfo\"\n        }\n\n    override fun evaluateScripts(jsEngine: JsEngine): Command {\n        return copy(\n            path = path.evaluateScripts(jsEngine),\n            cropOn = cropOn?.evaluateScripts(jsEngine)\n        )\n    }\n}\n\ndata class InputTextCommand(\n    val text: String,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Input text $text\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): InputTextCommand {\n        return copy(\n            text = text.evaluateScripts(jsEngine)\n        )\n    }\n}\n\ndata class LaunchAppCommand(\n    val appId: String,\n    val clearState: Boolean? = null,\n    val clearKeychain: Boolean? = null,\n    val stopApp: Boolean? = null,\n    var permissions: Map<String, String>? = null,\n    val launchArguments: Map<String, Any>? = null,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() {\n            var result = if (clearState != true) {\n                \"Launch app \\\"$appId\\\"\"\n            } else {\n                \"Launch app \\\"$appId\\\" with clear state\"\n            }\n\n            if (clearKeychain == true) {\n                result += \" and clear keychain\"\n            }\n\n            if (stopApp == false) {\n                result += \" without stopping app\"\n            }\n\n            if (launchArguments != null) {\n                result += \" (launch arguments: ${launchArguments})\"\n            }\n\n            return result\n        }\n\n    override fun evaluateScripts(jsEngine: JsEngine): LaunchAppCommand {\n        return copy(\n            appId = appId.evaluateScripts(jsEngine),\n            launchArguments = launchArguments?.entries?.associate {\n                val value = it.value\n                it.key.evaluateScripts(jsEngine) to if (value is String) value.evaluateScripts(jsEngine) else it.value\n            },\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n}\n\ndata class SetPermissionsCommand(\n    val appId: String,\n    var permissions: Map<String, String>,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Set permissions\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): SetPermissionsCommand {\n        return copy(\n            appId = appId.evaluateScripts(jsEngine),\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n}\n\ndata class ApplyConfigurationCommand(\n    val config: MaestroConfig,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Apply configuration\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): ApplyConfigurationCommand {\n        return copy(\n            config = config.evaluateScripts(jsEngine),\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n\n    override fun visible(): Boolean = false\n}\n\ndata class OpenLinkCommand(\n    val link: String,\n    val autoVerify: Boolean? = null,\n    val browser: Boolean? = null,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = when {\n            browser == true -> if (autoVerify == true) \"Open $link with auto verification in browser\" else \"Open $link in browser\"\n            else -> if (autoVerify == true) \"Open $link with auto verification\" else \"Open $link\"\n        }\n\n    override fun evaluateScripts(jsEngine: JsEngine): OpenLinkCommand {\n        return copy(\n            link = link.evaluateScripts(jsEngine),\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n}\n\ndata class PressKeyCommand(\n    val code: KeyCode,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Press ${code.description} key\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): PressKeyCommand {\n        return this\n    }\n}\n\ndata class EraseTextCommand(\n    val charactersToErase: Int?,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = when (charactersToErase) {\n            null -> \"Erase text\"\n            else -> \"Erase $charactersToErase characters\"\n        }\n\n    override fun evaluateScripts(jsEngine: JsEngine): EraseTextCommand {\n        return this\n    }\n\n}\n\ndata class TakeScreenshotCommand(\n    val path: String,\n    val cropOn: ElementSelector? = null,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Take screenshot $path\"\n\n    override fun description(): String {\n        return label ?: if (cropOn != null) {\n            \"Take screenshot $path, cropped to ${cropOn.description()}\"\n        } else {\n            \"Take screenshot $path\"\n        }\n    }\n\n    override fun evaluateScripts(jsEngine: JsEngine): TakeScreenshotCommand {\n        return copy(\n            path = path.evaluateScripts(jsEngine),\n            cropOn = cropOn?.evaluateScripts(jsEngine),\n        )\n    }\n}\n\ndata class StopAppCommand(\n    val appId: String,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Stop $appId\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): Command {\n        return copy(\n            appId = appId.evaluateScripts(jsEngine),\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n}\n\ndata class KillAppCommand(\n    val appId: String,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Kill $appId\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): Command {\n        return copy(\n            appId = appId.evaluateScripts(jsEngine),\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n}\n\ndata class ClearStateCommand(\n    val appId: String,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Clear state of $appId\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): Command {\n        return copy(\n            appId = appId.evaluateScripts(jsEngine),\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n}\n\nclass ClearKeychainCommand(\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Clear keychain\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): Command {\n        return this\n    }\n\n    override fun equals(other: Any?): Boolean {\n        if (this === other) return true\n        if (javaClass != other?.javaClass) return false\n        return true\n    }\n\n    override fun hashCode(): Int {\n        return javaClass.hashCode()\n    }\n}\n\nenum class InputRandomType {\n    NUMBER,\n    TEXT,\n    TEXT_EMAIL_ADDRESS,\n    TEXT_PERSON_NAME,\n    TEXT_CITY_NAME,\n    TEXT_COUNTRY_NAME,\n    TEXT_COLOR,\n}\n\ndata class InputRandomCommand(\n    val inputType: InputRandomType? = InputRandomType.TEXT,\n    val length: Int? = 8,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    fun genRandomString(): String {\n        val faker = Faker()\n        val lengthNonNull = length ?: 8\n        val finalLength = if (lengthNonNull <= 0) 8 else lengthNonNull\n\n        return when (inputType) {\n            InputRandomType.NUMBER -> faker.number().randomNumber(finalLength).toString()\n            InputRandomType.TEXT -> faker.text().text(finalLength)\n            InputRandomType.TEXT_EMAIL_ADDRESS -> faker.internet().emailAddress()\n            InputRandomType.TEXT_PERSON_NAME -> faker.name().firstName() + ' ' + faker.name().lastName()\n            InputRandomType.TEXT_CITY_NAME -> faker.address().cityName()\n            InputRandomType.TEXT_COUNTRY_NAME -> faker.address().country()\n            InputRandomType.TEXT_COLOR -> faker.color().name()\n            else -> faker.text().text(finalLength)\n        }\n    }\n\n    override val originalDescription: String\n        get() = \"Input text random $inputType\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): InputRandomCommand {\n        return this\n    }\n}\n\ndata class RunFlowCommand(\n    val commands: List<MaestroCommand>,\n    val condition: Condition? = null,\n    val sourceDescription: String? = null,\n    val config: MaestroConfig?,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : CompositeCommand {\n\n    override fun subCommands(): List<MaestroCommand> {\n        return commands\n    }\n\n    override fun config(): MaestroConfig? {\n        return config\n    }\n\n    override val originalDescription: String\n        get() {\n            val runDescription = if (sourceDescription != null) {\n                \"Run $sourceDescription\"\n            } else {\n                \"Run flow\"\n            }\n\n            return if (condition == null) {\n                runDescription\n            } else {\n                \"$runDescription when ${condition.description()}\"\n            }\n        }\n\n    override fun evaluateScripts(jsEngine: JsEngine): Command {\n        return copy(\n            condition = condition?.evaluateScripts(jsEngine),\n            config = config?.evaluateScripts(jsEngine),\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n}\n\ndata class SetLocationCommand(\n    val latitude: String,\n    val longitude: String,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Set location (${latitude}, ${longitude})\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): SetLocationCommand {\n        return copy(\n            latitude = latitude.evaluateScripts(jsEngine),\n            longitude = longitude.evaluateScripts(jsEngine),\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n}\n\ndata class SetOrientationCommand(\n    val orientation: String,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    constructor(\n        orientation: DeviceOrientation,\n        label: String? = null,\n        optional: Boolean = false,\n    ) : this(\n        orientation = orientation.name,\n        label = label,\n        optional = optional\n    )\n\n    override val originalDescription: String\n        get() = \"Set orientation ${orientation}\"\n\n    override fun description(): String {\n        return label ?: \"Set orientation ${orientation}\"\n    }\n\n    fun resolvedOrientation(): DeviceOrientation {\n        return DeviceOrientation.getByName(orientation)\n            ?: error(\"Unknown orientation: $orientation\")\n    }\n\n    override fun evaluateScripts(jsEngine: JsEngine): SetOrientationCommand {\n        val evaluatedOrientation = orientation.evaluateScripts(jsEngine)\n        val validOrientations = DeviceOrientation.entries\n        val resolved = DeviceOrientation.getByName(evaluatedOrientation)\n            ?: error(\n                \"Unknown orientation: $evaluatedOrientation. Valid orientations are: $validOrientations \\n\" +\n                    \"(case insensitive, underscores optional, e.g 'landscape_left', 'landscapeLeft', and 'LANDSCAPE_LEFT' are all valid)\"\n            )\n        return copy(\n            orientation = resolved.name,\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n}\n\ndata class RepeatCommand(\n    val times: String? = null,\n    val condition: Condition? = null,\n    val commands: List<MaestroCommand>,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : CompositeCommand {\n\n    override fun subCommands(): List<MaestroCommand> {\n        return commands\n    }\n\n    override fun config(): MaestroConfig? {\n        return null\n    }\n\n    override val originalDescription: String\n        get() {\n            val timesInt = times?.toIntOrNull() ?: 1\n\n            return when {\n                condition != null && timesInt > 1 -> {\n                    \"Repeat while ${condition.description()} (up to $timesInt times)\"\n                }\n                condition != null -> {\n                    \"Repeat while ${condition.description()}\"\n                }\n                timesInt > 1 -> \"Repeat $timesInt times\"\n                else -> \"Repeat indefinitely\"\n            }\n        }\n\n    override fun evaluateScripts(jsEngine: JsEngine): Command {\n        return copy(\n            times = times?.evaluateScripts(jsEngine),\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n\n}\n\ndata class RetryCommand(\n    val maxRetries: String? = null,\n    val commands: List<MaestroCommand>,\n    val config: MaestroConfig?,\n    val sourceDescription: String? = null,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : CompositeCommand {\n\n    override fun subCommands(): List<MaestroCommand> {\n        return commands\n    }\n\n    override fun config(): MaestroConfig? {\n        return null\n    }\n\n    override val originalDescription: String\n        get() {\n            val maxAttempts = maxRetries?.toIntOrNull() ?: 1\n            val baseDescription = if (sourceDescription != null) {\n                \"Retry $sourceDescription\"\n            } else {\n                \"Retry\"\n            }\n            return \"$baseDescription $maxAttempts times\"\n        }\n\n    override fun evaluateScripts(jsEngine: JsEngine): Command {\n        return copy(\n            maxRetries = maxRetries?.evaluateScripts(jsEngine),\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n\n}\n\ndata class DefineVariablesCommand(\n    val env: Map<String, String>,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Define variables\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): DefineVariablesCommand {\n        return copy(\n            env = env.mapValues { (_, value) ->\n                value.evaluateScripts(jsEngine)\n            },\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n\n    override fun visible(): Boolean = false\n}\n\ndata class RunScriptCommand(\n    val script: String,\n    val env: Map<String, String> = emptyMap(),\n    val sourceDescription: String,\n    val condition: Condition?,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = if (condition == null) {\n            \"Run $sourceDescription\"\n        } else {\n            \"Run $sourceDescription when ${condition.description()}\"\n        }\n\n    override fun evaluateScripts(jsEngine: JsEngine): Command {\n        return copy(\n            env = env.mapValues { (_, value) ->\n                value.evaluateScripts(jsEngine)\n            },\n            condition = condition?.evaluateScripts(jsEngine),\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n}\n\ndata class WaitForAnimationToEndCommand(\n    val timeout: Long?,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Wait for animation to end\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): Command {\n        return this\n    }\n}\n\ndata class EvalScriptCommand(\n    val scriptString: String,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Run $scriptString\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): Command {\n        return this\n    }\n\n}\n\ndata class TravelCommand(\n    val points: List<GeoPoint>,\n    val speedMPS: Double? = null,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    data class GeoPoint(\n        val latitude: String,\n        val longitude: String,\n    ) {\n\n        fun getDistanceInMeters(another: GeoPoint): Double {\n            val earthRadius = 6371 // in kilometers\n            val oLat = Math.toRadians(latitude.toDouble())\n            val oLon = Math.toRadians(longitude.toDouble())\n\n            val aLat = Math.toRadians(another.latitude.toDouble())\n            val aLon = Math.toRadians(another.longitude.toDouble())\n\n            val dLat = Math.toRadians(aLat - oLat)\n            val dLon = Math.toRadians(aLon - oLon)\n\n            val a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +\n                    Math.cos(Math.toRadians(oLat)) * Math.cos(Math.toRadians(aLat)) *\n                    Math.sin(dLon / 2) * Math.sin(dLon / 2)\n\n            val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))\n            val distance = earthRadius * c * 1000 // convert to meters\n\n            return distance\n        }\n\n    }\n\n    override val originalDescription: String\n        get() = \"Travel path ${points.joinToString { \"(${it.latitude}, ${it.longitude})\" }}\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): Command {\n        return copy(\n            points = points.map {\n                it.copy(\n                    latitude = it.latitude.evaluateScripts(jsEngine),\n                    longitude = it.longitude.evaluateScripts(jsEngine)\n                )\n            },\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n\n}\n\ndata class StartRecordingCommand(\n    val path: String,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Start recording $path\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): StartRecordingCommand {\n        return copy(\n            path = path.evaluateScripts(jsEngine),\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n}\n\ndata class AddMediaCommand(\n    val mediaPaths: List<String>,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Adding media files(${mediaPaths.size}) to the device\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): Command {\n        return copy(\n            mediaPaths = mediaPaths.map { it.evaluateScripts(jsEngine) },\n            label = label?.evaluateScripts(jsEngine)\n        )\n    }\n}\n\n\ndata class StopRecordingCommand(\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n\n    override val originalDescription: String\n        get() = \"Stop recording\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): Command {\n        return this\n    }\n}\n\nenum class AirplaneValue {\n    Enable,\n    Disable,\n}\n\ndata class SetAirplaneModeCommand(\n    val value: AirplaneValue,\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n    override val originalDescription: String\n        get() = when (value) {\n            AirplaneValue.Enable -> \"Enable airplane mode\"\n            AirplaneValue.Disable -> \"Disable airplane mode\"\n        }\n\n    override fun evaluateScripts(jsEngine: JsEngine): Command {\n        return this\n    }\n}\n\ndata class ToggleAirplaneModeCommand(\n    override val label: String? = null,\n    override val optional: Boolean = false,\n) : Command {\n    override val originalDescription: String\n        get() = \"Toggle airplane mode\"\n\n    override fun evaluateScripts(jsEngine: JsEngine): Command {\n        return this\n    }\n}\n\ninternal fun tapOnDescription(isLongPress: Boolean?, repeat: TapRepeat?): String {\n    return if (isLongPress == true) \"Long press\"\n    else if (repeat != null) {\n        when (repeat.repeat) {\n            1 -> \"Tap\"\n            2 -> \"Double tap\"\n            else -> \"Tap x${repeat.repeat}\"\n        }\n    } else \"Tap\"\n}\n"
  },
  {
    "path": "maestro-orchestra-models/src/main/java/maestro/orchestra/Condition.kt",
    "content": "package maestro.orchestra\n\nimport maestro.device.Platform\nimport maestro.js.JsEngine\nimport maestro.orchestra.util.Env.evaluateScripts\n\ndata class Condition(\n    val platform: Platform? = null,\n    val visible: ElementSelector? = null,\n    val notVisible: ElementSelector? = null,\n    val scriptCondition: String? = null,\n    val label: String? = null,\n) {\n\n    fun evaluateScripts(jsEngine: JsEngine): Condition {\n        return copy(\n            visible = visible?.evaluateScripts(jsEngine),\n            notVisible = notVisible?.evaluateScripts(jsEngine),\n            scriptCondition = scriptCondition?.evaluateScripts(jsEngine),\n        )\n    }\n\n    fun description(): String {\n        if(label != null){\n            return label\n        }\n\n        val descriptions = mutableListOf<String>()\n\n        platform?.let {\n            descriptions.add(\"Platform is $it\")\n        }\n\n        visible?.let {\n            descriptions.add(\"${it.description()} is visible\")\n        }\n\n        notVisible?.let {\n            descriptions.add(\"${it.description()} is not visible\")\n        }\n\n        scriptCondition?.let {\n            descriptions.add(\"$it is true\")\n        }\n\n        return if (descriptions.isEmpty()) {\n            \"true\"\n        } else {\n            descriptions.joinToString(\" and \")\n        }\n    }\n\n}\n"
  },
  {
    "path": "maestro-orchestra-models/src/main/java/maestro/orchestra/ElementSelector.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.orchestra\n\nimport maestro.js.JsEngine\nimport maestro.orchestra.util.Env.evaluateScripts\n\ndata class ElementSelector(\n    val textRegex: String? = null,\n    val idRegex: String? = null,\n    val size: SizeSelector? = null,\n    val below: ElementSelector? = null,\n    val above: ElementSelector? = null,\n    val leftOf: ElementSelector? = null,\n    val rightOf: ElementSelector? = null,\n    val containsChild: ElementSelector? = null,\n    val containsDescendants: List<ElementSelector>? = null,\n    val traits: List<ElementTrait>? = null,\n    val index: String? = null,\n    val enabled: Boolean? = null,\n    @Deprecated(\"This is a deprecated field, please use the optional in commands interface\")\n    val optional: Boolean = false,\n    val selected: Boolean? = null,\n    val checked: Boolean? = null,\n    val focused: Boolean? = null,\n    val childOf: ElementSelector? = null,\n    val css: String? = null,\n) {\n\n    data class SizeSelector(\n        val width: Int? = null,\n        val height: Int? = null,\n        val tolerance: Int? = null,\n    )\n\n    fun evaluateScripts(jsEngine: JsEngine): ElementSelector {\n        return copy(\n            textRegex = textRegex?.evaluateScripts(jsEngine),\n            idRegex = idRegex?.evaluateScripts(jsEngine),\n            below = below?.evaluateScripts(jsEngine),\n            above = above?.evaluateScripts(jsEngine),\n            leftOf = leftOf?.evaluateScripts(jsEngine),\n            rightOf = rightOf?.evaluateScripts(jsEngine),\n            containsChild = containsChild?.evaluateScripts(jsEngine),\n            containsDescendants = containsDescendants?.map { it.evaluateScripts(jsEngine) },\n            index = index?.evaluateScripts(jsEngine),\n            childOf = childOf?.evaluateScripts(jsEngine),\n            css = css?.evaluateScripts(jsEngine),\n        )\n    }\n\n    fun description(): String {\n        val descriptions = mutableListOf<String>()\n\n        textRegex?.let {\n            descriptions.add(\"\\\"$it\\\"\")\n        }\n\n        idRegex?.let {\n            descriptions.add(\"id: $it\")\n        }\n\n        enabled?.let {\n            when(enabled){\n                true -> descriptions.add(\"enabled\")\n                false -> descriptions.add(\"disabled\")\n            }\n        }\n\n        below?.let {\n            descriptions.add(\"Below ${it.description()}\")\n        }\n\n        above?.let {\n            descriptions.add(\"Above ${it.description()}\")\n        }\n\n        leftOf?.let {\n            descriptions.add(\"Left of ${it.description()}\")\n        }\n\n        rightOf?.let {\n            descriptions.add(\"Right of ${it.description()}\")\n        }\n\n        containsChild?.let {\n            descriptions.add(\"Contains child: ${it.description()}\")\n        }\n\n        containsDescendants?.let { selectors ->\n            val descendantDescriptions = selectors.joinToString(\", \") { it.description() }\n            descriptions.add(\"Contains descendants: [$descendantDescriptions]\")\n        }\n\n        size?.let {\n            var description = \"Size: ${it.width}x${it.height}\"\n            it.tolerance?.let { tolerance ->\n                description += \"(tolerance: $tolerance)\"\n            }\n\n            descriptions.add(description)\n        }\n\n        traits?.let {\n            descriptions.add(\n                \"Has traits: ${traits.joinToString(\", \") { it.description }}\"\n            )\n        }\n\n        index?.let {\n            descriptions.add(\"Index: ${it.toDoubleOrNull()?.toInt() ?: it}\")\n        }\n\n        selected?.let {\n            when(selected){\n                true -> descriptions.add(\"selected\")\n                false -> descriptions.add(\"not selected\")\n            }\n        }\n\n        focused?.let {\n            when(focused){\n                true -> descriptions.add(\"focused\")\n                false -> descriptions.add(\"not focused\")\n            }\n        }\n\n        childOf?.let {\n            descriptions.add(\"Child of: ${it.description()}\")\n        }\n\n        css?.let {\n            descriptions.add(\"CSS: $it\")\n        }\n\n        return descriptions.joinToString(\", \")\n    }\n\n}\n"
  },
  {
    "path": "maestro-orchestra-models/src/main/java/maestro/orchestra/ElementTrait.kt",
    "content": "package maestro.orchestra\n\nenum class ElementTrait(val description: String) {\n    TEXT(\"Has text\"),\n    SQUARE(\"Is square\"),\n    LONG_TEXT(\"Has long text\"),\n}"
  },
  {
    "path": "maestro-orchestra-models/src/main/java/maestro/orchestra/MaestroCommand.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.orchestra\n\nimport maestro.js.JsEngine\n\n/**\n * The Mobile.dev platform uses this class in the backend and hence the custom\n * serialization logic. The earlier implementation of this class had a nullable field for\n * each command. Sometime in the future we may move this serialization logic to the backend\n * itself, where it would be more relevant.\n */\ndata class MaestroCommand(\n    val tapOnElement: TapOnElementCommand? = null,\n    @Deprecated(\"Use tapOnPointV2Command\") val tapOnPoint: TapOnPointCommand? = null,\n    val tapOnPointV2Command: TapOnPointV2Command? = null,\n    val scrollCommand: ScrollCommand? = null,\n    val swipeCommand: SwipeCommand? = null,\n    val backPressCommand: BackPressCommand? = null,\n    @Deprecated(\"Use assertConditionCommand\") val assertCommand: AssertCommand? = null,\n    val assertConditionCommand: AssertConditionCommand? = null,\n    val assertScreenshotCommand: AssertScreenshotCommand? = null,\n    val assertNoDefectsWithAICommand: AssertNoDefectsWithAICommand? = null,\n    val assertWithAICommand: AssertWithAICommand? = null,\n    val extractTextWithAICommand: ExtractTextWithAICommand? = null,\n    val inputTextCommand: InputTextCommand? = null,\n    val inputRandomTextCommand: InputRandomCommand? = null,\n    val launchAppCommand: LaunchAppCommand? = null,\n    val setPermissionsCommand: SetPermissionsCommand? = null,\n    val applyConfigurationCommand: ApplyConfigurationCommand? = null,\n    val openLinkCommand: OpenLinkCommand? = null,\n    val pressKeyCommand: PressKeyCommand? = null,\n    val eraseTextCommand: EraseTextCommand? = null,\n    val hideKeyboardCommand: HideKeyboardCommand? = null,\n    val takeScreenshotCommand: TakeScreenshotCommand? = null,\n    val stopAppCommand: StopAppCommand? = null,\n    val killAppCommand: KillAppCommand? = null,\n    val clearStateCommand: ClearStateCommand? = null,\n    val clearKeychainCommand: ClearKeychainCommand? = null,\n    val runFlowCommand: RunFlowCommand? = null,\n    val setLocationCommand: SetLocationCommand? = null,\n    var setOrientationCommand: SetOrientationCommand? = null,\n    val repeatCommand: RepeatCommand? = null,\n    val copyTextCommand: CopyTextFromCommand? = null,\n    val setClipboardCommand: SetClipboardCommand? = null,\n    val pasteTextCommand: PasteTextCommand? = null,\n    val defineVariablesCommand: DefineVariablesCommand? = null,\n    val runScriptCommand: RunScriptCommand? = null,\n    val waitForAnimationToEndCommand: WaitForAnimationToEndCommand? = null,\n    val evalScriptCommand: EvalScriptCommand? = null,\n    val scrollUntilVisible: ScrollUntilVisibleCommand? = null,\n    val travelCommand: TravelCommand? = null,\n    val startRecordingCommand: StartRecordingCommand? = null,\n    val stopRecordingCommand: StopRecordingCommand? = null,\n    val addMediaCommand: AddMediaCommand? = null,\n    val setAirplaneModeCommand: SetAirplaneModeCommand? = null,\n    val toggleAirplaneModeCommand: ToggleAirplaneModeCommand? = null,\n    val retryCommand: RetryCommand? = null,\n) {\n\n    constructor(command: Command) : this(\n        tapOnElement = command as? TapOnElementCommand,\n        tapOnPoint = command as? TapOnPointCommand,\n        tapOnPointV2Command = command as? TapOnPointV2Command,\n        scrollCommand = command as? ScrollCommand,\n        swipeCommand = command as? SwipeCommand,\n        backPressCommand = command as? BackPressCommand,\n        assertCommand = command as? AssertCommand,\n        assertConditionCommand = command as? AssertConditionCommand,\n        assertNoDefectsWithAICommand = command as? AssertNoDefectsWithAICommand,\n        assertWithAICommand = command as? AssertWithAICommand,\n        extractTextWithAICommand = command as? ExtractTextWithAICommand,\n        inputTextCommand = command as? InputTextCommand,\n        inputRandomTextCommand = command as? InputRandomCommand,\n        assertScreenshotCommand = command as? AssertScreenshotCommand,\n        launchAppCommand = command as? LaunchAppCommand,\n        setPermissionsCommand = command as? SetPermissionsCommand,\n        applyConfigurationCommand = command as? ApplyConfigurationCommand,\n        openLinkCommand = command as? OpenLinkCommand,\n        pressKeyCommand = command as? PressKeyCommand,\n        eraseTextCommand = command as? EraseTextCommand,\n        hideKeyboardCommand = command as? HideKeyboardCommand,\n        takeScreenshotCommand = command as? TakeScreenshotCommand,\n        stopAppCommand = command as? StopAppCommand,\n        killAppCommand = command as? KillAppCommand,\n        clearStateCommand = command as? ClearStateCommand,\n        clearKeychainCommand = command as? ClearKeychainCommand,\n        runFlowCommand = command as? RunFlowCommand,\n        setLocationCommand = command as? SetLocationCommand,\n        setOrientationCommand = command as? SetOrientationCommand,\n        repeatCommand = command as? RepeatCommand,\n        copyTextCommand = command as? CopyTextFromCommand,\n        setClipboardCommand = command as? SetClipboardCommand,\n        pasteTextCommand = command as? PasteTextCommand,\n        defineVariablesCommand = command as? DefineVariablesCommand,\n        runScriptCommand = command as? RunScriptCommand,\n        waitForAnimationToEndCommand = command as? WaitForAnimationToEndCommand,\n        evalScriptCommand = command as? EvalScriptCommand,\n        scrollUntilVisible = command as? ScrollUntilVisibleCommand,\n        travelCommand = command as? TravelCommand,\n        startRecordingCommand = command as? StartRecordingCommand,\n        stopRecordingCommand = command as? StopRecordingCommand,\n        addMediaCommand = command as? AddMediaCommand,\n        setAirplaneModeCommand = command as? SetAirplaneModeCommand,\n        toggleAirplaneModeCommand = command as? ToggleAirplaneModeCommand,\n        retryCommand = command as? RetryCommand\n    )\n\n    fun asCommand(): Command? = when {\n        tapOnElement != null -> tapOnElement\n        tapOnPoint != null -> tapOnPoint\n        tapOnPointV2Command != null -> tapOnPointV2Command\n        scrollCommand != null -> scrollCommand\n        swipeCommand != null -> swipeCommand\n        backPressCommand != null -> backPressCommand\n        assertCommand != null -> assertCommand\n        assertConditionCommand != null -> assertConditionCommand\n        assertNoDefectsWithAICommand != null -> assertNoDefectsWithAICommand\n        assertWithAICommand != null -> assertWithAICommand\n        extractTextWithAICommand != null -> extractTextWithAICommand\n        inputTextCommand != null -> inputTextCommand\n        inputRandomTextCommand != null -> inputRandomTextCommand\n        launchAppCommand != null -> launchAppCommand\n        setPermissionsCommand != null -> setPermissionsCommand\n        applyConfigurationCommand != null -> applyConfigurationCommand\n        openLinkCommand != null -> openLinkCommand\n        pressKeyCommand != null -> pressKeyCommand\n        eraseTextCommand != null -> eraseTextCommand\n        hideKeyboardCommand != null -> hideKeyboardCommand\n        takeScreenshotCommand != null -> takeScreenshotCommand\n        stopAppCommand != null -> stopAppCommand\n        killAppCommand != null -> killAppCommand\n        clearStateCommand != null -> clearStateCommand\n        clearKeychainCommand != null -> clearKeychainCommand\n        runFlowCommand != null -> runFlowCommand\n        setLocationCommand != null -> setLocationCommand\n        setOrientationCommand != null -> setOrientationCommand\n        assertScreenshotCommand != null -> assertScreenshotCommand\n        repeatCommand != null -> repeatCommand\n        copyTextCommand != null -> copyTextCommand\n        setClipboardCommand != null -> setClipboardCommand\n        pasteTextCommand != null -> pasteTextCommand\n        defineVariablesCommand != null -> defineVariablesCommand\n        runScriptCommand != null -> runScriptCommand\n        waitForAnimationToEndCommand != null -> waitForAnimationToEndCommand\n        evalScriptCommand != null -> evalScriptCommand\n        scrollUntilVisible != null -> scrollUntilVisible\n        travelCommand != null -> travelCommand\n        startRecordingCommand != null -> startRecordingCommand\n        stopRecordingCommand != null -> stopRecordingCommand\n        addMediaCommand != null -> addMediaCommand\n        setAirplaneModeCommand != null -> setAirplaneModeCommand\n        toggleAirplaneModeCommand != null -> toggleAirplaneModeCommand\n        retryCommand != null -> retryCommand\n        else -> null\n    }\n\n    fun elementSelector(): ElementSelector? {\n        return when {\n            tapOnElement?.selector != null -> tapOnElement.selector\n            swipeCommand?.elementSelector != null -> swipeCommand.elementSelector\n            copyTextCommand?.selector != null -> copyTextCommand.selector\n            assertConditionCommand?.condition?.visible != null -> assertConditionCommand.condition.visible\n            assertConditionCommand?.condition?.notVisible != null -> assertConditionCommand.condition.notVisible\n            scrollUntilVisible?.selector != null -> scrollUntilVisible.selector\n            else -> null\n        }\n    }\n\n    fun evaluateScripts(jsEngine: JsEngine): MaestroCommand {\n        return asCommand()\n            ?.let { MaestroCommand(it.evaluateScripts(jsEngine)) }\n            ?: MaestroCommand()\n    }\n\n    fun description(): String {\n        return asCommand()?.description() ?: \"No op\"\n    }\n\n    override fun toString(): String =\n        asCommand()?.let { command ->\n            val argName = command::class.simpleName?.replaceFirstChar(Char::lowercaseChar) ?: \"command\"\n            \"MaestroCommand($argName=$command)\"\n        } ?: \"MaestroCommand()\"\n}\n"
  },
  {
    "path": "maestro-orchestra-models/src/main/java/maestro/orchestra/MaestroConfig.kt",
    "content": "package maestro.orchestra\n\nimport maestro.js.JsEngine\nimport maestro.orchestra.util.Env.evaluateScripts\n\n// Note: The appId config is only a yaml concept for now. It'll be a larger migration to get to a point\n// where appId is part of MaestroConfig (and factored out of MaestroCommands - eg: LaunchAppCommand).\ndata class MaestroConfig(\n    val appId: String? = null,\n    val name: String? = null,\n    val tags: List<String>? = emptyList(),\n    val ext: Map<String, Any?> = emptyMap(),\n    val onFlowStart: MaestroOnFlowStart? = null,\n    val onFlowComplete: MaestroOnFlowComplete? = null,\n    val properties: Map<String, String> = emptyMap(),\n) {\n\n    fun evaluateScripts(jsEngine: JsEngine): MaestroConfig {\n        return copy(\n            appId = appId?.evaluateScripts(jsEngine),\n            name = name?.evaluateScripts(jsEngine),\n            onFlowComplete = onFlowComplete?.evaluateScripts(jsEngine),\n            onFlowStart = onFlowStart?.evaluateScripts(jsEngine),\n        )\n    }\n\n}\n\ndata class MaestroOnFlowComplete(val commands: List<MaestroCommand>) {\n    fun evaluateScripts(jsEngine: JsEngine): MaestroOnFlowComplete {\n        return this\n    }\n}\n\ndata class MaestroOnFlowStart(val commands: List<MaestroCommand>) {\n    fun evaluateScripts(jsEngine: JsEngine): MaestroOnFlowStart {\n        return this\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra-models/src/main/java/maestro/orchestra/WorkspaceConfig.kt",
    "content": "package maestro.orchestra\n\nimport com.fasterxml.jackson.annotation.JsonAnySetter\nimport com.fasterxml.jackson.annotation.JsonCreator\n\ndata class WorkspaceConfig(\n    val flows: StringList? = null,\n    val includeTags: StringList? = null,\n    val excludeTags: StringList? = null,\n    val executionOrder: ExecutionOrder? = null,\n    val baselineBranch: String? = null,\n    val notifications: MaestroNotificationConfiguration? = null,\n    @Deprecated(\"not supported now by default on cloud\") val disableRetries: Boolean = false,\n    val platform: PlatformConfiguration? = PlatformConfiguration(\n        android = PlatformConfiguration.AndroidConfiguration(disableAnimations = false),\n        ios = PlatformConfiguration.IOSConfiguration(disableAnimations = false)\n    ),\n    val testOutputDir: String? = null,\n) {\n\n    data class MaestroNotificationConfiguration(\n        val email: EmailConfig? = null,\n        val slack: SlackConfig? = null,\n    ) {\n        data class EmailConfig(\n            val recipients: List<String>,\n            val enabled: Boolean = true,\n            val onSuccess: Boolean = false,\n        )\n\n        data class SlackConfig(\n            val channels: List<String>,\n            val apiKey: String,\n            val enabled: Boolean = true,\n            val onSuccess: Boolean = false,\n        )\n    }\n\n    data class PlatformConfiguration(\n        val android: AndroidConfiguration? = null,\n        val ios: IOSConfiguration? = null\n    ) {\n        data class AndroidConfiguration(\n            val disableAnimations: Boolean = false,\n        )\n\n        data class IOSConfiguration(\n            val disableAnimations: Boolean = false,\n            val snapshotKeyHonorModalViews: Boolean? = null,\n        )\n    }\n\n    @JsonAnySetter\n    fun setOtherField(key: String, other: Any?) {\n        // Do nothing\n    }\n\n    data class ExecutionOrder(\n        val continueOnFailure: Boolean? = true,\n        val flowsOrder: List<String> = emptyList()\n    )\n\n    class StringList : ArrayList<String>() {\n\n        companion object {\n\n            @Suppress(\"unused\")\n            @JvmStatic\n            @JsonCreator(mode = JsonCreator.Mode.DELEGATING)\n            fun parse(string: String): StringList {\n                return StringList().apply {\n                    add(string)\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra-models/src/main/java/maestro/orchestra/util/Env.kt",
    "content": "package maestro.orchestra.util\n\nimport java.io.File\nimport maestro.js.JsEngine\nimport maestro.orchestra.DefineVariablesCommand\nimport maestro.orchestra.MaestroCommand\n\nobject Env {\n\n    fun String.evaluateScripts(jsEngine: JsEngine): String {\n        val result = \"(?<!\\\\\\\\)\\\\\\$\\\\{([^\\$]*)}\".toRegex()\n            .replace(this) { match ->\n                val script = match.groups[1]?.value ?: \"\"\n\n                if (script.isNotBlank()) {\n                    jsEngine.evaluateScript(script).toString()\n                } else {\n                    \"\"\n                }\n            }\n\n        return result\n            .replace(\"\\\\\\\\\\\\\\$\\\\{([^\\$]*)}\".toRegex()) { match ->\n                match.value.substringAfter('\\\\')\n            }\n    }\n\n    fun List<MaestroCommand>.withEnv(env: Map<String, String>): List<MaestroCommand> =\n        if (env.isEmpty()) this\n        else listOf(MaestroCommand(DefineVariablesCommand(env))) + this\n\n    /**\n     * Reserved internal env vars that are controlled exclusively by Maestro.\n     * These cannot be set externally via --env, flow env, or shell environment.\n     * Any external values will be stripped and replaced by internal logic.\n     */\n    private val INTERNAL_ONLY_ENV_VARS = setOf(\n        \"MAESTRO_SHARD_ID\",\n        \"MAESTRO_SHARD_INDEX\",\n    )\n\n    fun Map<String, String>.withInjectedShellEnvVars(): Map<String, String> = this +\n        System.getenv()\n            .filterKeys {\n                it.startsWith(\"MAESTRO_\") &&\n                    this.containsKey(it).not() &&\n                    it !in INTERNAL_ONLY_ENV_VARS\n            }\n            .filterValues { it != null && it.isNotEmpty() }\n\n    fun Map<String, String>.withDefaultEnvVars(\n        flowFile: File? = null,\n        deviceId: String? = null,\n        shardIndex: Int? = null,\n    ): Map<String, String> {\n        val defaultEnvVars = mutableMapOf<String, String>()\n        flowFile?.nameWithoutExtension?.let { defaultEnvVars[\"MAESTRO_FILENAME\"] = it }\n        deviceId?.takeIf { it.isNotBlank() }?.let { defaultEnvVars[\"MAESTRO_DEVICE_UDID\"] = it }\n        // Always set shard vars - use actual values if sharding, otherwise defaults (1, 0)\n        // This ensures flows using these vars don't fail with undefined when debugging in Studio\n        val effectiveShardIndex = shardIndex ?: 0\n        defaultEnvVars[\"MAESTRO_SHARD_ID\"] = (effectiveShardIndex + 1).toString()\n        defaultEnvVars[\"MAESTRO_SHARD_INDEX\"] = effectiveShardIndex.toString()\n        // Start with base map, removing any existing shard vars to prevent external pollution\n        val baseMap = this - INTERNAL_ONLY_ENV_VARS\n        return baseMap + defaultEnvVars\n    }\n}\n"
  },
  {
    "path": "maestro-orchestra-models/src/test/kotlin/maestro/orchestra/CommandsTest.kt",
    "content": "package maestro.orchestra\n\nimport org.junit.jupiter.api.Assertions.assertEquals\nimport org.junit.jupiter.api.Assertions.assertNotNull\nimport org.junit.jupiter.api.Test\n\nclass CommandsTest {\n\n    @Test\n    fun `should return not null value when call InputRandomCommand with NUMBER value`() {\n        assertNotNull(InputRandomCommand(inputType = InputRandomType.NUMBER).genRandomString())\n    }\n\n    @Test\n    fun `should return not null value when call InputRandomCommand with TEXT value`() {\n        assertNotNull(InputRandomCommand(inputType = InputRandomType.TEXT).genRandomString())\n    }\n\n    @Test\n    fun `should return not null value when call InputRandomCommand with TEXT_EMAIL_ADDRESS value`() {\n        assertNotNull(InputRandomCommand(inputType = InputRandomType.TEXT_EMAIL_ADDRESS).genRandomString())\n    }\n\n    @Test\n    fun `should return not null value when call InputRandomCommand with TEXT_PERSON_NAME value`() {\n        assertNotNull(InputRandomCommand(inputType = InputRandomType.TEXT_PERSON_NAME).genRandomString())\n    }\n\n    @Test\n    fun `should return not null value when call InputRandomCommand with TEXT_CITY_NAME value`() {\n        assertNotNull(InputRandomCommand(inputType = InputRandomType.TEXT_CITY_NAME).genRandomString())\n    }\n\n    @Test\n    fun `should return not null value when call InputRandomCommand with TEXT_COUNTRY_NAME value`() {\n        assertNotNull(InputRandomCommand(inputType = InputRandomType.TEXT_COUNTRY_NAME).genRandomString())\n    }\n\n    @Test\n    fun `should return not null value when call InputRandomCommand with TEXT_COLOR value`() {\n        assertNotNull(InputRandomCommand(inputType = InputRandomType.TEXT_COLOR).genRandomString())\n    }\n\n    @Test\n    fun `should return not null value when call InputRandomCommand without inputType value`() {\n        assertNotNull(InputRandomCommand().genRandomString())\n    }\n\n    @Test\n    fun `should return a value with 10 characters when call InputRandomCommand with NUMBER value and length value`() {\n        assertEquals(10, InputRandomCommand(inputType = InputRandomType.NUMBER, length = 10).genRandomString().length)\n    }\n\n    @Test\n    fun `should return a value with 20 characters when call InputRandomCommand with TEXT value and length value`() {\n        assertEquals(20, InputRandomCommand(inputType = InputRandomType.TEXT, length = 20).genRandomString().length)\n    }\n}"
  },
  {
    "path": "maestro-orchestra-models/src/test/kotlin/maestro/orchestra/ElementSelectorTest.kt",
    "content": "package maestro.orchestra\n\nimport com.google.common.truth.Truth.assertThat\nimport org.junit.jupiter.api.Test\nimport kotlin.math.truncate\n\nclass ElementSelectorTest {\n    @Test\n    fun `simple text description`(){\n        val command = AssertConditionCommand(\n            condition = Condition(\n                visible = ElementSelector(\n                    textRegex = \"Hello\"\n                )\n            )\n        )\n\n        assertThat(command.description()).isEqualTo(\"Assert that \\\"Hello\\\" is visible\")\n    }\n\n    @Test\n    fun `simple id description`(){\n        val command = AssertConditionCommand(\n            condition = Condition(\n                visible = ElementSelector(\n                    idRegex = \"hello_element\"\n                )\n            )\n        )\n\n        assertThat(command.description()).isEqualTo(\"Assert that id: hello_element is visible\")\n    }\n\n    @Test\n    fun `description with optional`(){\n        val command = AssertConditionCommand(\n            condition = Condition(\n                visible = ElementSelector(\n                    textRegex = \"Hello\"\n                )\n            ),\n            optional = true\n        )\n\n        assertThat(command.description()).isEqualTo(\"Assert that (Optional) \\\"Hello\\\" is visible\")\n    }\n\n    @Test\n    fun `description with enabled`(){\n        val command = AssertConditionCommand(\n            condition = Condition(\n                visible = ElementSelector(\n                    textRegex = \"Hello\",\n                    enabled = false\n                )\n            )\n        )\n\n        assertThat(command.description()).isEqualTo(\"Assert that \\\"Hello\\\", disabled is visible\")\n    }\n\n    @Test\n    fun `complex description`(){\n        val command = AssertConditionCommand(\n            condition = Condition(\n                visible = ElementSelector(\n                    textRegex = \"Hello\",\n                    idRegex = \"hello_element\",\n                    enabled = false,\n                    below = ElementSelector(\n                        textRegex = \"World\"\n                    ),\n                    above = ElementSelector(\n                        idRegex = \"page_break_element\"\n                    ),\n                    leftOf = ElementSelector(\n                        textRegex = \"Right\"\n                    ),\n                    rightOf = ElementSelector(\n                        idRegex = \"left_element\"\n                    ),\n                    containsChild = ElementSelector(\n                        idRegex = \"hello_emoji_container\"\n                    ),\n                    containsDescendants = listOf(\n                        ElementSelector(\n                            idRegex = \"hello_emoji\"\n                        ),\n                        ElementSelector(\n                            idRegex = \"hello_emoji_text\"\n                        ),\n                        ElementSelector(\n                            idRegex = \"have_been_greeted\",\n                            checked = true\n                        )\n                    ),\n                    index = \"0\",\n                    selected = false,\n                    focused = false,\n                    childOf = ElementSelector(\n                        textRegex = \"Welcome Screen\"\n                    )\n                )\n            )\n        )\n\n        assertThat(command.description()).isEqualTo(\"Assert that \\\"Hello\\\", id: hello_element, disabled, Below \\\"World\\\", Above id: page_break_element, Left of \\\"Right\\\", Right of id: left_element, Contains child: id: hello_emoji_container, Contains descendants: [id: hello_emoji, id: hello_emoji_text, id: have_been_greeted], Index: 0, not selected, not focused, Child of: \\\"Welcome Screen\\\" is visible\")\n    }\n}"
  },
  {
    "path": "maestro-orchestra-models/src/test/kotlin/maestro/orchestra/util/EnvTest.kt",
    "content": "package maestro.orchestra.util\n\nimport com.google.common.truth.Truth.assertThat\nimport java.io.File\nimport kotlin.random.Random\nimport maestro.orchestra.ApplyConfigurationCommand\nimport maestro.orchestra.DefineVariablesCommand\nimport maestro.orchestra.MaestroCommand\nimport maestro.orchestra.MaestroConfig\nimport maestro.orchestra.util.Env.withDefaultEnvVars\nimport maestro.orchestra.util.Env.withEnv\nimport maestro.orchestra.util.Env.withInjectedShellEnvVars\nimport org.junit.jupiter.api.Test\n\nclass EnvTest {\n\n    private val emptyEnv = emptyMap<String, String>()\n\n    @Test\n    fun `withDefaultEnvVars should add file name without extension`() {\n        val env = emptyEnv.withDefaultEnvVars(File(\"myFlow.yml\"))\n        assertThat(env[\"MAESTRO_FILENAME\"]).isEqualTo(\"myFlow\")\n    }\n\n    @Test\n    fun `withDefaultEnvVars should override MAESTRO_FILENAME`() {\n        val env = mapOf(\"MAESTRO_FILENAME\" to \"otherFile\").withDefaultEnvVars(File(\"myFlow.yml\"))\n        assertThat(env[\"MAESTRO_FILENAME\"]).isEqualTo(\"myFlow\")\n    }\n\n    @Test\n    fun `withDefaultEnvVars should add shard and device values`() {\n        val env = emptyEnv.withDefaultEnvVars(\n            flowFile = File(\"myFlow.yml\"),\n            deviceId = \"device-123\",\n            shardIndex = 1\n        )\n        assertThat(env[\"MAESTRO_DEVICE_UDID\"]).isEqualTo(\"device-123\")\n        assertThat(env[\"MAESTRO_SHARD_ID\"]).isEqualTo(\"2\")\n        assertThat(env[\"MAESTRO_SHARD_INDEX\"]).isEqualTo(\"1\")\n    }\n\n    @Test\n    fun `withDefaultEnvVars should override shard and device values`() {\n        val env = mapOf(\n            \"MAESTRO_DEVICE_UDID\" to \"old-device\",\n            \"MAESTRO_SHARD_ID\" to \"99\",\n            \"MAESTRO_SHARD_INDEX\" to \"98\",\n        ).withDefaultEnvVars(deviceId = \"device-456\", shardIndex = 0)\n        assertThat(env[\"MAESTRO_DEVICE_UDID\"]).isEqualTo(\"device-456\")\n        assertThat(env[\"MAESTRO_SHARD_ID\"]).isEqualTo(\"1\")\n        assertThat(env[\"MAESTRO_SHARD_INDEX\"]).isEqualTo(\"0\")\n    }\n\n    @Test\n    fun `withDefaultEnvVars should set default shard values when shardIndex is null`() {\n        // When not sharding, shard vars default to 1/0 so flows don't fail with undefined\n        val env = emptyEnv.withDefaultEnvVars(\n            flowFile = File(\"myFlow.yml\"),\n            deviceId = \"device-123\",\n            shardIndex = null\n        )\n        assertThat(env[\"MAESTRO_FILENAME\"]).isEqualTo(\"myFlow\")\n        assertThat(env[\"MAESTRO_DEVICE_UDID\"]).isEqualTo(\"device-123\")\n        assertThat(env[\"MAESTRO_SHARD_ID\"]).isEqualTo(\"1\")\n        assertThat(env[\"MAESTRO_SHARD_INDEX\"]).isEqualTo(\"0\")\n    }\n\n    @Test\n    fun `withDefaultEnvVars should override external shard values with defaults when shardIndex is null`() {\n        // External shard values (from --env, flow env, or shell) are replaced with defaults\n        val env = mapOf(\n            \"MAESTRO_SHARD_ID\" to \"99\",\n            \"MAESTRO_SHARD_INDEX\" to \"98\",\n            \"OTHER_VAR\" to \"preserved\",\n        ).withDefaultEnvVars(\n            flowFile = File(\"myFlow.yml\"),\n            shardIndex = null\n        )\n        // Shard values are reset to defaults (not the external values)\n        assertThat(env[\"MAESTRO_SHARD_ID\"]).isEqualTo(\"1\")\n        assertThat(env[\"MAESTRO_SHARD_INDEX\"]).isEqualTo(\"0\")\n        // Other vars are preserved\n        assertThat(env[\"OTHER_VAR\"]).isEqualTo(\"preserved\")\n        assertThat(env[\"MAESTRO_FILENAME\"]).isEqualTo(\"myFlow\")\n    }\n\n    @Test\n    fun `withInjectedShellEnvVars only keeps MAESTRO_ vars`() {\n        val env = emptyEnv.withInjectedShellEnvVars()\n        assertThat(env.filterKeys { it.startsWith(\"MAESTRO_\").not() }).isEmpty()\n    }\n\n    @Test\n    fun `withInjectedShellEnvVars should not inject shard variables from shell`() {\n        // Shard variables should only be controlled by internal logic (withDefaultEnvVars),\n        // not from external shell environment, to prevent inconsistent state where only\n        // one of MAESTRO_SHARD_ID or MAESTRO_SHARD_INDEX is set from external environment.\n        val env = emptyEnv.withInjectedShellEnvVars()\n        // These assertions verify that even if shell has MAESTRO_SHARD_* vars,\n        // they won't be injected. The actual shell env might not have these vars,\n        // but this test documents the expected behavior.\n        assertThat(env.containsKey(\"MAESTRO_SHARD_ID\")).isFalse()\n        assertThat(env.containsKey(\"MAESTRO_SHARD_INDEX\")).isFalse()\n    }\n\n    @Test\n    fun `withInjectedShellEnvVars does not strip previous MAESTRO_ vars`() {\n        val rand = Random.nextInt()\n        val env = mapOf(\"MAESTRO_$rand\" to \"$rand\").withInjectedShellEnvVars()\n        assertThat(env[\"MAESTRO_$rand\"]).isEqualTo(\"$rand\")\n    }\n\n    @Test\n    fun `withEnv does not affect empty env`() {\n        val commands = emptyList<MaestroCommand>()\n\n        val withEnv = commands.withEnv(emptyEnv)\n\n        assertThat(withEnv).isEmpty()\n    }\n\n    @Test\n    fun `withEnv prepends DefineVariable command`() {\n        val env = mapOf(\"MY_ENV_VAR\" to \"1234\")\n        val applyConfig = MaestroCommand(ApplyConfigurationCommand(MaestroConfig()))\n        val defineVariables = MaestroCommand(DefineVariablesCommand(env))\n\n        val withEnv = listOf(applyConfig).withEnv(env)\n\n        assertThat(withEnv).containsExactly(defineVariables, applyConfig)\n    }\n}\n"
  },
  {
    "path": "maestro-proto/build.gradle.kts",
    "content": "\nplugins {\n    id(\"maven-publish\")\n    java\n    alias(libs.plugins.mavenPublish)\n}\n\nmavenPublishing {\n    publishToMavenCentral(true)\n    signAllPublications()\n}\n\njava {\n    sourceCompatibility = JavaVersion.VERSION_17\n    targetCompatibility = JavaVersion.VERSION_17\n}\n\ntasks.named<Jar>(\"jar\") {\n    from(\"src/main/proto/maestro_android.proto\")\n}\n"
  },
  {
    "path": "maestro-proto/gradle.properties",
    "content": "POM_NAME=Orchestra Proto\nPOM_ARTIFACT_ID=maestro-proto\nPOM_PACKAGING=jar"
  },
  {
    "path": "maestro-proto/src/main/proto/maestro_android.proto",
    "content": "syntax = \"proto3\";\n\npackage maestro_android;\n\nservice MaestroDriver {\n\n  rpc deviceInfo(DeviceInfoRequest) returns (DeviceInfo) {}\n\n  rpc viewHierarchy(ViewHierarchyRequest) returns (ViewHierarchyResponse) {}\n\n  rpc screenshot(ScreenshotRequest) returns (ScreenshotResponse) {}\n\n  rpc tap(TapRequest) returns (TapResponse) {}\n\n  rpc inputText(InputTextRequest) returns (InputTextResponse) {}\n\n  rpc eraseAllText(EraseAllTextRequest) returns (EraseAllTextResponse) {}\n\n  rpc setLocation(SetLocationRequest) returns (SetLocationResponse) {}\n\n  rpc isWindowUpdating(CheckWindowUpdatingRequest) returns (CheckWindowUpdatingResponse) {}\n\n  rpc launchApp(LaunchAppRequest) returns (LaunchAppResponse) {}\n\n  rpc addMedia(stream AddMediaRequest) returns (AddMediaResponse) {}\n\n  rpc enableMockLocationProviders(EmptyRequest) returns (EmptyResponse) {}\n\n  rpc disableLocationUpdates(EmptyRequest) returns (EmptyResponse) {}\n}\n\nmessage EmptyRequest {}\nmessage EmptyResponse {}\n\nmessage LaunchAppRequest {\n\n  string packageName = 1;\n  repeated ArgumentValue arguments = 2;\n}\n\nmessage ArgumentValue {\n  string key = 1;\n  string value = 2;\n  string type = 3;\n}\n\nmessage LaunchAppResponse {}\n\n// Device info\nmessage DeviceInfoRequest {}\n\nmessage DeviceInfo {\n  uint32 widthPixels = 1;\n  uint32 heightPixels = 2;\n}\n\nmessage ScreenshotRequest {}\n\nmessage ScreenshotResponse {\n  bytes bytes = 1;\n}\n\n// View hierarchy\nmessage ViewHierarchyRequest {}\n\nmessage ViewHierarchyResponse {\n  string hierarchy = 1;\n}\n\n// Interactions\n\nmessage TapRequest {\n  uint32 x = 1;\n  uint32 y = 2;\n}\n\nmessage TapResponse {}\n\nmessage InputTextRequest {\n  string text = 1;\n}\nmessage InputTextResponse {}\n\n\nmessage EraseAllTextRequest {\n    uint32 charactersToErase = 1;\n}\n\nmessage EraseAllTextResponse {}\n\nmessage SetLocationRequest {\n    double latitude = 1;\n    double longitude = 2;\n}\n\nmessage SetLocationResponse {}\n\nmessage CheckWindowUpdatingRequest {\n  string appId = 1;\n}\n\nmessage CheckWindowUpdatingResponse {\n  bool isWindowUpdating = 1;\n}\n\nmessage AddMediaRequest {\n  Payload payload = 1;\n  string media_name = 2;\n  string media_ext = 3;\n}\n\nmessage AddMediaResponse { }\n\nmessage Payload {\n  bytes data = 1;\n}"
  },
  {
    "path": "maestro-studio/server/.gitignore",
    "content": "/src/main/resources/web"
  },
  {
    "path": "maestro-studio/server/build.gradle",
    "content": "import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask\n\nplugins {\n    id(\"maven-publish\")\n    id(\"application\")\n    alias(libs.plugins.kotlin.jvm)\n    alias(libs.plugins.mavenPublish)\n}\n\napplication {\n    applicationName = \"maestro-studio\"\n    mainClass = \"maestro.studio.ServerKt\"\n}\n\nmavenPublishing {\n    publishToMavenCentral(true)\n    signAllPublications()\n}\n\ndef copyWebFiles = tasks.register(\"copyWebFiles\", Copy.class) {\n    dependsOn(\":maestro-studio:web:build\")\n    from(new File(project(\":maestro-studio:web\").projectDir, \"build\"))\n    into(new File(projectDir, \"src/main/resources/web\"))\n}\n\ntasks.compileJava {\n    dependsOn(copyWebFiles)\n}\n\ntasks.processResources {\n    dependsOn(copyWebFiles)\n}\n\ntasks.whenTaskAdded {\n    if (name == \"sourcesJar\") {\n        dependsOn(copyWebFiles)\n    }\n}\n\njava {\n    sourceCompatibility = JavaVersion.VERSION_17\n    targetCompatibility = JavaVersion.VERSION_17\n}\n\nkotlin {\n    jvmToolchain(17)\n}\n\ntasks.named(\"compileKotlin\", KotlinCompilationTask) {\n    compilerOptions {\n        freeCompilerArgs.addAll(\"-Xjdk-release=17\")\n    }\n}\n\ndependencies {\n    implementation(project(\":maestro-orchestra\"))\n    implementation(project(\":maestro-client\"))\n    implementation(project(\":maestro-utils\"))\n    implementation(libs.ktor.server.core)\n    implementation(libs.ktor.server.netty)\n    implementation(libs.ktor.server.cors)\n    implementation(libs.ktor.server.status.pages)\n    implementation(libs.jackson.module.kotlin)\n    implementation(libs.square.okhttp)\n}\n"
  },
  {
    "path": "maestro-studio/server/src/main/java/maestro/studio/AuthService.kt",
    "content": "package maestro.studio\n\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport io.ktor.http.*\nimport io.ktor.server.application.*\nimport io.ktor.server.response.*\nimport io.ktor.server.routing.*\nimport maestro.auth.ApiKey\nimport java.io.IOException\nimport java.nio.file.Paths\nimport kotlin.io.path.createDirectories\nimport kotlin.io.path.deleteIfExists\nimport kotlin.io.path.isRegularFile\nimport kotlin.io.path.readText\nimport kotlin.io.path.writeText\n\ndata class OpenAiTokenRequest(\n    val token: String\n)\n\ndata class AuthResponse(\n    val authToken: String?,\n    val openAiToken: String?,\n)\n\nobject AuthService {\n\n    private val mobileDevDir = Paths.get(System.getProperty(\"user.home\"), \".mobiledev\")\n    private val openAiTokenFile = mobileDevDir.resolve(\"openaitoken\")\n\n    fun routes(routing: Routing) {\n        routing.get(\"/api/auth-token\") {\n            val authToken = ApiKey.getToken()\n            if (authToken == null) {\n                call.respond(HttpStatusCode.NotFound, \"No auth token found\")\n            } else {\n                call.respond(authToken)\n            }\n        }\n        routing.get(\"/api/auth\") {\n            val authToken = ApiKey.getToken()\n            val openAiToken = getOpenAiToken()\n            val response = AuthResponse(authToken, openAiToken)\n            val responseString = jacksonObjectMapper().writeValueAsString(response)\n            call.respond(responseString)\n        }\n        routing.post(\"/api/auth/openai-token\") {\n            val request = call.parseBody<OpenAiTokenRequest>()\n            try {\n                setOpenAiToken(request.token)\n                call.respond(HttpStatusCode.OK)\n            } catch (e: IOException) {\n                call.respond(HttpStatusCode.BadRequest, \"Failed to save OpenAI token: ${e.message}\")\n            }\n        }\n        routing.delete(\"/api/auth/openai-token\") {\n            try {\n                setOpenAiToken(null)\n                call.respond(HttpStatusCode.OK)\n            } catch (e: IOException) {\n                call.respond(HttpStatusCode.BadRequest, \"Failed to delete OpenAI token: ${e.message}\")\n            }\n        }\n    }\n\n    private fun getOpenAiToken(): String? {\n        if (!openAiTokenFile.isRegularFile()) return null\n        return openAiTokenFile.readText()\n    }\n\n    private fun setOpenAiToken(token: String?) {\n        if (token == null) {\n            openAiTokenFile.deleteIfExists()\n        } else {\n            openAiTokenFile.parent.createDirectories()\n            openAiTokenFile.writeText(token)\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-studio/server/src/main/java/maestro/studio/DeviceService.kt",
    "content": "package maestro.studio\n\nimport com.fasterxml.jackson.annotation.JsonInclude\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport io.ktor.http.*\nimport io.ktor.server.application.*\nimport io.ktor.server.http.content.*\nimport io.ktor.server.response.*\nimport io.ktor.server.routing.*\nimport io.ktor.utils.io.*\nimport kotlinx.coroutines.runBlocking\nimport maestro.ElementFilter\nimport maestro.Filters\nimport maestro.Maestro\nimport maestro.TreeNode\nimport maestro.orchestra.Orchestra\nimport maestro.utils.StringUtils.toRegexSafe\nimport java.io.File\nimport java.nio.file.Path\nimport java.nio.file.Paths\nimport java.util.*\nimport java.util.regex.Pattern\nimport kotlin.io.path.Path\nimport kotlin.io.path.createDirectories\nimport kotlin.io.path.createTempDirectory\nimport maestro.orchestra.MaestroCommand\nimport maestro.orchestra.yaml.FlowParseException\nimport maestro.orchestra.yaml.MaestroFlowParser\nimport maestro.orchestra.yaml.YamlCommandReader\nimport maestro.orchestra.yaml.YamlFluentCommand\n\nprivate data class RunCommandRequest(\n    val yaml: String,\n    val dryRun: Boolean?,\n)\n\nprivate data class FormatCommandsRequest(\n    val commands: List<String>,\n)\n\nprivate data class FormattedFlow(\n    val config: String,\n    val commands: String,\n)\n\nobject DeviceService {\n\n    private const val MAX_SCREENSHOTS = 10\n\n    private val SCREENSHOT_DIR = getScreenshotDir()\n\n    private val savedScreenshots = mutableListOf<File>()\n\n    private var lastViewHierarchy: TreeNode? = null\n\n    fun routes(routing: Routing, maestro: Maestro) {\n        routing.post(\"/api/run-command\") {\n            val request = call.parseBody<RunCommandRequest>()\n            try {\n                val commands = MaestroFlowParser.parseCommand(Paths.get(\"\"), \"\", request.yaml)\n                if (request.dryRun != true) {\n                    executeCommands(maestro, commands)\n                }\n                val response = jacksonObjectMapper().writeValueAsString(commands)\n                call.respond(response)\n            } catch (e: FlowParseException) {\n                call.respond(HttpStatusCode.BadRequest, listOfNotNull(e.errorMessage, e.docs).joinToString(\"\\n\"))\n            } catch (e: Exception) {\n                call.respond(HttpStatusCode.BadRequest, e.message ?: \"Failed to run command\")\n            }\n        }\n        routing.post(\"/api/format-flow\") {\n            val request = call.parseBody<FormatCommandsRequest>()\n            val commands = request.commands.map { YamlCommandReader.readSingleCommand(Paths.get(\"\"), \"\", it) }\n            val inferredAppId = commands.flatten().firstNotNullOfOrNull { it.launchAppCommand?.appId }\n            val commandsString = YamlCommandReader.formatCommands(request.commands)\n            val formattedFlow = FormattedFlow(\"appId: $inferredAppId\", commandsString)\n            val response = jacksonObjectMapper().writeValueAsString(formattedFlow)\n            call.respondText(response)\n        }\n        // Ktor SSE sample project: https://github.com/ktorio/ktor-samples/blob/main/sse/src/main/kotlin/io/ktor/samples/sse/SseApplication.kt\n        routing.get(\"/api/device-screen/sse\") {\n            call.response.cacheControl(CacheControl.NoCache(null))\n            call.respondBytesWriter(contentType = ContentType.Text.EventStream) {\n                while (true) {\n                    try {\n                        val deviceScreen = getDeviceScreen(maestro)\n                        writeStringUtf8(\"data: $deviceScreen\\n\\n\")\n                        flush()\n                    } catch (_: Exception) {\n                        // Ignoring the exception to prevent SSE stream from dying\n                        // Don't log since this floods the terminal after killing studio\n                    }\n                }\n            }\n        }\n        routing.get(\"/api/last-view-hierarchy\") {\n            if (lastViewHierarchy == null) {\n                call.respond(HttpStatusCode.NotFound, \"No view hierarchy available\")\n            } else {\n                val response = jacksonObjectMapper().writeValueAsString(lastViewHierarchy)\n                call.respond(response)\n            }\n        }\n        routing.static(\"/screenshot\") {\n            staticRootFolder = SCREENSHOT_DIR.toFile()\n            files(\".\")\n        }\n    }\n\n    private fun executeCommands(maestro: Maestro, commands: List<MaestroCommand>) {\n        runBlocking {\n            var failure: Throwable? = null\n            val result = Orchestra(maestro, onCommandFailed = { _, _, throwable ->\n                failure = throwable\n                Orchestra.ErrorResolution.FAIL\n            }).runFlow(commands)\n            if (failure != null) {\n                throw RuntimeException(\"Command execution failed\")\n            }\n        }\n    }\n\n    private fun treeToElements(tree: TreeNode): List<UIElement> {\n        fun gatherElements(tree: TreeNode, list: MutableList<TreeNode>): List<TreeNode> {\n            tree.children.forEach { child ->\n                gatherElements(child, list)\n            }\n            list.add(tree)\n            return list\n        }\n\n        fun TreeNode.attribute(key: String): String? {\n            val value = attributes[key]\n            if (value.isNullOrEmpty()) return null\n            return value\n        }\n\n        val elements = gatherElements(tree, mutableListOf())\n            .sortedWith(Filters.INDEX_COMPARATOR)\n\n        fun getIndex(filter: ElementFilter, element: TreeNode): Int? {\n            val identityHashMap = IdentityHashMap<TreeNode, Unit>()\n            val matchingElements = Filters.deepestMatchingElement(filter)(elements).filter {\n                // There are duplicate elements for some reason (likely due to unintended behavior in Filter.deepestMatchingElement) - filter them out\n                identityHashMap.put(it, Unit) == null\n            }\n            if (matchingElements.size < 2) return null\n            return matchingElements.sortedWith(Filters.INDEX_COMPARATOR).indexOf(element)\n        }\n\n        val ids = mutableMapOf<String, Int>()\n        return elements.map { element ->\n            val bounds = element.bounds()\n            val text = element.attribute(\"text\")\n            val hintText = element.attribute(\"hintText\")\n            val accessibilityText = element.attribute(\"accessibilityText\")\n            val resourceId = element.attribute(\"resource-id\")\n            val textIndex = if (text == null) {\n                null\n            } else {\n                getIndex(Filters.textMatches(text.toRegexSafe(Orchestra.REGEX_OPTIONS)), element)\n            }\n            val resourceIdIndex = if (resourceId == null) {\n                null\n            } else {\n                getIndex(Filters.idMatches(resourceId.toRegexSafe(Orchestra.REGEX_OPTIONS)), element)\n            }\n            fun createElementId(): String {\n                val parts = listOfNotNull(resourceId, resourceIdIndex, text, textIndex)\n                val fallbackId = bounds?.let { (x, y, w, h) -> \"$x,$y,$w,$h\" } ?: UUID.randomUUID().toString()\n                val id = if (parts.isEmpty()) fallbackId else parts.joinToString(\"-\")\n                val index = ids.compute(id) { _, i -> (i ?: 0) + 1}\n                return if (index == 1) id else \"$id-$index\"\n            }\n            val id = createElementId()\n            UIElement(id, bounds, resourceId, resourceIdIndex, text, hintText, accessibilityText, textIndex)\n        }\n    }\n\n    private fun getDeviceScreen(maestro: Maestro): String {\n        val tree: TreeNode\n        val screenshotFile: File\n        synchronized(DeviceService) {\n            tree = maestro.viewHierarchy().root\n            lastViewHierarchy = tree\n            screenshotFile = takeScreenshot(maestro)\n            savedScreenshots.add(screenshotFile)\n            while (savedScreenshots.size > MAX_SCREENSHOTS) {\n                savedScreenshots.removeFirst().delete()\n            }\n        }\n\n        val deviceInfo = maestro.deviceInfo()\n        val deviceWidth = deviceInfo.widthGrid\n        val deviceHeight = deviceInfo.heightGrid\n\n        val url = tree.attributes[\"url\"]\n        val elements = treeToElements(tree)\n        val deviceScreen = DeviceScreen(deviceInfo.platform, \"/screenshot/${screenshotFile.name}\", deviceWidth, deviceHeight, elements, url)\n        return jacksonObjectMapper()\n            .setSerializationInclusion(JsonInclude.Include.NON_NULL)\n            .writeValueAsString(deviceScreen)\n    }\n\n    private fun TreeNode.bounds(): UIElementBounds? {\n        val boundsString = attributes[\"bounds\"] ?: return null\n        val pattern = Pattern.compile(\"\\\\[([0-9-]+),([0-9-]+)]\\\\[([0-9-]+),([0-9-]+)]\")\n        val m = pattern.matcher(boundsString)\n        if (!m.matches()) {\n            System.err.println(\"Warning: Bounds text does not match expected pattern: $boundsString\")\n            return null\n        }\n\n        val l = m.group(1).toIntOrNull() ?: return null\n        val t = m.group(2).toIntOrNull() ?: return null\n        val r = m.group(3).toIntOrNull() ?: return null\n        val b = m.group(4).toIntOrNull() ?: return null\n\n        return UIElementBounds(\n            x = l,\n            y = t,\n            width = r - l,\n            height = b - t,\n        )\n    }\n\n    private fun takeScreenshot(maestro: Maestro): File {\n        val name = \"${UUID.randomUUID()}.png\"\n        val screenshotFile = SCREENSHOT_DIR.resolve(name).toFile()\n        screenshotFile.deleteOnExit()\n        try {\n            maestro.takeScreenshot(screenshotFile, true)\n        } catch (ignore: Exception) {\n            // ignore intermittent screenshot errors\n        }\n        return screenshotFile\n    }\n\n    private fun getScreenshotDir(): Path {\n        val home = System.getProperty(\"user.home\")\n        val parent = if (home.isNullOrBlank()) createTempDirectory() else Path(home)\n        val screenshotDir = parent.resolve(\".maestro/studio/screenshots\")\n        screenshotDir.createDirectories()\n        return screenshotDir\n    }\n}\n"
  },
  {
    "path": "maestro-studio/server/src/main/java/maestro/studio/HttpException.kt",
    "content": "package maestro.studio\n\nimport io.ktor.http.HttpStatusCode\n\ndata class HttpException(\n    val statusCode: HttpStatusCode,\n    val errorMessage: String,\n) : RuntimeException(\"$statusCode: $errorMessage\")\n"
  },
  {
    "path": "maestro-studio/server/src/main/java/maestro/studio/InsightService.kt",
    "content": "package maestro.studio\n\nimport com.fasterxml.jackson.annotation.JsonInclude\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport io.ktor.server.application.*\nimport io.ktor.server.response.*\nimport io.ktor.server.routing.*\nimport maestro.studio.BannerMessage.*\nimport maestro.utils.Insight\nimport maestro.utils.CliInsights\n\nobject InsightService {\n\n    private var currentInsight: Insight = Insight(\"\", Insight.Level.NONE)\n\n    fun routes(routing: Route) {\n        registerInsightUpdateCallback()\n\n        routing.get(\"/api/banner-message\") {\n            if (currentInsight.level != Insight.Level.NONE) {\n                val bannerMessage = BannerMessage(\n                    currentInsight.message,\n                    Level.valueOf(currentInsight.level.toString()).toString().lowercase()\n                )\n                val response = jacksonObjectMapper()\n                    .setSerializationInclusion(JsonInclude.Include.NON_NULL)\n                    .writerWithDefaultPrettyPrinter()\n                    .writeValueAsString(bannerMessage)\n                call.respondText(response)\n            } else {\n                val response = jacksonObjectMapper()\n                    .setSerializationInclusion(JsonInclude.Include.NON_NULL)\n                    .writerWithDefaultPrettyPrinter()\n                    .writeValueAsString(BannerMessage(\"\", Level.NONE.toString().lowercase()))\n                call.respondText(response)\n            }\n        }\n    }\n\n    private fun registerInsightUpdateCallback() {\n        CliInsights.onInsightsUpdated {\n            currentInsight = it\n        }\n    }\n}\n\ndata class BannerMessage(val message: String, val level: String) {\n    enum class Level {\n        WARNING,\n        NONE,\n        INFO\n    }\n}"
  },
  {
    "path": "maestro-studio/server/src/main/java/maestro/studio/KtorUtils.kt",
    "content": "package maestro.studio\n\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport io.ktor.http.HttpStatusCode\nimport io.ktor.server.application.ApplicationCall\nimport io.ktor.server.request.receiveStream\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.withContext\nimport java.io.IOException\n\nsuspend inline fun <reified T> ApplicationCall.parseBody(): T {\n    return try {\n        receiveStream().use { body ->\n            withContext(Dispatchers.IO) {\n                jacksonObjectMapper().readValue(body, T::class.java)\n            }\n        }\n    } catch (e: IOException) {\n        throw HttpException(HttpStatusCode.BadRequest, \"Failed to parse request body\")\n    }\n}\n"
  },
  {
    "path": "maestro-studio/server/src/main/java/maestro/studio/MaestroStudio.kt",
    "content": "package maestro.studio\n\nimport io.ktor.http.HttpHeaders\nimport io.ktor.server.application.install\nimport io.ktor.server.engine.embeddedServer\nimport io.ktor.server.http.content.singlePageApplication\nimport io.ktor.server.netty.Netty\nimport io.ktor.server.plugins.cors.routing.CORS\nimport io.ktor.server.plugins.statuspages.StatusPages\nimport io.ktor.server.request.ApplicationReceivePipeline\nimport io.ktor.server.response.respond\nimport io.ktor.server.routing.routing\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.withContext\nimport maestro.Maestro\nimport maestro.mockserver.MockInteractor\n\nobject MaestroStudio {\n\n    fun start(port: Int, maestro: Maestro?) {\n        embeddedServer(Netty, port = port) {\n            install(CORS) {\n                allowHost(\"localhost:3000\")\n                allowHost(\"studio.mobile.dev\", listOf(\"https\"))\n                allowHeader(HttpHeaders.ContentType)\n            }\n            install(StatusPages) {\n                exception<HttpException> { call, cause ->\n                    call.respond(cause.statusCode, cause.errorMessage)\n                }\n                exception { _, cause: Throwable ->\n                    cause.printStackTrace()\n                }\n            }\n            receivePipeline.intercept(ApplicationReceivePipeline.Before) {\n                withContext(Dispatchers.IO) {\n                    proceed()\n                }\n            }\n            routing {\n                if (maestro != null) {\n                    DeviceService.routes(this, maestro)\n                    InsightService.routes(this)\n                    AuthService.routes(this)\n                }\n                MockService.routes(this, MockInteractor())\n                singlePageApplication {\n                    useResources = true\n                    filesPath = \"web\"\n                    defaultPage = \"index.html\"\n                }\n            }\n        }.start()\n    }\n}\n"
  },
  {
    "path": "maestro-studio/server/src/main/java/maestro/studio/MockService.kt",
    "content": "package maestro.studio\n\nimport com.fasterxml.jackson.annotation.JsonInclude\nimport com.fasterxml.jackson.databind.DeserializationFeature\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport io.ktor.server.application.call\nimport io.ktor.server.response.respondText\nimport io.ktor.server.routing.Routing\nimport io.ktor.server.routing.get\nimport maestro.mockserver.MockEvent\nimport maestro.mockserver.MockInteractor\nimport java.util.UUID\n\nprivate data class GetMockDataResponse(\n    val projectId: UUID?,\n    val events: List<MockEvent>,\n)\n\nobject MockService {\n\n    fun routes(routing: Routing, interactor: MockInteractor) {\n        routing.get(\"/api/mock-server/data\") {\n            val data = GetMockDataResponse(\n                projectId = interactor.getProjectId(),\n                events = interactor.getMockEvents()\n            )\n\n            val response = jacksonObjectMapper()\n                .setSerializationInclusion(JsonInclude.Include.NON_NULL)\n                .writerWithDefaultPrettyPrinter()\n                .writeValueAsString(data)\n            call.respondText(response)\n        }\n    }\n\n}"
  },
  {
    "path": "maestro-studio/server/src/main/java/maestro/studio/Models.kt",
    "content": "package maestro.studio\n\nimport com.fasterxml.jackson.annotation.JsonProperty\nimport maestro.device.Platform\nimport java.util.UUID\n\ndata class DeviceScreen(\n    val platform: Platform,\n    val screenshot: String,\n    val width: Int,\n    val height: Int,\n    val elements: List<UIElement>,\n    val url: String?,\n)\n\ndata class UIElementBounds(\n    val x: Int,\n    val y: Int,\n    val width: Int,\n    val height: Int,\n)\n\ndata class UIElement(\n    val id: String, // Autogenerated uuid to make this easier to work with on the frontend\n    val bounds: UIElementBounds?,\n    val resourceId: String?,\n    val resourceIdIndex: Int?,\n    val text: String?,\n    val hintText: String?,\n    val accessibilityText: String?,\n    val textIndex: Int?\n)\n\nenum class ReplCommandStatus {\n    @JsonProperty(\"pending\")\n    PENDING,\n    @JsonProperty(\"running\")\n    RUNNING,\n    @JsonProperty(\"success\")\n    SUCCESS,\n    @JsonProperty(\"error\")\n    ERROR,\n    @JsonProperty(\"canceled\")\n    CANCELED,\n}\n\ndata class ReplCommand(\n    val id: UUID,\n    val yaml: String,\n    val status: ReplCommandStatus,\n)\n\ndata class Repl(\n    val commands: List<ReplCommand>,\n)\n"
  },
  {
    "path": "maestro-studio/web/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n\n# production\n/build\n\n# misc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nyarn.lock"
  },
  {
    "path": "maestro-studio/web/.npmrc",
    "content": "legacy-peer-deps=true\n"
  },
  {
    "path": "maestro-studio/web/.nvmrc",
    "content": "v20.19.4"
  },
  {
    "path": "maestro-studio/web/build.gradle",
    "content": "tasks.register(\"deps\", Exec.class) {\n    inputs.file(layout.projectDirectory.file(\"package.json\"))\n    outputs.dir(layout.projectDirectory.dir(\"node_modules\"))\n    if (System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows')) {\n        commandLine 'npm.cmd', 'install'\n    } else {\n        commandLine 'npm', 'install'\n    }\n}\n\ntasks.register(\"build\", Exec.class) {\n    def inputFiles = fileTree(layout.projectDirectory) {\n        exclude(\"build\", \"node_modules\", \".idea\")\n    }\n    inputs.files(inputFiles)\n    outputs.dir(layout.projectDirectory.dir(\"build\"))\n    dependsOn(tasks.deps)\n    if (System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows')) {\n        commandLine 'npm.cmd', 'run', 'build'\n    } else {\n        commandLine(\"npm\", \"run\", \"build\")\n    }\n}\n"
  },
  {
    "path": "maestro-studio/web/package.json",
    "content": "{\n  \"name\": \"maestro-studio\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"@headlessui/react\": \"^1.7.15\",\n    \"@radix-ui/react-dialog\": \"^1.0.4\",\n    \"@radix-ui/react-dropdown-menu\": \"^2.0.5\",\n    \"@radix-ui/react-tabs\": \"^1.0.4\",\n    \"@react-hook/mouse-position\": \"^4.1.3\",\n    \"@testing-library/jest-dom\": \"^5.16.5\",\n    \"@testing-library/react\": \"^13.4.0\",\n    \"@testing-library/user-event\": \"^13.5.0\",\n    \"@textea/json-viewer\": \"^2.13.1\",\n    \"@types/jest\": \"^27.5.2\",\n    \"@types/node\": \"^16.18.4\",\n    \"@types/react\": \"^18.0.26\",\n    \"@types/react-dom\": \"^18.0.9\",\n    \"ajv\": \"^6.12.6\",\n    \"ajv-keywords\": \"^3.5.2\",\n    \"class-variance-authority\": \"^0.6.0\",\n    \"clsx\": \"^1.2.1\",\n    \"copy-to-clipboard\": \"^3.3.3\",\n    \"date-fns\": \"^2.29.3\",\n    \"file-saver\": \"^2.0.5\",\n    \"framer-motion\": \"^7.10.3\",\n    \"fuse.js\": \"^6.6.2\",\n    \"prop-types\": \"^15.8.1\",\n    \"react\": \"^18.2.0\",\n    \"react-beautiful-dnd\": \"^13.1.1\",\n    \"react-contenteditable\": \"^3.3.6\",\n    \"react-dom\": \"^18.2.0\",\n    \"react-draggable\": \"^4.4.5\",\n    \"react-hotkeys-hook\": \"^4.3.5\",\n    \"react-icons\": \"^4.9.0\",\n    \"react-router-dom\": \"^6.8.1\",\n    \"react-scripts\": \"5.0.1\",\n    \"swr\": \"^2.1.5\",\n    \"tailwind-merge\": \"^1.13.2\",\n    \"typescript\": \"^4.9.3\",\n    \"uuid\": \"^9.0.0\",\n    \"web-vitals\": \"^2.1.4\",\n    \"yaml\": \"^2.2.1\"\n  },\n  \"overrides\": {\n    \"react-scripts\": {\n      \"@svgr/webpack\": \"8.1.0\",\n      \"postcss\": \"$postcss\",\n      \"webpack-dev-server\": \"5.2.1\"\n    }\n  },\n  \"scripts\": {\n    \"start\": \"react-scripts start\",\n    \"build\": \"react-scripts build\",\n    \"test\": \"react-scripts test\",\n    \"eject\": \"react-scripts eject\",\n    \"storybook\": \"start-storybook -p 6006\",\n    \"build-storybook\": \"build-storybook\"\n  },\n  \"eslintConfig\": {\n    \"extends\": [\n      \"react-app\",\n      \"react-app/jest\"\n    ],\n    \"overrides\": [\n      {\n        \"files\": [\n          \"**/*.stories.*\"\n        ],\n        \"rules\": {\n          \"import/no-anonymous-default-export\": \"off\"\n        }\n      }\n    ]\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.2%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  },\n  \"devDependencies\": {\n    \"@types/file-saver\": \"^2.0.5\",\n    \"@types/lodash\": \"^4.17.20\",\n    \"@types/react-beautiful-dnd\": \"^13.1.4\",\n    \"@types/uuid\": \"^9.0.3\",\n    \"ajv\": \"^6.12.6\",\n    \"ajv-keywords\": \"^3.5.2\",\n    \"autoprefixer\": \"^10.4.13\",\n    \"postcss\": \"^8.4.31\",\n    \"tailwindcss\": \"^3.2.4\",\n    \"webpack\": \"^5.75.0\"\n  }\n}\n"
  },
  {
    "path": "maestro-studio/web/postcss.config.js",
    "content": "module.exports = {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n}\n"
  },
  {
    "path": "maestro-studio/web/public/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <link rel=\"icon\" href=\"favicon.ico\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n    <title>Maestro Studio</title>\n  </head>\n  <body>\n    <noscript>You need to enable JavaScript to run this app.</noscript>\n    <div id=\"root\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "maestro-studio/web/src/App.tsx",
    "content": "import React from \"react\";\nimport { Routes, Route, Navigate } from \"react-router-dom\";\nimport Header from \"./components/common/Header\";\nimport InteractPage from \"./pages/InteractPage\";\nimport { AuthProvider } from \"./context/AuthContext\";\n\nconst App = () => (\n  <div className=\"flex flex-col h-screen overflow-hidden dark:bg-slate-900\">\n    <AuthProvider>\n      <Header />\n      <div className=\"overflow-hidden h-full\">\n        <Routes>\n          <Route path=\"interact\" element={<InteractPage />} />\n          <Route path=\"*\" element={<Navigate to=\"/interact\" replace />} />\n        </Routes>\n      </div>\n    </AuthProvider>\n  </div>\n);\n\nexport default App;\n"
  },
  {
    "path": "maestro-studio/web/src/api/api.ts",
    "content": "import _ from \"lodash\";\nimport {\n  AiResponseType,\n  AuthType,\n  BannerMessage,\n  DeviceScreen,\n  FormattedFlow,\n  ViewHierarchyType,\n} from \"../helpers/models\";\nimport useSWR, { SWRConfiguration, SWRResponse } from \"swr\";\nimport useSWRSubscription, { SWRSubscriptionResponse } from \"swr/subscription\";\n\nexport class HttpError extends Error {\n  constructor(public status: number, public message: string) {\n    super(message);\n  }\n}\n\nexport const wait = async (durationMs: number) => {\n  return new Promise((resolve) => setTimeout(resolve, durationMs));\n};\nconst makeRequest = async <T>(\n  method: string,\n  path: string,\n  body?: Object | undefined,\n  type?: \"json\" | \"text\"\n): Promise<T> => {\n  const options: RequestInit = {\n    method,\n    headers: {\n      Accept: \"application/json\",\n      \"Content-Type\": \"application/json\",\n    },\n  };\n  // Ensure body isn't set for GET or HEAD requests\n  if (body && method !== \"GET\" && method !== \"HEAD\") {\n    options.body = JSON.stringify(body);\n  }\n  const response = await fetch(path, options);\n  if (!response.ok) {\n    const responseBody = await response.text();\n    throw new HttpError(response.status, responseBody);\n  }\n  const contentLength = response.headers.get(\"Content-Length\");\n  if (contentLength === \"0\") {\n    return null as any as T;\n  }\n  if (type === \"text\") {\n    return (await response.text()) as any as T;\n  }\n  try {\n    return (await response.json()) as T;\n  } catch (error: any) {\n    throw new Error(\"Failed to parse JSON: \" + _.get(error, \"message\"));\n  }\n};\n\nconst useSse = <T>(url: string): SWRSubscriptionResponse<T> => {\n  return useSWRSubscription<T, any, string>(url, (key, { next }) => {\n    const eventSource = new EventSource(key);\n    eventSource.onmessage = (e) => {\n      const repl: T = JSON.parse(e.data);\n      next(null, repl);\n    };\n    eventSource.onerror = (error) => {\n      next(error);\n    };\n    return () => eventSource.close();\n  });\n};\n\nconst useDeviceScreen = (): { deviceScreen?: DeviceScreen; error?: any } => {\n  const { data: deviceScreen, error } = useSse<DeviceScreen>(\n    \"/api/device-screen/sse\"\n  );\n  return { deviceScreen, error };\n};\n\nexport const API = {\n  useAuth: (config?: SWRConfiguration<AuthType>): SWRResponse<AuthType> => {\n    return useSWR(\n      \"/api/auth-token\",\n      () => makeRequest(\"GET\", \"/api/auth\"),\n      config\n    );\n  },\n  useDeviceScreen,\n  useBannerMessage: (\n    config?: SWRConfiguration<BannerMessage>\n  ): SWRResponse<BannerMessage> => {\n    return useSWR(\n      \"/api/banner-message\",\n      (url) => makeRequest(\"GET\", url),\n      config\n    );\n  },\n  runCommand: async (yaml: string, dryRun?: boolean): Promise<void> => {\n    await makeRequest(\"POST\", \"/api/run-command\", { yaml, dryRun });\n  },\n  formatFlow: async (commands: string[]): Promise<FormattedFlow> => {\n    return makeRequest(\"POST\", \"/api/format-flow\", { commands });\n  },\n  lastViewHierarchy: async (): Promise<ViewHierarchyType> => {\n    return makeRequest(\"GET\", \"/api/last-view-hierarchy\");\n  },\n  saveOpenAiToken: async (token: string) => {\n    return makeRequest(\"POST\", \"/api/auth/openai-token\", { token: token });\n  },\n  deleteOpenAiToken: async () => {\n    return makeRequest(\"DELETE\", \"/api/auth/openai-token\");\n  },\n  generateCommandWithAI: async ({\n    screen,\n    userInput,\n    token,\n    openAiToken,\n    signal,\n  }: {\n    screen: any;\n    userInput: string;\n    token: string | null | undefined;\n    openAiToken: string | null | undefined;\n    signal?: AbortSignal;\n  }): Promise<AiResponseType> => {\n    const response = await fetch(\n      \"https://api.copilot.mobile.dev/v2/maestro-studio/generate-command\",\n      {\n        method: \"POST\",\n        headers: {\n          Accept: \"application/json\",\n          \"Content-Type\": \"application/json\",\n          Authorization: `Bearer ${token}`,\n        },\n        body: JSON.stringify({ screen, userInput, openAiToken }),\n        signal,\n      }\n    );\n    if (!response.ok) {\n      const body = await response.text();\n      throw new HttpError(response.status, body);\n    }\n    return await response.json();\n  },\n};\n"
  },
  {
    "path": "maestro-studio/web/src/components/commands/CommandCreator.tsx",
    "content": "import { ReactNode, useEffect, useRef, useState } from \"react\";\nimport _ from \"lodash\";\nimport { useAuth } from \"../../context/AuthContext\";\nimport { useDeviceContext } from \"../../context/DeviceContext\";\nimport AuthModal from \"../common/AuthModal\";\nimport { Button } from \"../design-system/button\";\nimport { Input, InputHint, InputWrapper, TextArea } from \"../design-system/input\";\nimport { AiSparkles, EnterKey } from \"../design-system/utils/images\";\nimport CommandInput from \"./CommandInput\";\nimport { API } from \"../../api/api\";\nimport { Spinner } from \"../design-system/spinner\";\nimport ChatGptApiKeyModal from \"../common/ChatGptApiKeyModal\";\n\ntype CommandCreatorProps = {\n  onSubmit: () => void;\n  error: string | null;\n  setError: (val: string | null) => void;\n};\n\n/************************************************\n * Main Component\n ************************************************/\nexport default function CommandCreator({\n  onSubmit,\n  error,\n  setError,\n}: CommandCreatorProps) {\n  const { authToken } = useAuth();\n  const { currentCommandValue, setCurrentCommandValue } = useDeviceContext();\n\n  const [showAuthModal, setShowAuthModal] = useState<boolean>(false);\n  const showAiInput = currentCommandValue[0] === \" \";\n\n  useEffect(() => {\n    const enableAI = currentCommandValue[0] === \" \";\n    if (enableAI && !authToken) {\n      setShowAuthModal(true);\n    }\n  }, [authToken, currentCommandValue]);\n\n  const handleSetValue = (value: string) => {\n    setError(null);\n    setCurrentCommandValue(value);\n  };\n\n  return (\n    <div>\n      <AuthModal\n        open={showAuthModal}\n        onOpenChange={(val: boolean) => {\n          setShowAuthModal(val);\n          setCurrentCommandValue(\"\");\n        }}\n      />\n      {currentCommandValue.length > 0 ? (\n        <>\n          {showAiInput ? (\n            <AiInput />\n          ) : (\n            <CommandForm\n              onSubmit={onSubmit}\n              error={error}\n              setValue={handleSetValue}\n            />\n          )}\n        </>\n      ) : (\n        <DefaultInput />\n      )}\n    </div>\n  );\n}\n\n/************************************************\n * Default Placed Input\n ************************************************/\nconst DefaultInput = () => {\n  const inputRef = useRef<HTMLTextAreaElement>(null);\n  const { currentCommandValue, setCurrentCommandValue } = useDeviceContext();\n\n  useEffect(() => {\n    inputRef.current?.focus();\n  }, []);\n\n  return (\n    <TextArea\n      ref={inputRef}\n      placeholder=\"Press ‘space’ for AI, or type commands…\"\n      value={currentCommandValue}\n      onChange={(e) => setCurrentCommandValue(e.target.value)}\n      rows={1}\n      resize=\"none\"\n    />\n  );\n};\n\n/************************************************\n * AI Input Form\n ************************************************/\nconst AiInput = () => {\n  const aiCommandFormRef = useRef<HTMLFormElement>(null)\n  const abortControllerRef = useRef<any>(null);\n  const { authToken, openAiToken, deleteOpenAiToken } = useAuth();\n  const { setCurrentCommandValue } = useDeviceContext();\n  const aiInputRef = useRef<HTMLInputElement>(null);\n  const [userInput, setUserInput] = useState<string>(\"\");\n  const [formStates, setFormStates] = useState<{\n    isLoading: boolean;\n    error: string | ReactNode | null;\n  }>({\n    isLoading: false,\n    error: null,\n  });\n  const [showApiKeyModal, setShowApiKeyModal] = useState<boolean>(false);\n\n  useEffect(() => {\n    aiInputRef.current?.focus();\n    aiCommandFormRef.current?.scrollIntoView({ block: \"end\", inline: \"nearest\" });\n  }, []);\n\n  const handleFormSubmit = async (e: React.FormEvent) => {\n    abortControllerRef.current = new AbortController();\n    e.preventDefault();\n    setFormStates({ isLoading: true, error: null });\n    try {\n      const viewHeir = await API.lastViewHierarchy();\n      const response = await API.generateCommandWithAI({\n        screen: viewHeir,\n        userInput,\n        token: authToken,\n        signal: abortControllerRef.current.signal,\n        openAiToken: openAiToken,\n      });\n      if (_.get(response, \"command\")) {\n        setFormStates({ isLoading: false, error: null });\n        setCurrentCommandValue(_.get(response, \"command\", \"\"));\n      } else {\n        setFormStates({\n          isLoading: false,\n          error: \"AI was not able to generate a command.\",\n        });\n      }\n    } catch (error) {\n      let errorMessage;\n      if (_.get(error, \"name\") === \"AbortError\") {\n        errorMessage = \"Request was aborted!\";\n      } else if (_.get(error, \"status\") === \"429\" && !openAiToken) {\n        errorMessage = (\n          <>\n            Exceeded the rate limit.{\" \"}\n            <span\n              className=\"underline cursor-pointer\"\n              onClick={() => setShowApiKeyModal(true)}\n            >\n              Add your own Key\n            </span>\n          </>\n        );\n      } else {\n        errorMessage =\n          _.get(error, \"message\") || \"An unexpected error occurred!\";\n      }\n      setFormStates({\n        isLoading: false,\n        error: errorMessage,\n      });\n    }\n  };\n\n  // Function to handle backspace when the input is empty or escape key\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if ((e.key === \"Backspace\" && userInput === \"\") || e.key === \"Escape\") {\n      setUserInput(\"\");\n      setCurrentCommandValue(\"\");\n    }\n  };\n\n  if (formStates.isLoading) {\n    return (\n      <div className=\"ai-loader flex px-3 h-10 items-center gap-2 relative rounded-xl\">\n        <div className=\"absolute top-0.5 left-0.5 right-0.5 bottom-0.5 rounded-[10px] bg-white dark:bg-gray-900 z-0\" />\n        <Spinner size=\"18\" className=\"relative z-10\" />\n        <p className=\"flex-grow text-sm font-semibold relative z-10\">\n          {userInput}\n        </p>\n        <Button\n          onClick={() => {\n            abortControllerRef.current.abort();\n            setFormStates({ error: null, isLoading: false });\n          }}\n          leftIcon=\"RiStopLine\"\n          variant=\"quaternary\"\n          className=\"relative z-10\"\n        >\n          Stop Request\n        </Button>\n      </div>\n    );\n  }\n\n  return (\n    <>\n      <ChatGptApiKeyModal\n        open={showApiKeyModal}\n        onOpenChange={(val) => setShowApiKeyModal(val)}\n      />\n      <form ref={aiCommandFormRef} className=\"relative pb-10\" onSubmit={handleFormSubmit}>\n        <InputWrapper error={formStates.error}>\n          <Input\n            ref={aiInputRef}\n            value={userInput}\n            onKeyDown={handleKeyDown}\n            onChange={(e) => {\n              setUserInput(e.target.value);\n            }}\n            leftElement={<AiSparkles className=\"w-[18px] text-orange-500\" />}\n            placeholder=\"Ask AI to generate command\"\n          />\n          <InputHint />\n        </InputWrapper>\n        <Button\n          disabled={userInput === \"\"}\n          type=\"submit\"\n          size=\"sm\"\n          className=\"absolute top-1.5 right-2 p-0 w-[28px] h-[28px]\"\n        >\n          <EnterKey className=\"w-4\" />\n        </Button>\n        {openAiToken && (\n          <div className=\"mt-2 bg-blue-100 px-4 py-2 rounded-lg flex flex-col md:flex-row gap-2 md:items-center\">\n            <p className=\"text-sm font-medium flex-grow\">\n              Your openai API key: {openAiToken}\n            </p>\n            <div className=\"flex gap-2\">\n              <Button\n              type=\"button\"\n                onClick={() => setShowApiKeyModal(true)}\n                variant=\"secondary\"\n                className=\"min-w-[85px]\"\n              >\n                Update API\n              </Button>\n              <Button\n              type=\"button\"\n                onClick={deleteOpenAiToken}\n                variant=\"secondary-red\"\n                leftIcon=\"RiDeleteBin2Line\"\n                className=\"min-w-[88px]\"\n              >\n                Remove API\n              </Button>\n            </div>\n          </div>\n        )}\n      </form>\n      \n    </>\n  );\n};\n\n/************************************************\n * Command Input Form\n ************************************************/\nconst CommandForm = ({\n  onSubmit,\n  error,\n  setValue,\n}: {\n  onSubmit: () => void;\n  error: string | null;\n  setValue: (val: string) => void;\n}) => {\n  const commandForm = useRef<HTMLFormElement>(null);\n  const commandInputRef = useRef<HTMLTextAreaElement>(null);\n  const { currentCommandValue } = useDeviceContext();\n\n  useEffect(() => {\n    const input = commandInputRef.current;\n    if (input) {\n      input.focus();\n      commandForm.current?.scrollIntoView({ block: \"end\", inline: \"nearest\" });\n      const len = input.value.length;\n      input.selectionStart = len;\n      input.selectionEnd = len;\n    }\n  }, []);\n\n  const handleFormSubmit = (e: React.FormEvent) => {\n    e.preventDefault();\n    onSubmit();\n  };\n\n  return (\n    <form ref={commandForm} className=\"gap-2 flex flex-col relative pb-10\" onSubmit={handleFormSubmit}>\n      <CommandInput\n        ref={commandInputRef}\n        setValue={setValue}\n        value={currentCommandValue}\n        error={error}\n        placeholder=\"Enter a command\"\n        onSubmit={onSubmit}\n      />\n      <Button\n        disabled={!currentCommandValue || !!error}\n        type=\"submit\"\n        leftIcon=\"RiCommandLine\"\n        size=\"sm\"\n        className=\"absolute top-2 right-2 text-lg font-medium\"\n      >\n        +\n        <EnterKey className=\"w-4\" />\n      </Button>\n    </form>\n  );\n};\n"
  },
  {
    "path": "maestro-studio/web/src/components/commands/CommandInput.tsx",
    "content": "import { ForwardRefRenderFunction, KeyboardEvent, forwardRef } from \"react\";\nimport { InputHint, InputWrapper, TextArea } from \"../design-system/input\";\nimport { TextAreaProps } from \"../../helpers/models\";\nimport { twMerge } from \"tailwind-merge\";\nimport clsx from \"clsx\";\n\ninterface CommandInputProps {\n  value: string;\n  setValue: (value: string) => void;\n  resize?: \"automatic\" | \"vertical\" | \"none\";\n  error?: boolean | string | null;\n  onSubmit?: () => void;\n}\n\ntype CombinedProps = CommandInputProps &\n  TextAreaProps &\n  React.RefAttributes<HTMLTextAreaElement>;\n\nconst CommandInput: ForwardRefRenderFunction<\n  HTMLTextAreaElement,\n  CombinedProps\n> = (\n  {\n    value,\n    setValue,\n    error,\n    resize = \"automatic\",\n    onSubmit,\n    className,\n    ...rest\n  },\n  ref\n) => {\n  const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {\n    if (e.metaKey && e.key === \"Enter\") {\n      onSubmit && onSubmit();\n      return;\n    } else if (e.key === \"Tab\") {\n      e.preventDefault();\n      const start = e.currentTarget.selectionStart;\n      setValue(\n        value.slice(0, start) +\n          \"    \" +\n          value.slice(e.currentTarget.selectionEnd)\n      );\n    }\n  };\n\n  return (\n    <InputWrapper error={error}>\n      <TextArea\n        ref={ref}\n        id=\"commandInputBox\"\n        value={value}\n        rows={2}\n        onChange={(e) => {\n          setValue(e.target.value);\n        }}\n        resize={resize}\n        showResizeIcon={false}\n        onKeyDown={handleKeyDown}\n        textAreaClassName={twMerge(\n          clsx(\"font-mono font-normal pb-12\", className)\n        )}\n        {...rest}\n      />\n      <InputHint />\n    </InputWrapper>\n  );\n};\n\nexport default forwardRef(CommandInput);\n"
  },
  {
    "path": "maestro-studio/web/src/components/commands/CommandList.tsx",
    "content": "import { ReplCommand } from \"../../helpers/models\";\nimport CommandRow from \"./CommandRow\";\nimport { DragDropContext, Droppable, Draggable } from \"react-beautiful-dnd\";\nimport { Button } from \"../design-system/button\";\nimport clsx from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\ninterface CommandListProps {\n  commands: ReplCommand[];\n  selectedIds: string[];\n  onReorder: (newOrder: ReplCommand[]) => void;\n  updateSelected: (id: any) => void;\n}\n\nexport default function CommandList({\n  commands,\n  selectedIds,\n  updateSelected,\n  onReorder,\n}: CommandListProps) {\n  const handleDragEnd = (result: any) => {\n    if (!result.destination) return;\n    let newCommandOrder = commands;\n    const [reorderedItem] = newCommandOrder.splice(result.source.index, 1);\n    newCommandOrder.splice(result.destination.index, 0, reorderedItem);\n    onReorder([...newCommandOrder]);\n  };\n\n  return (\n    <DragDropContext onDragEnd={handleDragEnd}>\n      <Droppable droppableId=\"commands\">\n        {(provided) => (\n          <div {...provided.droppableProps} ref={provided.innerRef}>\n            {commands.map((command: ReplCommand, index) => (\n              <Draggable\n                key={command.id}\n                draggableId={command.id}\n                index={index}\n              >\n                {(provided, snapshot) => (\n                  <div\n                    ref={provided.innerRef}\n                    {...provided.draggableProps}\n                    className={twMerge(\n                      clsx(\n                        \"group relative flex flex-row bg-white rounded-lg border border-transparent dark:bg-slate-900 dark:active:bg-slate-900 overflow-hidden\",\n                        snapshot.isDragging &&\n                          \"shadow-md border-slate-100 dark:border-slate-700\"\n                      )\n                    )}\n                  >\n                    <div\n                      {...provided.dragHandleProps}\n                      className=\"opacity-0 group-hover:opacity-100 group-active:opacity-100 transition-opacity\"\n                    >\n                      <Button\n                        variant=\"quaternary\"\n                        className=\"text-gray-900/40 dark:text-white/40 py-4 my-0.5 pointer-events-none\"\n                        tabIndex={-1}\n                        size=\"xs\"\n                        iconElement={\n                          <svg\n                            className=\"w-5 h-5 min-w-[20px]\"\n                            xmlns=\"http://www.w3.org/2000/svg\"\n                            viewBox=\"0 0 24 24\"\n                          >\n                            <path\n                              fill=\"currentColor\"\n                              d=\"M8.5 7C9.32843 7 10 6.32843 10 5.5C10 4.67157 9.32843 4 8.5 4C7.67157 4 7 4.67157 7 5.5C7 6.32843 7.67157 7 8.5 7ZM8.5 13.5C9.32843 13.5 10 12.8284 10 12C10 11.1716 9.32843 10.5 8.5 10.5C7.67157 10.5 7 11.1716 7 12C7 12.8284 7.67157 13.5 8.5 13.5ZM10 18.5C10 19.3284 9.32843 20 8.5 20C7.67157 20 7 19.3284 7 18.5C7 17.6716 7.67157 17 8.5 17C9.32843 17 10 17.6716 10 18.5ZM15.5 7C16.3284 7 17 6.32843 17 5.5C17 4.67157 16.3284 4 15.5 4C14.6716 4 14 4.67157 14 5.5C14 6.32843 14.6716 7 15.5 7ZM17 12C17 12.8284 16.3284 13.5 15.5 13.5C14.6716 13.5 14 12.8284 14 12C14 11.1716 14.6716 10.5 15.5 10.5C16.3284 10.5 17 11.1716 17 12ZM15.5 20C16.3284 20 17 19.3284 17 18.5C17 17.6716 16.3284 17 15.5 17C14.6716 17 14 17.6716 14 18.5C14 19.3284 14.6716 20 15.5 20Z\"\n                            ></path>\n                          </svg>\n                        }\n                      />\n                    </div>\n                    <CommandRow\n                      isDragging={snapshot.isDragging}\n                      command={command}\n                      selected={selectedIds.includes(command.id)}\n                      onClick={() => updateSelected(command.id)}\n                    />\n                  </div>\n                )}\n              </Draggable>\n            ))}\n            {provided.placeholder}\n          </div>\n        )}\n      </Droppable>\n    </DragDropContext>\n  );\n}\n"
  },
  {
    "path": "maestro-studio/web/src/components/commands/CommandRow.tsx",
    "content": "import { ReactElement } from \"react\";\nimport { ReplCommand, ReplCommandStatus } from \"../../helpers/models\";\nimport { Checkbox } from \"../design-system/checkbox\";\nimport { Icon } from \"../design-system/icon\";\nimport { Spinner } from \"../design-system/spinner\";\nimport clsx from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\ninterface CommandRowProps {\n  command: ReplCommand;\n  selected: boolean;\n  onClick: () => void;\n  isDragging: boolean;\n}\n\nexport default function CommandRow({\n  command,\n  selected,\n  onClick,\n  isDragging,\n}: CommandRowProps) {\n  return (\n    <div\n      className={twMerge(\n        clsx(\n          \"flex flex-row gap-3 w-[calc(100%-24px)] relative overflow-hidden py-1 pr-1\",\n          isDragging && \"border-transparent\"\n        )\n      )}\n    >\n      <div onClick={onClick} className=\"py-1 cursor-pointer\">\n        <Checkbox\n          size=\"sm\"\n          checked={selected}\n          className=\"pointer-events-none\"\n        />\n      </div>\n      <div className=\"relative flex-grow\">\n        <pre className=\"font-mono bg-gray-100 dark:bg-slate-800/50 cursor-default overflow-auto text-sm text-gray-900 dark:text-white px-2 py-1 pr-10 hide-scrollbar min-h-full flex items-center rounded-xl\">\n          {command.yaml}\n        </pre>\n        <div className=\"bg-gradient-to-r from-transparent to-gray-100 dark:to-slate-800/50 w-10 absolute top-0 right-0 bottom-0 pointer-events-none rounded-xl\" />\n        <div className=\"absolute top-2 right-2\">\n          <StatusIcon status={command.status} />\n        </div>\n      </div>\n    </div>\n  );\n}\n\nconst StatusIcon = ({\n  status,\n}: {\n  status: ReplCommandStatus;\n}): ReactElement | null => {\n  switch (status) {\n    case \"success\":\n      return (\n        <Icon iconName=\"RiCheckLine\" size=\"16\" className=\"text-green-500\" />\n      );\n    case \"canceled\":\n      return null;\n    case \"error\":\n      return <Icon iconName=\"RiAlertLine\" size=\"16\" className=\"text-red-500\" />;\n    case \"pending\":\n      return null;\n    case \"running\":\n      return <Spinner size=\"16\" className=\"text-gray-900 dark:text-white\" />;\n  }\n};\n"
  },
  {
    "path": "maestro-studio/web/src/components/commands/ReplHeader.tsx",
    "content": "import { useState } from \"react\";\nimport clsx from \"clsx\";\nimport copy from \"copy-to-clipboard\";\nimport { Checkbox } from \"../design-system/checkbox\";\nimport { Button } from \"../design-system/button\";\nimport { ConfirmationDialog } from \"../common/ConfirmationDialog\";\n\ninterface ReplHeaderProps {\n  onSelectAll: () => void;\n  onDeselectAll: () => void;\n  selectedLength: number;\n  copyText: string;\n  allSelected: boolean;\n  onPlay: () => void;\n  onExport: () => void;\n  onDelete: () => void;\n}\n\nexport default function ReplHeader({\n  onSelectAll,\n  onDeselectAll,\n  selectedLength,\n  copyText,\n  allSelected,\n  onPlay,\n  onExport,\n  onDelete,\n}: ReplHeaderProps) {\n  const [commandCopied, setCommandCopied] = useState<boolean>(false);\n\n  /**\n   * Show success state for copy\n   */\n  const onCommandCopy = () => {\n    setCommandCopied(true);\n    setTimeout(() => {\n      setCommandCopied(false);\n    }, 1000);\n  };\n\n  return (\n    <div className=\"flex justify-between w-full border-b border-slate-100 dark:border-slate-800 flex-wrap pt-4\">\n      <div\n        className={clsx(\n          \"py-2\",\n          selectedLength > 0\n            ? \"text-gray-900 dark:text-white\"\n            : \"text-gray-400 dark:text-gray-600\"\n        )}\n      >\n        <Checkbox\n          size=\"sm\"\n          checked={allSelected}\n          onChange={() => {\n            if (selectedLength === 0) {\n              onSelectAll();\n            } else {\n              onDeselectAll();\n            }\n          }}\n          indeterminate={selectedLength > 0 && !allSelected}\n          label={\n            selectedLength > 0 ? `${selectedLength} Selected` : \"Select All\"\n          }\n        />\n      </div>\n      {selectedLength > 0 && (\n        <div className=\"flex gap-0 flex-wrap justify-end\">\n          <Button\n            variant=\"quaternary\"\n            size=\"sm\"\n            leftIcon=\"RiPlayLine\"\n            onClick={onPlay}\n          >\n            Play\n          </Button>\n          <Button\n            variant=\"quaternary\"\n            size=\"sm\"\n            leftIcon=\"RiUpload2Line\"\n            onClick={onExport}\n          >\n            Export\n          </Button>\n          {commandCopied ? (\n            <Button variant=\"primary-green\" size=\"sm\" leftIcon=\"RiCheckLine\">\n              Copy\n            </Button>\n          ) : (\n            <Button\n              onClick={() => {\n                copy(copyText);\n                onCommandCopy();\n              }}\n              variant=\"quaternary\"\n              size=\"sm\"\n              leftIcon=\"RiFileCopyLine\"\n            >\n              Copy\n            </Button>\n          )}\n\n          <ConfirmationDialog\n            title={`Delete (${selectedLength}) command${\n              selectedLength === 1 ? \"\" : \"s\"\n            }?`}\n            content={`Click confirm to delete the selected command${\n              selectedLength === 1 ? \"\" : \"s\"\n            }.`}\n            mainAction={onDelete}\n          >\n            <Button\n              variant=\"quaternary-red\"\n              size=\"sm\"\n              leftIcon=\"RiDeleteBinLine\"\n            >\n              Delete\n            </Button>\n          </ConfirmationDialog>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "maestro-studio/web/src/components/commands/ReplView.tsx",
    "content": "import { memo, useEffect, useRef, useState } from \"react\";\nimport { API } from \"../../api/api\";\nimport { Icon } from \"../design-system/icon\";\nimport { FormattedFlow, ReplCommand } from \"../../helpers/models\";\nimport { SaveFlowModal } from \"./SaveFlowModal\";\nimport ReplHeader from \"./ReplHeader\";\nimport CommandList from \"./CommandList\";\nimport CommandCreator from \"./CommandCreator\";\nimport { useDeviceContext } from \"../../context/DeviceContext\";\nimport { useRepl } from '../../context/ReplContext';\n\nconst getFlowText = (selected: ReplCommand[]): string => {\n  let combinedYaml = '';\n  \n  // Process each command independently\n  selected.forEach(command => {\n    // Get the first line of the command YAML\n    const lines = command.yaml.split('\\n');\n    if (lines.length === 0) return;\n    \n    // Process the first line (command line)\n    let firstLine = lines[0].trim();\n    if (!firstLine.startsWith('-')) {\n      firstLine = `- ${firstLine}`;\n    }\n    combinedYaml += firstLine + '\\n';\n    \n    // Process the parameter lines\n    for (let i = 1; i < lines.length; i++) {\n      if (lines[i].trim()) {\n        // Parameters should have appropriate indentation.\n        // Given we're adding 2 characters for '- ', we add 2 spaces to subsequent lines.\n        combinedYaml += '  ' + lines[i] + '\\n';\n      }\n    }\n  });\n  \n  return combinedYaml;\n};\n\nconst ReplView = () => {\n  const { currentCommandValue, setCurrentCommandValue } = useDeviceContext();\n  const listRef = useRef<HTMLElement>();\n  const [_selected, setSelected] = useState<string[]>([]);\n  const [formattedFlow, setFormattedFlow] =\n    useState<FormattedFlow | null>(null);\n  const { repl, errorMessage, setErrorMessage, reorderCommands, deleteCommands, runCommandYaml, runCommandIds } = useRepl();\n  const listSize = repl?.commands.length || 0;\n  const previousListSize = useRef(0);\n\n  // Scroll to bottom when new commands are added\n  useEffect(() => {\n    const listSizeChange = listSize - previousListSize.current;\n    if (listSizeChange > 0 && listRef.current) {\n      listRef.current.scrollTop = listRef.current.scrollHeight;\n    }\n    previousListSize.current = listSize;\n  }, [listSize]);\n\n  if (!repl) {\n    return null;\n  }\n\n  const selectedCommands = _selected\n    .map((id) => repl.commands.find((c) => c.id === id))\n    .filter((c): c is ReplCommand => !!c);\n  const selectedIds = selectedCommands.map((c) => c.id);\n\n  // TODO handle invalid yaml\n  const onCommandSubmit = async () => {\n    if (!currentCommandValue) return;\n    const success = await runCommandYaml(currentCommandValue);\n    if (success) setCurrentCommandValue('');\n  };\n\n  const onReorder = (newOrder: ReplCommand[]) => {\n    reorderCommands(newOrder.map((c) => c.id));\n  };\n\n  const onPlay = async () => {\n    await runCommandIds(selectedIds);\n  };\n\n  const onExport = () => {\n    if (selectedIds.length === 0) return;\n    const commands = selectedIds.map(id => repl.commands.find(command => command.id === id)?.yaml).filter(Boolean) as string[]\n    API.formatFlow(commands).then(setFormattedFlow);\n  };\n\n  const onDelete = () => {\n    deleteCommands(selectedIds);\n  };\n\n  const flowText = getFlowText(selectedCommands);\n\n  return (\n    <>\n      {repl.commands.length > 0 ? (\n        <div className=\"flex flex-col h-full\">\n          <div className=\"px-12\">\n            <ReplHeader\n              onSelectAll={() => setSelected(repl.commands.map((c) => c.id))}\n              onDeselectAll={() => setSelected([])}\n              selectedLength={selectedIds.length}\n              allSelected={selectedIds.length === repl.commands.length}\n              copyText={flowText}\n              onPlay={onPlay}\n              onExport={onExport}\n              onDelete={onDelete}\n            />\n          </div>\n          <div className=\"px-12 overflow-auto pb-20 hide-scrollbar\">\n            <div className=\"-ml-6 py-5 -mr-1\">\n              <CommandList\n                onReorder={onReorder}\n                commands={repl.commands}\n                selectedIds={selectedIds}\n                updateSelected={(id: string) => {\n                  if (selectedIds.includes(id)) {\n                    setSelected((prevState: string[]) =>\n                      prevState.filter((val: string) => val !== id)\n                    );\n                  } else {\n                    setSelected((prevState: string[]) => [...prevState, id]);\n                  }\n                }}\n              />\n            </div>\n            <CommandCreator\n              onSubmit={onCommandSubmit}\n              error={errorMessage}\n              setError={setErrorMessage}\n            />\n          </div>\n        </div>\n      ) : (\n        <div className=\"px-12 py-6\">\n          <div className=\"flex px-12 flex-col items-center py-4 bg-slate-50 dark:bg-slate-800/50 rounded-xl mb-4\">\n            <div className=\"p-4 bg-white dark:bg-slate-900 rounded-3xl mb-4 shadow-xl\">\n              <Icon iconName=\"RiCodeLine\" size=\"20\" />\n            </div>\n            <p className=\"text-center text-base font-semibold mb-1\">\n              No commands added yet.\n            </p>\n            <p className=\"text-center text-sm text-gray-600 dark:text-gray-300\">\n              Write command below OR select an element, then a command to add it\n            </p>\n          </div>\n          <CommandCreator\n            onSubmit={onCommandSubmit}\n            error={errorMessage}\n            setError={setErrorMessage}\n          />\n        </div>\n      )}\n      {/* <div className=\"px-12\">\n        \n      </div> */}\n      {formattedFlow && (\n        <SaveFlowModal\n          formattedFlow={formattedFlow}\n          onClose={() => {\n            setFormattedFlow(null);\n          }}\n        />\n      )}\n    </>\n  );\n};\n\nexport default memo(ReplView);\n"
  },
  {
    "path": "maestro-studio/web/src/components/commands/SaveFlowModal.tsx",
    "content": "import { FormattedFlow } from \"../../helpers/models\";\nimport { useState } from \"react\";\nimport CommandInput from \"./CommandInput\";\nimport { saveAs } from \"file-saver\";\nimport { Modal } from \"../common/Modal\";\nimport { Button } from \"../design-system/button\";\n\nexport const SaveFlowModal = ({\n  formattedFlow,\n  onClose,\n}: {\n  formattedFlow: FormattedFlow;\n  onClose: () => void;\n}) => {\n  const [config, setConfig] = useState(formattedFlow.config);\n  const [commands, setCommands] = useState(formattedFlow.commands);\n  const onSave = () => {\n    const content = `${config}\\n---\\n${commands}`;\n    saveAs(\n      new Blob([content], { type: \"text/yaml;charset=utf-8\" }),\n      \"Flow.yaml\"\n    );\n    onClose();\n  };\n\n  return (\n    <Modal onClose={onClose}>\n      <div className=\"flex flex-col gap-3 p-8 h-full bg-white dark:bg-slate-900 dark:text-white rounded-lg\">\n        <span className=\"text-lg font-bold\">Save Flow to File</span>\n        <div className=\"flex flex-col h-full rounded gap-2\">\n          <CommandInput value={config} setValue={setConfig} />\n          <div className=\"flex-grow\">\n            <CommandInput\n              value={commands}\n              resize=\"vertical\"\n              className=\"h-full\"\n              setValue={setCommands}\n            />\n          </div>\n        </div>\n        <div className=\"flex justify-end gap-2\">\n          <Button onClick={onClose} type=\"button\" size=\"md\" variant=\"tertiary\">\n            Cancel\n          </Button>\n          <Button onClick={onSave} type=\"button\" size=\"md\" variant=\"primary\">\n            Save\n          </Button>\n        </div>\n      </div>\n    </Modal>\n  );\n};\n"
  },
  {
    "path": "maestro-studio/web/src/components/common/AuthModal.tsx",
    "content": "import { ReactNode, useState } from \"react\";\nimport {\n  Dialog,\n  DialogTrigger,\n  DialogContent,\n  DialogHeader,\n  DialogDescription,\n  DialogTitle,\n} from \"../design-system/dialog\";\nimport { Button } from \"../design-system/button\";\nimport copy from \"copy-to-clipboard\";\n\nexport default function AuthModal({\n  open,\n  onOpenChange,\n  children,\n}: {\n  open?: boolean;\n  onOpenChange?: (val: boolean) => void;\n  children?: ReactNode;\n}) {\n  const [isBeingCopied, setIsBeingCopied] = useState<boolean>(false);\n\n  const copyCommand = (command: string) => {\n    copy(command);\n    setIsBeingCopied(true);\n    setTimeout(() => {\n      setIsBeingCopied(false);\n    }, 1000);\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent>\n        <div className=\"flex gap-20 p-8 items-stretch\">\n          <div className=\"flex-grow min-w-0\">\n            <DialogHeader className=\"pb-4\">\n              <DialogTitle className=\"text-left text-3xl\">\n                Authentication Required\n              </DialogTitle>\n            </DialogHeader>\n            <DialogDescription>\n              <p className=\"text-base mb-6\">\n                To access this feature, you must be authenticated with Maestro.\n              </p>\n              <div className=\"bg-gray-200 dark:bg-gray-800 px-3 gap-2 py-2 rounded-lg flex\">\n                <p className=\"font-mono font-bold flex-grow py-1.5\">\n                  maestro login\n                </p>\n                {isBeingCopied ? (\n                  <Button\n                    variant=\"primary-green\"\n                    size=\"sm\"\n                    icon=\"RiCheckLine\"\n                  />\n                ) : (\n                  <Button\n                    onClick={() => {\n                      copyCommand(\"maestro login\");\n                    }}\n                    variant=\"quaternary\"\n                    size=\"sm\"\n                    icon=\"RiFileCopyLine\"\n                  />\n                )}\n              </div>\n            </DialogDescription>\n          </div>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "maestro-studio/web/src/components/common/Banner.tsx",
    "content": "const CloseIcon = () => {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      fill=\"none\"\n      viewBox=\"0 0 24 24\"\n      strokeWidth={1.5}\n      stroke=\"currentColor\"\n      className=\"w-6 h-6\"\n    >\n      <path\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        d=\"M6 18L18 6M6 6l12 12\"\n      />\n    </svg>\n  );\n};\n\nexport const ElementLabel = ({\n  text,\n  cursor,\n}: {\n  text: string | undefined;\n  cursor?: string | undefined;\n}) => {\n  return (\n    <span\n      className={`whitespace-nowrap overflow-hidden text-ellipsis ${\n        text ? \"\" : \"text-slate-900/20\"\n      }`}\n      style={{\n        cursor: cursor,\n      }}\n    >\n      {text || \"—\"}\n    </span>\n  );\n};\n\nconst Banner = ({\n  left,\n  right,\n  onClose,\n}: {\n  left: string | undefined;\n  right: string | undefined;\n  onClose: () => void;\n}) => {\n  return (\n    <div className=\"flex gap-3 items-center font-bold p-2 pr-5 rounded bg-blue-100 dark:bg-slate-900 dark:text-white dark:border-slate-800 border border-blue-500 overflow-hidden\">\n      <div\n        className=\"flex justify-center p-2 rounded items-center hover:bg-blue-900/20 active:bg-blue-900/40\"\n        onClick={onClose}\n      >\n        <CloseIcon />\n      </div>\n      <ElementLabel text={left} />\n      <div className=\"flex-1\" />\n      <ElementLabel text={right} />\n    </div>\n  );\n};\n\nexport default Banner;\n"
  },
  {
    "path": "maestro-studio/web/src/components/common/ChatGptApiKeyModal.tsx",
    "content": "import { ReactNode, useState } from \"react\";\nimport {\n  Dialog,\n  DialogTrigger,\n  DialogContent,\n  DialogHeader,\n  DialogDescription,\n  DialogTitle,\n} from \"../design-system/dialog\";\nimport { Button } from \"../design-system/button\";\nimport { Input, InputHint, InputWrapper } from \"../design-system/input\";\nimport { API } from \"../../api/api\";\nimport _ from \"lodash\";\nimport { Spinner } from \"../design-system/spinner\";\nimport { useAuth } from \"../../context/AuthContext\";\n\nexport default function ChatGptApiKeyModal({\n  open,\n  onOpenChange,\n  children,\n}: {\n  open?: boolean;\n  onOpenChange?: (val: boolean) => void;\n  children?: ReactNode;\n}) {\n  const { openAiToken, refetchAuth } = useAuth();\n  const [token, setToken] = useState<string>(openAiToken || \"\");\n  const [formStates, setFormStates] = useState<{\n    isLoading: boolean;\n    error: string | ReactNode | null;\n  }>({\n    isLoading: false,\n    error: null,\n  });\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault();\n    setFormStates({ isLoading: true, error: null });\n    try {\n      await API.saveOpenAiToken(token);\n      refetchAuth();\n      setFormStates({ isLoading: false, error: null });\n      onOpenChange && onOpenChange(false);\n    } catch (error) {\n      setFormStates({\n        isLoading: false,\n        error: _.get(error, \"message\") || \"An unexpected error occurred!\",\n      });\n    }\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent>\n        <div className=\"flex gap-20 p-8 items-stretch\">\n          <div className=\"flex-grow min-w-0\">\n            <DialogHeader className=\"pb-4\">\n              <DialogTitle className=\"text-left text-3xl\">\n                Personalize Your Experience with ChatGPT API Key\n              </DialogTitle>\n            </DialogHeader>\n            <DialogDescription>\n              <p className=\"text-base mb-6\">\n                By providing your own ChatGPT API key, you'll benefit from fewer\n                interruptions and improved response times.{\" \"}\n                <span className=\"text-green-600 font-bold\">\n                  Rest assured, your key remains securely on your device and is\n                  never shared or sent to our servers.\n                </span>\n              </p>\n              <form onSubmit={handleSubmit}>\n                <InputWrapper error={formStates.error}>\n                  <Input\n                    value={token}\n                    onChange={(e) => setToken(e.target.value)}\n                    placeholder=\"Paste your ChatGPT API key here\"\n                  />\n                  <InputHint />\n                </InputWrapper>\n                <Button\n                  disabled={token === \"\"}\n                  type=\"submit\"\n                  size=\"md\"\n                  className=\"mt-4 w-full\"\n                >\n                  {formStates.isLoading && <Spinner size=\"18\" />}\n                  {openAiToken ? \"Update\" : \"Activate\"} Key\n                </Button>\n              </form>\n            </DialogDescription>\n          </div>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n"
  },
  {
    "path": "maestro-studio/web/src/components/common/ConfirmationDialog.tsx",
    "content": "import { ReactNode, useState } from \"react\";\nimport { Button } from \"../design-system/button\";\nimport {\n  Dialog,\n  DialogTrigger,\n  DialogContent,\n  DialogHeader,\n  DialogDescription,\n  DialogTitle,\n} from \"../design-system/dialog\";\n\nexport const ConfirmationDialog = ({\n  title,\n  content,\n  mainAction,\n  children,\n}: {\n  title: string;\n  content: string;\n  mainAction?: () => void;\n  children?: ReactNode;\n}) => {\n  const [isOpen, setIsOpen] = useState<boolean>(false);\n  return (\n    <Dialog open={isOpen} onOpenChange={(val) => setIsOpen(val)}>\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent>\n        <div className=\"flex gap-20 p-8 items-stretch\">\n          <div className=\"flex-grow min-w-0\">\n            <DialogHeader className=\"pb-4\">\n              <DialogTitle className=\"text-xl font-medium leading-normal text-gray-800 dark:text-white\">\n                {title}\n              </DialogTitle>\n            </DialogHeader>\n            <DialogDescription>\n              <p className=\"text-base mb-6\">{content}</p>\n              <div className=\"modal-footer flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md gap-2\">\n                <Button\n                  onClick={() => setIsOpen(false)}\n                  type=\"button\"\n                  size=\"md\"\n                  variant=\"tertiary\"\n                >\n                  Cancel\n                </Button>\n                <Button\n                  onClick={() => {\n                    setIsOpen(false);\n                    mainAction && mainAction();\n                  }}\n                  type=\"button\"\n                  size=\"md\"\n                  variant=\"primary-red\"\n                >\n                  Confirm\n                </Button>\n              </div>\n            </DialogDescription>\n          </div>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n};\n"
  },
  {
    "path": "maestro-studio/web/src/components/common/Header.tsx",
    "content": "import { ThemeToggle } from \"../common/theme\";\nimport { API } from \"../../api/api\";\nimport clsx from \"clsx\";\n\nconst HeaderBanner = () => {\n  const { data } = API.useBannerMessage({ refreshInterval: 1000 });\n  if (!data?.level || data.level === \"none\") return null;\n\n  const bgColor = {\n    info: \"bg-blue-100 dark:bg-blue-900\",\n    warning: \"bg-amber-100 dark:bg-amber-900\",\n    error: \"bg-red-100 dark:bg-red-900\",\n  }[data.level];\n\n  return (\n    <div\n      className={clsx(\"py-3 px-7 text-sm text-center font-semibold\", bgColor)}\n    >\n      <span>{data.message}</span>\n    </div>\n  );\n};\n\nconst DeprecationBanner = () => (\n  <div className=\"py-3 px-7 text-sm text-center font-semibold bg-amber-100 dark:bg-amber-900\">\n    <span>\n      This feature has been deprecated and will be removed in the near future. Check out{\" \"}\n      <a\n        href=\"https://maestro.dev/?utm_source=old_studio&utm_campaign=download_studio#maestro-studio\"\n        target=\"_blank\"\n        rel=\"noreferrer\"\n        className=\"underline\"\n      >\n        Maestro Studio Desktop\n      </a>{\" \"}\n      for similar, fully supported functionality.\n    </span>\n  </div>\n);\n\nconst Header = () => (\n  <div className=\"flex flex-col\">\n    <div className=\"flex py-3 px-7 items-center bg-white dark:bg-slate-900 dark:text-white border-b border-slate-200 dark:border-slate-800\">\n      <span className=\"font-bold cursor-default grow\">$ maestro studio</span>\n      <ThemeToggle />\n    </div>\n    <DeprecationBanner />\n    <HeaderBanner />\n  </div>\n);\n\nexport default Header;\n"
  },
  {
    "path": "maestro-studio/web/src/components/common/Modal.tsx",
    "content": "import React, { ReactNode } from \"react\";\nimport { motion } from \"framer-motion\";\n\nexport const Modal = ({\n  onClose,\n  children,\n}: {\n  onClose: () => void;\n  children: ReactNode;\n}) => {\n  return (\n    <motion.div\n      className=\"fixed w-full h-full p-12 top-0 left-0 flex items-center justify-center bg-slate-900/60 dark:bg-white/20 z-50\"\n      onClick={onClose}\n    >\n      <motion.div\n        className=\"flex flex-col h-full min-w-[70%] min-h-[70%] max-w-[1000px] rounded-lg\"\n        initial={{ scale: 0.97, opacity: 0 }}\n        animate={{ scale: 1, opacity: 1 }}\n        transition={{ ease: \"easeOut\", duration: 0.1 }}\n        onClick={(e) => e.stopPropagation()}\n      >\n        {children}\n      </motion.div>\n    </motion.div>\n  );\n};\n"
  },
  {
    "path": "maestro-studio/web/src/components/common/PageSwitcher.tsx",
    "content": "import { AnimatePresence, motion } from \"framer-motion\";\nimport { ReactElement } from \"react\";\n\nconst PageSwitcher = ({\n  children,\n  banner,\n}: {\n  banner: ReactElement | null;\n  children: [ReactElement, ReactElement | null];\n}) => {\n  const animationDuration = 0.08;\n  return (\n    <motion.div className=\"flex flex-col gap-4 w-full h-full basis-0 flex-grow overflow-hidden\">\n      {banner ? (\n        <motion.div\n          initial={{ opacity: 0, scale: 0.9 }}\n          animate={{ opacity: 1, scale: 1 }}\n          transition={{ duration: animationDuration }}\n        >\n          {banner}\n        </motion.div>\n      ) : null}\n      <motion.div\n        className=\"w-full h-full relative rounded-lg border dark:border-slate-600 dark:text-white bg-white overflow-clip\"\n        layout=\"position\"\n        transition={{ ease: \"easeOut\", duration: animationDuration }}\n      >\n        <motion.div className=\"absolute p-8 w-full h-full bg-white dark:bg-slate-700\">\n          {children[0]}\n        </motion.div>\n        <AnimatePresence>\n          {children[1] ? (\n            <motion.div\n              className=\"absolute p-8 w-full h-full bg-white dark:bg-slate-700 dark:text-white\"\n              initial={{ opacity: 0, translateY: \"10px\" }}\n              animate={{ opacity: 1, translateY: 0 }}\n              exit={{ opacity: 0, translateY: \"10px\" }}\n              transition={{ ease: \"easeOut\", duration: animationDuration }}\n            >\n              {children[1]}\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n      </motion.div>\n    </motion.div>\n  );\n};\n\nexport default PageSwitcher;\n"
  },
  {
    "path": "maestro-studio/web/src/components/common/theme.tsx",
    "content": "import { useState } from \"react\";\nimport { Button } from \"../design-system/button\";\n\ntype Theme = \"light\" | \"dark\";\n\nconst getDefaultTheme = (): Theme => {\n  if (\n    localStorage.theme === \"dark\" ||\n    (!(\"theme\" in localStorage) &&\n      window.matchMedia(\"(prefers-color-scheme: dark)\").matches)\n  ) {\n    localStorage.theme = \"dark\";\n    document.documentElement.classList.add(\"dark\");\n    return \"dark\";\n  } else {\n    localStorage.theme = \"light\";\n    document.documentElement.classList.remove(\"dark\");\n    return \"light\";\n  }\n};\n\nexport const ThemeToggle = () => {\n  const [theme, toggleTheme] = useTheme();\n\n  return (\n    <Button\n      variant=\"quaternary\"\n      onClick={toggleTheme}\n      icon={theme === \"light\" ? \"RiSunLine\" : \"RiMoonLine\"}\n      size=\"md\"\n      className=\"h-8 w-8\"\n    />\n  );\n};\n\nexport const useTheme = (): [Theme, () => void] => {\n  const [theme, setTheme] = useState<Theme>(getDefaultTheme());\n\n  const toggleTheme = () => {\n    const newTheme = localStorage.theme === \"light\" ? \"dark\" : \"light\";\n\n    if (newTheme === \"dark\") {\n      localStorage.theme = \"dark\";\n      document.documentElement.classList.add(\"dark\");\n    } else {\n      localStorage.theme = \"light\";\n      document.documentElement.classList.remove(\"dark\");\n    }\n\n    localStorage.theme = newTheme;\n    setTheme(newTheme);\n  };\n\n  return [theme, toggleTheme];\n};\n"
  },
  {
    "path": "maestro-studio/web/src/components/design-system/button.tsx",
    "content": "import * as React from \"react\";\nimport { cva } from \"class-variance-authority\";\nimport { clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\nimport { Icon, IconList } from \"./icon\";\nimport { Spinner } from \"./spinner\";\n\nconst buttonVariants = cva(\n  \"inline-flex items-center border justify-center font-semibold transition hover:shadow-e2 hover:z-[1] focus-visible:outline-none focus-visible:ring-4 focus-visible:shadow-e2 focus-visible:ring-ring focus-visible:ring-offset-0 focus-visible:z-[2] disabled:cursor-not-allowed ring-offset-background disabled:shadow-none disabled:bg-gray-300 dark:disabled:bg-gray-300/10 disabled:border-base-em disabled:text-gray-400 dark:disabled:text-gray-600\",\n  {\n    variants: {\n      variant: {\n        primary:\n          \"bg-purple-500 text-white border-transparent shadow-sm hover:bg-purple-400 focus-visible:bg-purple-400 focus-visible:ring-purple-100\",\n        \"primary-blue\":\n          \"bg-blue-500 text-white border-transparent shadow-sm hover:bg-blue-400 focus-visible:bg-blue-400 focus-visible:ring-blue-100\",\n        \"primary-red\":\n          \"bg-red-500 text-white border-transparent shadow-sm hover:bg-red-400 focus-visible:bg-red-400 focus-visible:ring-red-100\",\n        \"primary-green\":\n          \"bg-green-500 dark:bg-green-500/40 text-white border-transparent shadow-sm hover:bg-green-400 focus-visible:bg-green-400 focus-visible:ring-green-100\",\n        \"primary-yellow\":\n          \"bg-yellow-500 text-gray-400 border-transparent shadow-sm hover:bg-yellow-400 focus-visible:bg-yellow-400 focus-visible:ring-yellow-100\",\n        secondary:\n          \"bg-purple-25 dark:bg-purple-500/10 text-purple-500 dark:text-purple-200 border-purple-75 dark:border-purple-500/20 shadow-sm hover:bg-purple-50 dark:hover:bg-purple-500/20 hover:text-purple-500 dark:hover-text-200 focus-visible:bg-purple-50 dark:focus-visible:bg-purple-400/10 focus-visible:ring-purple-100 dark:focus-visible:ring-purple-100/10\",\n        \"secondary-blue\":\n          \"bg-blue-50 dark:bg-blue-50/10 text-blue-500 border-blue-200 dark:border-blue-200/20 shadow-sm hover:bg-blue-50 hover:text-blue-500 focus-visible:bg-blue-50 focus-visible:ring-blue-100\",\n        \"secondary-green\":\n          \"bg-green-50 dark:bg-green-50/10 text-green-500 border-green-200 dark:border-green-200/20 shadow-sm hover:bg-green-50 hover:text-green-500 focus-visible:bg-green-50 focus-visible:ring-green-100\",\n        \"secondary-red\":\n          \"bg-red-50 dark:bg-red-50/10 text-red-500 border-red-200 dark:border-red-200/20 shadow-sm hover:bg-red-50 hover:text-red-500 focus-visible:bg-red-50 focus-visible:ring-red-100\",\n        \"secondary-yellow\":\n          \"bg-yellow-50 dark:bg-yellow-50/10 text-yellow-500 border-yellow-200 dark:border-yellow-200/20 shadow-sm hover:bg-yellow-50 hover:text-yellow-500 focus-visible:bg-yellow-50 focus-visible:ring-yellow-100\",\n        tertiary:\n          \"bg-white dark:bg-slate-800 text-gray-700 dark:text-gray-200 border-slate-200 dark:border-slate-200/20 shadow-sm hover:bg-slate-50 dark:hover:bg-slate-50/10 focus-visible:bg-slate-50 dark:focus-visible:bg-slate-50/10 focus-visible:ring-slate-100 dark:focus-visible:ring-slate-100/20\",\n        \"tertiary-blue\":\n          \"bg-white dark:bg-slate-800 text-blue-500 dark:text-blue-200 border-blue-200 dark:border-blue-200/20 shadow-sm hover:bg-blue-50 dark:hover:bg-blue-50/10 focus-visible:bg-blue-50 dark:focus-visible:bg-blue-50/10 focus-visible:ring-blue-100 dark:focus-visible:ring-blue-100/20\",\n        \"tertiary-green\":\n          \"bg-white dark:bg-slate-800 text-green-500 dark:text-green-200 border-green-200 dark:border-green-200/20 shadow-sm hover:bg-green-50 dark:hover:bg-green-50/10 focus-visible:bg-green-50 dark:focus-visible:bg-green-50/10 focus-visible:ring-green-100 dark:focus-visible:ring-green-100/20\",\n        \"tertiary-red\":\n          \"bg-white dark:bg-slate-800 text-red-500 dark:text-red-200 border-red-200 dark:border-red-200/20 shadow-sm hover:bg-red-50 dark:hover:bg-red-50/10 focus-visible:bg-red-50 dark:focus-visible:bg-red-50/10 focus-visible:ring-red-100 dark:focus-visible:ring-red-100/20\",\n        \"tertiary-yellow\":\n          \"bg-white dark:bg-slate-800 text-yellow-500 dark:text-yellow-200 border-yellow-200 dark:border-yellow-200/20 shadow-sm hover:bg-yellow-50 focus-visible:bg-yellow-50 dark:focus-visible:bg-yellow-50/10 focus-visible:ring-yellow-100 dark:focus-visible:ring-yellow-100/20\",\n        quaternary:\n          \"bg-transparent text-gray-700 dark:text-white border-transparent hover:border-slate-200 dark:hover:border-slate-200/20 hover:bg-slate-50 dark:hover:bg-slate-50/10 focus-visible:border-slate-200 dark:focus-visible:border-slate-200/20 focus-visible:bg-slate-50 dark:focus-visible:bg-slate-50/10 focus-visible:ring-slate-100 dark:focus-visible:ring-slate-100/20\",\n        \"quaternary-blue\":\n          \"bg-transparent text-blue-500 border-transparent hover:border-blue-200 dark:hover:border-blue-200/20 hover:bg-blue-50 dark:hover:bg-blue-50/10 focus-visible:border-blue-200 dark:focus-visible:border-blue-200/20 focus-visible:bg-blue-50 dark:focus-visible:bg-blue-50/10 focus-visible:ring-blue-100 dark:focus-visible:ring-blue-100/20\",\n        \"quaternary-green\":\n          \"bg-transparent text-green-500 border-transparent hover:border-green-200 dark:hover:border-green-200/20 hover:bg-green-50 dark:hover:bg-green-50/10 focus-visible:border-green-200 dark:focus-visible:border-green-200/20 focus-visible:bg-green-50 dark:focus-visible:bg-green-50/10 focus-visible:ring-green-100 dark:focus-visible:ring-green-100/20\",\n        \"quaternary-red\":\n          \"bg-transparent text-red-500 border-transparent hover:border-red-200 dark:hover:border-red-200/20 hover:bg-red-50 dark:hover:bg-red-50/10 focus-visible:border-red-200 dark:focus-visible:border-red-200/20 focus-visible:bg-red-50 dark:focus-visible:bg-red-50/10 focus-visible:ring-red-100 dark:focus-visible:ring-red-100/20\",\n        \"quaternary-yellow\":\n          \"bg-transparent text-yellow-500 border-transparent hover:border-yellow-200 dark:hover:border-yellow-200/20 hover:bg-yellow-50 dark:hover:bg-yellow-50/10 focus-visible:border-yellow-200 dark:focus-visible:border-yellow-200/20 focus-visible:bg-yellow-50 dark:focus-visible:bg-yellow-50/10 focus-visible:ring-yellow-100 dark:focus-visible:ring-yellow-100/20\",\n      },\n      size: {\n        xs: \"rounded-sm gap-px text-[10px] px-2 h-6\",\n        sm: \"rounded-md gap-1.5 text-xs px-2.5 h-8\",\n        md: \"rounded-md gap-2 text-sm px-3 h-10\",\n        lg: \"rounded-lg gap-2.5 text-md px-4 h-12\",\n        xl: \"rounded-lg gap-3 text-lg px-4 h-14\",\n      },\n      iconExist: {\n        false: \"\",\n        xs: \"w-6 min-w-6 max-w-6 h-6 px-0\",\n        sm: \"w-8 min-w-8 max-w-8 h-8 px-0\",\n        md: \"w-10 min-w-10 max-w-10 h-10 px-0\",\n        lg: \"w-12 min-w-12 max-w-12 h-12 px-0\",\n        xl: \"w-14 min-w-14 max-w-14 h-14 px-0\",\n      },\n    },\n    defaultVariants: {\n      variant: \"primary\",\n      size: \"sm\",\n    },\n  }\n);\n\nconst getIconSize = (\n  size: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"\n): \"12\" | \"14\" | \"16\" | \"18\" | \"20\" | \"24\" | \"28\" | \"32\" | undefined => {\n  switch (size) {\n    case \"xs\":\n      return \"12\";\n    case \"sm\":\n      return \"14\";\n    case \"md\":\n      return \"18\";\n    case \"lg\":\n      return \"20\";\n    case \"xl\":\n      return \"24\";\n    default:\n      return undefined;\n  }\n};\n\ninterface CommonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n  variant?:\n    | \"primary\"\n    | \"primary-blue\"\n    | \"primary-red\"\n    | \"primary-green\"\n    | \"primary-yellow\"\n    | \"secondary\"\n    | \"secondary-blue\"\n    | \"secondary-red\"\n    | \"secondary-green\"\n    | \"secondary-yellow\"\n    | \"tertiary\"\n    | \"tertiary-blue\"\n    | \"tertiary-red\"\n    | \"tertiary-green\"\n    | \"tertiary-yellow\"\n    | \"quaternary\"\n    | \"quaternary-blue\"\n    | \"quaternary-red\"\n    | \"quaternary-green\"\n    | \"quaternary-yellow\";\n  size?: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\";\n  isLoading?: boolean;\n}\n\ntype ConditionalProps =\n  | {\n      icon?: keyof typeof IconList;\n      iconClassName?: string;\n      iconElement?: React.ReactNode;\n      leftIcon?: never;\n      leftIconClassName?: never;\n      rightIcon?: never;\n      rightIconClassName?: string;\n      children?: never;\n    }\n  | {\n      icon?: never;\n      iconClassName?: never;\n      iconElement?: never;\n      leftIcon?: keyof typeof IconList;\n      leftIconClassName?: string;\n      rightIcon?: keyof typeof IconList;\n      rightIconClassName?: string;\n      children?: React.ReactNode;\n    };\n\nexport type ButtonProps = CommonProps & ConditionalProps;\n\ninterface ButtonGroupProps extends React.ComponentPropsWithoutRef<\"div\"> {}\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n  (\n    {\n      className,\n      variant = \"primary\",\n      size = \"sm\",\n      icon,\n      iconElement,\n      iconClassName,\n      leftIcon,\n      leftIconClassName,\n      rightIcon,\n      rightIconClassName,\n      isLoading = false,\n      children,\n      ...props\n    },\n    ref\n  ) => {\n    return (\n      <button\n        className={twMerge(\n          clsx(\n            buttonVariants({\n              variant,\n              size,\n              iconExist: icon || iconElement ? size : false,\n              className,\n            })\n          )\n        )}\n        ref={ref}\n        {...props}\n      >\n        {iconElement ||\n          (icon ? (\n            isLoading ? (\n              <Spinner size={size && getIconSize(size)} />\n            ) : (\n              <Icon\n                iconName={icon}\n                size={size && getIconSize(size)}\n                className={iconClassName}\n              />\n            )\n          ) : (\n            <>\n              {isLoading && <Spinner size={size && getIconSize(size)} />}\n              {leftIcon && (\n                <Icon\n                  iconName={leftIcon}\n                  size={size && getIconSize(size)}\n                  className={leftIconClassName}\n                />\n              )}\n              {children}\n              {rightIcon && (\n                <Icon\n                  iconName={rightIcon}\n                  size={size && getIconSize(size)}\n                  className={rightIconClassName}\n                />\n              )}\n            </>\n          ))}\n      </button>\n    );\n  }\n);\nButton.displayName = \"Button\";\n\nconst ButtonGroup = React.forwardRef<HTMLDivElement, ButtonGroupProps>(\n  ({ className, ...props }, ref) => {\n    const gapClasses =\n      className &&\n      className\n        .split(\" \")\n        .some((className: string) => className.startsWith(\"gap-\"));\n    return (\n      <div\n        ref={ref}\n        className={twMerge(\n          clsx(\n            \"flex items-center\",\n            gapClasses\n              ? null\n              : \"[&>button:not(:first-child)]:ml-[-1px] [&>button:not(:first-child)]:rounded-l-[0] [&>button:not(:last-child)]:rounded-r-[0]\",\n            className\n          )\n        )}\n        {...props}\n      />\n    );\n  }\n);\nButtonGroup.displayName = \"ButtonGroup\";\n\nexport { Button, ButtonGroup, buttonVariants };\n"
  },
  {
    "path": "maestro-studio/web/src/components/design-system/checkbox.tsx",
    "content": "import * as React from \"react\";\nimport { clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\nimport { CheckboxCheck, CheckboxIntermediate } from \"./utils/images\";\n\nconst checkboxStates = {\n  indeterminate: [\n    \"[&>input:indeterminate~.checkmark>.icon-wrapper]:bg-purple-300 dark:[&>input:indeterminate~.checkmark>.icon-wrapper]:bg-purple-500/50\", // Checkmark bg color\n    \"[&>input:indeterminate~.checkmark>.icon-wrapper]:border-none\", // Checkmark no border\n    \"[&>input:indeterminate~.checkmark>.icon-wrapper>.indeterminate-icon]:block\", // Show indeterminate icon\n  ],\n  checked: [\n    \"[&>input:checked~.checkmark>.icon-wrapper]:bg-purple-500\", // Checkmark bg color\n    \"[&>input:checked~.checkmark>.icon-wrapper]:border-none\", // Checkmark no border\n    \"[&>input:checked~.checkmark>.icon-wrapper>.checkmark-tick]:block\", // Show check icon\n  ],\n  focus: [\n    \"[&>input:focus-visible~.checkmark>.icon-wrapper]:ring-4\",\n    \"[&>input:focus-visible~.checkmark>.icon-wrapper]:ring-ring\",\n    \"[&>input:focus-visible~.checkmark>.icon-wrapper]:ring-gray-100 dark:[&>input:focus-visible~.checkmark>.icon-wrapper]:ring-gray-100/10\", // Focus shadow when unchecked\n    \"[&>input:focus-visible:checked~.checkmark>.icon-wrapper]:ring-purple-100 dark:[&>input:focus-visible:checked~.checkmark>.icon-wrapper]:ring-purple-100/10\", // Focus shadow when checked\n    \"[&>input:focus:indeterminate~.checkmark>.icon-wrapper]:ring-purple-100 dark:[&>input:focus:indeterminate~.checkmark>.icon-wrapper]:ring-purple-100/10\", // Focus shadow when indeterminate\n  ],\n  disabled: [\n    '[&>input[type=\"checkbox\"]:disabled~.checkmark>.icon-wrapper]:border-gray-200', // Checkmark border when unchecked\n    '[&>input[type=\"checkbox\"]:disabled:checked~.checkmark>.icon-wrapper]:bg-gray-200', // Checkmark bg when checked\n    '[&>input[type=\"checkbox\"]:disabled:checked~.checkmark>.icon-wrapper]:text-med-em', // Checkmark bg when checked\n    '[&>input[type=\"checkbox\"]:disabled:indeterminate~.checkmark>.icon-wrapper]:bg-gray-400', // Checkmark bg when indeterminate\n  ],\n};\n\ninterface CheckboxProps\n  extends Omit<\n    React.InputHTMLAttributes<HTMLInputElement>,\n    \"size\" | \"onChange\"\n  > {\n  size?: \"sm\" | \"md\";\n  shape?: \"square\" | \"round\";\n  label?: string;\n  caption?: string;\n  indeterminate?: boolean;\n  disabled?: boolean;\n  onChange?: (checked: boolean) => void;\n}\n\nconst Checkbox = ({\n  size = \"md\",\n  shape = \"square\",\n  label,\n  caption,\n  className = \"\",\n  checked,\n  disabled,\n  indeterminate,\n  defaultChecked,\n  onChange,\n  ...rest\n}: CheckboxProps) => {\n  return (\n    <label\n      className={twMerge(\n        clsx(\n          \"flex gap-3\",\n          disabled ? \"cursor-not-allowed\" : \"cursor-pointer\",\n          ...checkboxStates[\"indeterminate\"],\n          ...checkboxStates[\"checked\"],\n          ...checkboxStates[\"focus\"],\n          ...checkboxStates[\"disabled\"],\n          className\n        )\n      )}\n    >\n      <input\n        ref={(input) => {\n          if (input) {\n            defaultChecked && (input.checked = true);\n            indeterminate && (input.indeterminate = true);\n            !indeterminate && (input.indeterminate = false);\n          }\n        }}\n        type=\"checkbox\"\n        className=\"h-0 w-0 absolute opacity-0 pointer-events-none\"\n        onChange={(e: React.ChangeEvent<HTMLInputElement>) =>\n          onChange && onChange(e.target.checked)\n        }\n        checked={checked}\n        disabled={disabled}\n        {...rest}\n      />\n      <div\n        className={clsx(\n          \"checkmark\",\n          size === \"sm\" && \"p-0.5\",\n          size === \"md\" && \"p-[3px]\",\n          !disabled && \"text-white\",\n          disabled && indeterminate && \"text-med-em\",\n          disabled && !indeterminate && \"text-disabled\"\n        )}\n      >\n        <div\n          className={clsx(\n            \"icon-wrapper\",\n            size === \"sm\" && \"h-4 w-4 min-w-4\",\n            size === \"md\" && \"h-[18px] w-[18px] min-w-[18px]\",\n            \"border-2\",\n            disabled ? \"border-gray-200\" : \"border-gray-300\",\n            shape === \"round\" ? \"rounded-full\" : \"rounded-md\"\n          )}\n        >\n          {indeterminate && (\n            <CheckboxIntermediate className=\"indeterminate-icon hidden w-full h-full\" />\n          )}\n          <CheckboxCheck className=\"checkmark-tick hidden w-full h-full\" />\n        </div>\n      </div>\n      {(label || caption) && (\n        <div className=\"flex flex-col gap-0.5 justify-center flex-grow\">\n          {label && (\n            <p\n              className={clsx(\n                \"font-semibold\",\n                size === \"md\" ? \"text-sm\" : \"text-xs\",\n                disabled && indeterminate && \"text-current\",\n                disabled && !indeterminate && \"text-gray-300\"\n              )}\n            >\n              {label}\n            </p>\n          )}\n          {caption && (\n            <p\n              className={clsx(\n                \"text-xs\",\n                disabled ? \"text-disabled\" : \"text-gray-300\"\n              )}\n            >\n              {caption}\n            </p>\n          )}\n        </div>\n      )}\n    </label>\n  );\n};\n\nexport { Checkbox };\n"
  },
  {
    "path": "maestro-studio/web/src/components/design-system/dialog.tsx",
    "content": "import * as React from \"react\";\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\";\nimport { Button } from \"./button\";\nimport { twMerge } from \"tailwind-merge\";\nimport clsx from \"clsx\";\n\nconst Dialog = DialogPrimitive.Root;\n\nconst DialogTrigger = DialogPrimitive.Trigger;\n\nconst DialogPortal = ({\n  children,\n  ...props\n}: DialogPrimitive.DialogPortalProps) => (\n  <DialogPrimitive.Portal {...props}>\n    <div className=\"fixed inset-0 z-50 flex items-start justify-center sm:items-center\">\n      {children}\n    </div>\n  </DialogPrimitive.Portal>\n);\nDialogPortal.displayName = DialogPrimitive.Portal.displayName;\n\nconst DialogOverlay = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Overlay>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Overlay\n    ref={ref}\n    className={twMerge(\n      clsx(\n        \"fixed inset-0 z-50 bg-black/40 dark:bg-white/20 backdrop-blur-sm transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in\",\n        className\n      )\n    )}\n    {...props}\n  />\n));\nDialogOverlay.displayName = DialogPrimitive.Overlay.displayName;\n\nconst DialogContent = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>\n>(({ className, children, ...props }, ref) => (\n  <DialogPortal>\n    <DialogOverlay />\n    <DialogPrimitive.Content\n      ref={ref}\n      className={twMerge(\n        clsx(\n          \"fixed max-h-[90vh] overflow-auto z-50 block w-full rounded-b-md border bg-white dark:bg-slate-900 border-gray-200 dark:border-gray-900 shadow-xl animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-2xl sm:rounded-xl sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0\",\n          className\n        )\n      )}\n      {...props}\n    >\n      {children}\n      <DialogPrimitive.Close className=\"absolute right-2 top-2 rounded-full p-0 ring-purple-100 dark:ring-purple-200/10 transition-opacity hover:opacity-100 focus:outline-none focus:ring-4 focus:ring-ring focus:ring-offset-0 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\">\n        <Button\n          icon=\"RiCloseLine\"\n          variant=\"tertiary\"\n          className=\"rounded-full\"\n          tabIndex={-1}\n        />\n      </DialogPrimitive.Close>\n    </DialogPrimitive.Content>\n  </DialogPortal>\n));\nDialogContent.displayName = DialogPrimitive.Content.displayName;\n\nconst DialogHeader = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={twMerge(\n      clsx(\"flex flex-col space-y-1.5 text-center sm:text-left\", className)\n    )}\n    {...props}\n  />\n);\nDialogHeader.displayName = \"DialogHeader\";\n\nconst DialogFooter = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLDivElement>) => (\n  <div\n    className={twMerge(\n      clsx(\n        \"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\",\n        className\n      )\n    )}\n    {...props}\n  />\n);\nDialogFooter.displayName = \"DialogFooter\";\n\nconst DialogTitle = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Title>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Title\n    ref={ref}\n    className={twMerge(clsx(\"text-xl font-semibold\", className))}\n    {...props}\n  />\n));\nDialogTitle.displayName = DialogPrimitive.Title.displayName;\n\nconst DialogDescription = React.forwardRef<\n  React.ElementRef<typeof DialogPrimitive.Description>,\n  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>\n>(({ className, ...props }, ref) => (\n  <DialogPrimitive.Description\n    ref={ref}\n    className={twMerge(clsx(\"text-sm text-muted-foreground\", className))}\n    {...props}\n  />\n));\nDialogDescription.displayName = DialogPrimitive.Description.displayName;\n\nexport {\n  Dialog,\n  DialogTrigger,\n  DialogContent,\n  DialogHeader,\n  DialogFooter,\n  DialogTitle,\n  DialogDescription,\n};\n"
  },
  {
    "path": "maestro-studio/web/src/components/design-system/dropdown-menu.tsx",
    "content": "import * as React from \"react\";\nimport * as DropdownMenuPrimitive from \"@radix-ui/react-dropdown-menu\";\nimport { twMerge } from \"tailwind-merge\";\nimport { Icon } from \"./icon\";\nimport clsx from \"clsx\";\n\nconst DropdownMenu = DropdownMenuPrimitive.Root;\n\nconst DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;\n\nconst DropdownMenuGroup = DropdownMenuPrimitive.Group;\n\nconst DropdownMenuPortal = DropdownMenuPrimitive.Portal;\n\nconst DropdownMenuSub = DropdownMenuPrimitive.Sub;\n\nconst DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;\n\nconst DropdownMenuSubTrigger = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {\n    inset?: boolean;\n  }\n>(({ className, inset, children, ...props }, ref) => (\n  <DropdownMenuPrimitive.SubTrigger\n    ref={ref}\n    className={twMerge(\n      clsx(\n        \"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-slate-100 data-[state=open]:bg-slate-100\",\n        inset && \"pl-8\",\n        className\n      )\n    )}\n    {...props}\n  >\n    {children}\n    <Icon iconName=\"RiArrowDownSLine\" className=\"ml-auto h-4 w-4\" />\n  </DropdownMenuPrimitive.SubTrigger>\n));\nDropdownMenuSubTrigger.displayName =\n  DropdownMenuPrimitive.SubTrigger.displayName;\n\nconst DropdownMenuSubContent = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>\n>(({ className, ...props }, ref) => (\n  <DropdownMenuPrimitive.SubContent\n    ref={ref}\n    className={twMerge(\n      clsx(\n        \"text-on-popover z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 shadow-md animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1\",\n        className\n      )\n    )}\n    {...props}\n  />\n));\nDropdownMenuSubContent.displayName =\n  DropdownMenuPrimitive.SubContent.displayName;\n\ntype DropdownMenuContentType = Omit<\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>,\n  \"defaultProps\"\n>;\ntype DropdownMenuPortalType = Omit<\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Portal>,\n  \"defaultProps\"\n>;\ninterface DropdownCombinedProps\n  extends DropdownMenuContentType,\n    DropdownMenuPortalType {\n  className?: string;\n  sideOffset?: number;\n  container?: any;\n}\nconst DropdownMenuContent: React.FC<DropdownCombinedProps> = ({\n  className,\n  sideOffset = 4,\n  container,\n  ...props\n}) => (\n  <DropdownMenuPrimitive.Portal container={container}>\n    <DropdownMenuPrimitive.Content\n      sideOffset={sideOffset}\n      className={twMerge(\n        clsx(\n          \"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n          className\n        )\n      )}\n      {...props}\n    />\n  </DropdownMenuPrimitive.Portal>\n);\nDropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;\n\nconst DropdownMenuItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {\n    inset?: boolean;\n  }\n>(({ className, inset, ...props }, ref) => (\n  <DropdownMenuPrimitive.Item\n    ref={ref}\n    className={twMerge(\n      clsx(\n        \"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-gray-800 data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n        inset && \"pl-8\",\n        className\n      )\n    )}\n    {...props}\n  />\n));\nDropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;\n\nconst DropdownMenuCheckboxItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>\n>(({ className, children, checked, ...props }, ref) => (\n  <DropdownMenuPrimitive.CheckboxItem\n    ref={ref}\n    className={twMerge(\n      clsx(\n        \"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-gray-800 data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n        className\n      )\n    )}\n    checked={checked}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <div className=\"h-4 w-4 rounded-full bg-slate-600\" />\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.CheckboxItem>\n));\nDropdownMenuCheckboxItem.displayName =\n  DropdownMenuPrimitive.CheckboxItem.displayName;\n\nconst DropdownMenuRadioItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>\n>(({ className, children, ...props }, ref) => (\n  <DropdownMenuPrimitive.RadioItem\n    ref={ref}\n    className={twMerge(\n      clsx(\n        \"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-slate-100 focus:text-gray-800 data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n        className\n      )\n    )}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <div className=\"h-4 w-4 rounded-full bg-slate-600\" />\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.RadioItem>\n));\nDropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;\n\nconst DropdownMenuLabel = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {\n    inset?: boolean;\n  }\n>(({ className, inset, ...props }, ref) => (\n  <DropdownMenuPrimitive.Label\n    ref={ref}\n    className={twMerge(\n      clsx(\"px-2 py-1.5 text-sm font-semibold\", inset && \"pl-8\", className)\n    )}\n    {...props}\n  />\n));\nDropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;\n\nconst DropdownMenuSeparator = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <DropdownMenuPrimitive.Separator\n    ref={ref}\n    className={twMerge(clsx(\"-mx-1 my-1 h-px bg-slate-200\", className))}\n    {...props}\n  />\n));\nDropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;\n\nconst DropdownMenuShortcut = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n  return (\n    <span\n      className={twMerge(\n        clsx(\"ml-auto text-xs tracking-widest opacity-60\", className)\n      )}\n      {...props}\n    />\n  );\n};\nDropdownMenuShortcut.displayName = \"DropdownMenuShortcut\";\n\nexport {\n  DropdownMenu,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuGroup,\n  DropdownMenuPortal,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuRadioGroup,\n};\n"
  },
  {
    "path": "maestro-studio/web/src/components/design-system/icon.tsx",
    "content": "import * as React from \"react\";\nimport { VariantProps, cva } from \"class-variance-authority\";\nimport clsx from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\nimport * as IconList from \"react-icons/ri\";\n\nconst iconVariants = cva(\"\", {\n  variants: {\n    size: {\n      \"12\": \"h-3 w-3 min-w-[12px]\",\n      \"14\": \"h-3.5 w-3.5 min-w-[14px]\",\n      \"16\": \"h-4 w-4 min-w-[16px]\",\n      \"18\": \"h-[18px] w-[18px] min-w-[18px]\",\n      \"20\": \"h-5 w-5 min-w-[20px]\",\n      \"24\": \"h-6 w-6 min-w-[24px]\",\n      \"28\": \"h-7 w-7 min-w-[28px]\",\n      \"32\": \"h-8 w-8 min-w-[32px]\",\n    },\n  },\n  defaultVariants: {\n    size: \"20\",\n  },\n});\n\nexport interface IconProps\n  extends React.SVGAttributes<SVGElement>,\n    VariantProps<typeof iconVariants> {\n  iconName: keyof typeof IconList;\n}\n\nfunction Icon({ className, iconName, size, ...props }: IconProps) {\n  const IconComponent = IconList[iconName];\n  return (\n    <IconComponent\n      className={twMerge(clsx(iconVariants({ size, className })))}\n      {...props}\n    />\n  );\n}\n\nexport { Icon, iconVariants, IconList };\n"
  },
  {
    "path": "maestro-studio/web/src/components/design-system/input.tsx",
    "content": "import React, {\n  HtmlHTMLAttributes,\n  InputHTMLAttributes,\n  LabelHTMLAttributes,\n  ReactNode,\n  TextareaHTMLAttributes,\n  forwardRef,\n} from \"react\";\nimport clsx from \"clsx\";\nimport { cva } from \"class-variance-authority\";\nimport { Icon, IconList } from \"./icon\";\nimport { twMerge } from \"tailwind-merge\";\nimport { TextAreaResizer } from \"./utils/images\";\n\nconst labelVariant = cva(\"font-semibold text-med-em\", {\n  variants: {\n    size: {\n      sm: \"text-xs pb-1 pl-1\",\n      md: \"text-xs pb-1 pl-1\",\n      lg: \"text-sm pb-1 pl-1\",\n      xl: \"text-base pb-1.5 pl-1\",\n    },\n    disabled: {\n      true: \"text-gray-400\",\n      false: \"\",\n    },\n  },\n});\n\nconst inputVariants = cva(\n  \"flex transition cursor-text w-full text-gray-900 dark:text-white items-center border border-slate-200 dark:border-slate-700 justify-center font-semibold transition ring-4 focus-within:shadow-e2 ring-ring ring-offset-0 ring-transparent focus-within:ring-purple-100 dark:focus-within:ring-purple-100/20 focus-within:border-purple-500 dark:focus-within:border-purple-500 ring-offset-background\",\n  {\n    variants: {\n      size: {\n        sm: \"rounded-md gap-1.5 text-xs px-2 h-8\",\n        md: \"rounded-md gap-2 text-sm px-3 h-10\",\n        lg: \"rounded-lg gap-2.5 text-base px-4 h-12\",\n        xl: \"rounded-lg gap-3 text-lg px-4 h-14\",\n      },\n      success: {\n        true: \"text-green-500 ring-green-100 border-green-400 focus-within:ring-green-100 focus-within:border-green-400\",\n        false: \"\",\n      },\n      error: {\n        true: \"text-red-500 ring-red-100 dark:ring-red-100/20 dark:text-red-500 border-red-400 hover:border-red-400 dark:border-red-400 dark:hover:border-red-400 focus:ring-red-100 dark:focus:ring-red-100/20 focus:border-red-400 focus:hover:border-red-400\",\n        false: \"\",\n      },\n      disabled: {\n        true: \"cursor-not-allowed bg-gray-75 text-disabled\",\n        false: \"\",\n      },\n    },\n  }\n);\n\nconst textareaVariants = cva(\n  \"block transition bg-white dark:bg-slate-800/50 cursor-text w-full text-gray-900 dark:text-white border border-slate-200 dark:border-slate-700 justify-center font-semibold transition ring-4 focus:outline-none focus:shadow-e2 ring-ring ring-offset-0 ring-transparent focus:ring-purple-100 dark:focus:ring-purple-100/20 focus:border-purple-500 dark:focus:border-purple-500 focus:hover:border-purple-500 ring-offset-background disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-disabled disabled:hover:border-slate-300\",\n  {\n    variants: {\n      size: {\n        sm: \"rounded-sm text-xs px-2.5 py-2\",\n        md: \"rounded-md text-sm px-3 py-2\",\n        lg: \"rounded-lg text-base px-4 py-3\",\n        xl: \"rounded-lg text-lg px-4 py-3\",\n      },\n      success: {\n        true: \"text-green-500 ring-success-100 border-green-400 hover:border-green-400 focus:ring-success-100 focus:border-green-400 focus:hover:border-green-400\",\n        false: \"\",\n      },\n      error: {\n        true: \"text-red-500 ring-red-100 dark:ring-red-100/20 dark:text-red-500 border-red-400 hover:border-red-400 dark:border-red-400 dark:hover:border-red-400 focus:ring-red-100 dark:focus:ring-red-100/20 focus:border-red-400 focus:hover:border-red-400\",\n        false: \"\",\n      },\n    },\n  }\n);\n\nconst inputHintVariant = cva(\n  \"flex w-full items-center gap-8 text-med-em font-semibold\",\n  {\n    variants: {\n      size: {\n        sm: \"gap-1 text-[10px] pl-1 pt-0.5\",\n        md: \"gap-1 text-xs pl-1 pt-0.5\",\n        lg: \"gap-1 text-sm pl-1 pt-0.5\",\n        xl: \"gap-1 text-base pl-1 pt-1.5\",\n      },\n      success: {\n        true: \"text-green-500\",\n        false: \"\",\n      },\n      error: {\n        true: \"text-red-500\",\n        false: \"\",\n      },\n      disabled: {\n        true: \"text-gray-400\",\n        false: \"\",\n      },\n    },\n  }\n);\n\nconst getIconSize = (\n  size: \"sm\" | \"md\" | \"lg\" | \"xl\"\n): \"12\" | \"14\" | \"16\" | \"18\" | \"20\" | \"24\" | \"28\" | \"32\" | undefined => {\n  switch (size) {\n    case \"sm\":\n      return \"14\";\n    case \"md\":\n      return \"18\";\n    case \"lg\":\n      return \"20\";\n    case \"xl\":\n      return \"20\";\n    default:\n      return undefined;\n  }\n};\n\nconst getHintIconSize = (\n  size: \"sm\" | \"md\" | \"lg\" | \"xl\"\n): \"12\" | \"14\" | \"16\" | \"18\" | \"20\" | \"24\" | \"28\" | \"32\" | undefined => {\n  switch (size) {\n    case \"sm\":\n      return \"12\";\n    case \"md\":\n      return \"14\";\n    case \"lg\":\n      return \"14\";\n    case \"xl\":\n      return \"18\";\n    default:\n      return undefined;\n  }\n};\n\ninterface InputWrapperProps extends LabelHTMLAttributes<HTMLLabelElement> {\n  size?: \"sm\" | \"md\" | \"lg\" | \"xl\";\n  disabled?: boolean;\n  success?: boolean | string | null;\n  error?: boolean | ReactNode | string | null;\n}\n\ninterface InpurLabelProps extends HtmlHTMLAttributes<HTMLElement> {\n  text?: string;\n  size?: \"sm\" | \"md\" | \"lg\" | \"xl\";\n  labelHint?: string;\n  disabled?: boolean;\n}\n\ninterface InputProps\n  extends Omit<InputHTMLAttributes<HTMLInputElement>, \"size\"> {\n  size?: \"sm\" | \"md\" | \"lg\" | \"xl\";\n  leftElement?: React.ReactNode;\n  leftIcon?: keyof typeof IconList;\n  leftIconClassName?: string;\n  rightElement?: React.ReactNode;\n  rightIcon?: keyof typeof IconList;\n  rightIconClassName?: string;\n  success?: boolean | string | null;\n  error?: boolean | ReactNode | string | null;\n  inputClassName?: string;\n}\n\ninterface TextareaProps\n  extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, \"size\"> {\n  size?: \"sm\" | \"md\" | \"lg\" | \"xl\";\n  success?: boolean | string | null;\n  error?: boolean | ReactNode | string | null;\n  resize?: \"automatic\" | \"vertical\" | \"none\";\n  showResizeIcon?: boolean;\n  textAreaClassName?: string;\n}\n\ninterface InputHintProps extends HtmlHTMLAttributes<HTMLElement> {\n  size?: \"sm\" | \"md\" | \"lg\" | \"xl\";\n  icon?: keyof typeof IconList;\n  disabled?: boolean;\n  hint?: string;\n  success?: boolean | string | null;\n  error?: boolean | ReactNode | string | null;\n}\n\nfunction InputWrapper({\n  size = \"md\",\n  disabled,\n  children,\n  success,\n  error,\n  className,\n  ...rest\n}: InputWrapperProps) {\n  const childrenWithProps = React.Children.map(children, (child) => {\n    if (React.isValidElement<InputProps>(child) && child.type === Input) {\n      return React.cloneElement(child, { size, disabled, success, error });\n    }\n    if (React.isValidElement<TextareaProps>(child) && child.type === TextArea) {\n      return React.cloneElement(child, { size, disabled, success, error });\n    }\n    if (\n      React.isValidElement<InpurLabelProps>(child) &&\n      child.type === InputLabel\n    ) {\n      return React.cloneElement(child, { size, disabled });\n    }\n    if (\n      React.isValidElement<InputHintProps>(child) &&\n      child.type === InputHint\n    ) {\n      return React.cloneElement(child, { size, disabled, success, error });\n    }\n    return child;\n  });\n\n  return (\n    <label\n      {...rest}\n      className={twMerge(clsx(disabled && \"cursor-not-allowed\", className))}\n    >\n      {childrenWithProps}\n    </label>\n  );\n}\n\nfunction InputLabel({\n  size,\n  text,\n  labelHint,\n  className,\n  disabled,\n  ...rest\n}: InpurLabelProps) {\n  if (!labelHint) {\n    return (\n      <p\n        className={twMerge(clsx(labelVariant({ size, disabled, className })))}\n        {...rest}\n      >\n        {text}\n      </p>\n    );\n  }\n\n  return (\n    <p\n      className={twMerge(\n        clsx(\n          labelVariant({\n            size,\n            disabled,\n            className: className,\n          })\n        )\n      )}\n      {...rest}\n    >\n      {text}\n    </p>\n  );\n}\n\nconst Input = forwardRef(\n  (\n    {\n      size = \"md\",\n      leftElement,\n      leftIcon,\n      leftIconClassName,\n      rightElement,\n      rightIcon,\n      rightIconClassName,\n      success,\n      error,\n      className,\n      disabled,\n      inputClassName,\n      ...rest\n    }: InputProps,\n    ref: React.ForwardedRef<HTMLInputElement>\n  ) => {\n    return (\n      <>\n        <div\n          className={twMerge(\n            clsx(\n              inputVariants({\n                size,\n                success: !!success,\n                error: !!error,\n                disabled,\n                className,\n              })\n            )\n          )}\n        >\n          {leftElement}\n          {leftIcon && (\n            <Icon\n              iconName={leftIcon as keyof typeof IconList}\n              size={size && getIconSize(size)}\n              className={twMerge(\n                clsx(\n                  disabled\n                    ? \"text-gray-400\"\n                    : \"text-gray-800 dark:text-white/80\",\n                  leftIconClassName\n                )\n              )}\n            />\n          )}\n          <input\n            ref={ref}\n            className={clsx(\n              twMerge(\n                \"flex-grow border-none bg-transparent placeholder:text-gray-400 autofill:shadow-[0_0_0_30px_white_inset_!important] focus:outline-none disabled:cursor-not-allowed\",\n                inputClassName\n              )\n            )}\n            disabled={disabled}\n            {...rest}\n          />\n          {rightIcon && (\n            <Icon\n              iconName={rightIcon as keyof typeof IconList}\n              size={size && getIconSize(size)}\n              className={twMerge(\n                clsx(\n                  disabled\n                    ? \"text-gray-400\"\n                    : \"text-gray-800 dark:text-white/80\",\n                  rightIconClassName\n                )\n              )}\n            />\n          )}\n          {rightElement}\n        </div>\n      </>\n    );\n  }\n);\n\nconst TextArea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(\n  (\n    {\n      size = \"md\",\n      success,\n      error,\n      className,\n      textAreaClassName,\n      resize,\n      onChange,\n      showResizeIcon = true,\n      ...rest\n    },\n    ref?: Exclude<React.Ref<HTMLTextAreaElement>, string>\n  ) => {\n    const textAreaRef = React.useRef<HTMLTextAreaElement>(null);\n\n    // Use the forwarded ref if provided, otherwise use local ref\n    React.useImperativeHandle(ref, () => {\n      if (textAreaRef.current) {\n        return textAreaRef.current;\n      } else {\n        throw new Error(\"TextArea ref is not yet available.\");\n      }\n    });\n\n    React.useEffect(() => {\n      if (\n        resize === \"automatic\" &&\n        typeof textAreaRef !== \"function\" &&\n        textAreaRef.current\n      ) {\n        textAreaRef.current.style.height = \"auto\";\n        textAreaRef.current.style.height = `${\n          textAreaRef.current.scrollHeight + 2\n        }px`;\n      }\n    }, [textAreaRef, resize, rest.value]);\n\n    const handleEvent = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n      const target = e.target as HTMLTextAreaElement;\n\n      if (resize === \"automatic\" && target) {\n        target.style.height = \"auto\";\n        target.style.height = `${target.scrollHeight + 2}px`;\n      }\n\n      if (e.type === \"change\" && onChange) {\n        onChange(e as React.ChangeEvent<HTMLTextAreaElement>);\n      }\n    };\n\n    return (\n      <div className={twMerge(clsx(\"relative\", className))}>\n        <textarea\n          ref={textAreaRef}\n          className={twMerge(\n            clsx(\n              textareaVariants({\n                size,\n                success: !!success,\n                error: !!error,\n                className: clsx(\n                  (resize === \"none\" || resize === \"automatic\") &&\n                    \"resize-none\",\n                  textAreaClassName\n                ),\n              })\n            )\n          )}\n          onChange={handleEvent}\n          {...rest}\n        />\n        {resize !== \"none\" && showResizeIcon && (\n          <TextAreaResizer className=\"pointer-events-none absolute bottom-0.5 right-0.5 h-4 w-4 text-gray-600\" />\n        )}\n      </div>\n    );\n  }\n);\n\nfunction InputHint({\n  hint,\n  disabled,\n  size = \"md\",\n  icon,\n  className,\n  success,\n  error,\n  ...rest\n}: InputHintProps) {\n  if (\n    hint ||\n    typeof success === \"string\" ||\n    (error && typeof error !== \"boolean\")\n  ) {\n    let hintText: string | ReactNode | undefined = hint;\n    typeof success === \"string\" && (hintText = success);\n    error && typeof error !== \"boolean\" && (hintText = error);\n    return (\n      <div\n        className={twMerge(\n          clsx(\n            inputHintVariant({\n              size,\n              success: !!success,\n              error: !!error,\n              disabled,\n              className,\n            })\n          )\n        )}\n        {...rest}\n      >\n        <Icon\n          iconName=\"RiInformationFill\"\n          size={size && getHintIconSize(size)}\n        />\n        {hintText}\n      </div>\n    );\n  }\n\n  return null;\n}\n\nexport { Input, TextArea, InputLabel, InputWrapper, InputHint };\n"
  },
  {
    "path": "maestro-studio/web/src/components/design-system/keyboard-key.tsx",
    "content": "import { ReactNode } from \"react\";\n\ninterface KeyboardKeyProps {\n  children: ReactNode;\n}\n\nconst KeyboardKey = ({ children }: KeyboardKeyProps) => {\n  return (\n    <kbd className=\"h-6 min-w-6 px-1 bg-slate-200 dark:bg-slate-800 rounded-sm flex items-center justify-center text-center font-mono\">\n      {children}\n    </kbd>\n  );\n};\n\nexport default KeyboardKey;\n"
  },
  {
    "path": "maestro-studio/web/src/components/design-system/link.tsx",
    "content": "import * as React from \"react\";\nimport { cva } from \"class-variance-authority\";\nimport { clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\nimport { Icon, IconList } from \"./icon\";\n\nconst linkVariant = cva(\n  \"inline-flex items-center font-semibold border-b-2 border-transparent transition focus-visible:outline-none focus-visible:ring-4 focus-visible:shadow-e2 focus-visible:ring-ring focus-visible:ring-offset-0 ring-offset-background\",\n  {\n    variants: {\n      variant: {\n        primary:\n          \"text-purple-500 hover:border-purple-200 dark:hover:border-purple-200/20 focus-visible:ring-purple-100 dark:focus-visible:ring-purple-100/20\",\n        info: \"text-blue-500 hover:border-blue-200 dark:hover:border-blue-200/20 focus-visible:ring-blue-100 dark:focus-visible:ring-blue-100/20\",\n        success:\n          \"text-green-500 hover:border-green-200 dark:hover:green-blue-200/20 focus-visible:ring-green-100 dark:focus-visible:ring-green-100/20\",\n        danger:\n          \"text-red-500 hover:border-red-200 dark:hover:border-red-200/20 focus-visible:ring-red-100 dark:focus-visible:ring-red-100/20\",\n        warning:\n          \"text-yellow-500 hover:border-yellow-200 dark:hover:border-yellow-200/20 focus-visible:ring-yellow-100 dark:focus-visible:ring-yellow-100/20\",\n      },\n      size: {\n        xs: \"gap-0.5 text-[10px]\",\n        sm: \"gap-1 text-xs\",\n        md: \"gap-1 text-sm\",\n        lg: \"gap-1.5 text-base\",\n        xl: \"gap-1.5 text-lg\",\n      },\n      disabled: {\n        true: \"cursor-not-allowed text-disabled hover:border-transparent\",\n        false: \"\",\n      },\n    },\n  }\n);\n\nconst getIconSize = (\n  size: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"\n): \"12\" | \"14\" | \"16\" | \"18\" | \"20\" | \"24\" | \"28\" | \"32\" | undefined => {\n  switch (size) {\n    case \"xs\":\n      return \"12\";\n    case \"sm\":\n      return \"14\";\n    case \"md\":\n      return \"18\";\n    case \"lg\":\n      return \"20\";\n    case \"xl\":\n      return \"20\";\n    default:\n      return undefined;\n  }\n};\n\ntype AnchorProps = React.DetailedHTMLProps<\n  React.AnchorHTMLAttributes<HTMLAnchorElement>,\n  HTMLAnchorElement\n>;\ntype ButtonProps = React.DetailedHTMLProps<\n  React.ButtonHTMLAttributes<HTMLButtonElement>,\n  HTMLButtonElement\n>;\ntype DivProps = React.DetailedHTMLProps<\n  React.HTMLAttributes<HTMLDivElement>,\n  HTMLDivElement\n>;\n\ntype CustomProps =\n  | (AnchorProps & { href: string })\n  | (ButtonProps & { onClick: () => void })\n  | DivProps;\n\ninterface CommonProps extends React.HtmlHTMLAttributes<HTMLElement> {\n  variant?: \"primary\" | \"info\" | \"danger\" | \"success\" | \"warning\";\n  size?: \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\";\n  leftIcon?: keyof typeof IconList;\n  leftIconClassName?: string;\n  rightIcon?: keyof typeof IconList;\n  rightIconClassName?: string;\n  disabled?: boolean;\n}\n\ntype TagProps = CustomProps & CommonProps;\n\nconst Link = React.forwardRef<\n  HTMLDivElement | HTMLButtonElement | HTMLAnchorElement,\n  TagProps\n>(\n  (\n    {\n      className,\n      variant = \"primary\",\n      size = \"md\",\n      leftIcon,\n      leftIconClassName,\n      rightIcon,\n      rightIconClassName,\n      disabled,\n      children,\n      ...props\n    },\n    ref\n  ) => {\n    /**\n     * Setting up the element\n     */\n    let Element: \"a\" | \"button\" | \"div\";\n    if (\"href\" in props) {\n      Element = \"a\";\n    } else if (\"onClick\" in props) {\n      Element = \"button\";\n    } else {\n      Element = \"div\";\n    }\n    const Component = Element as any;\n\n    return (\n      <Component\n        className={twMerge(\n          clsx(\n            linkVariant({\n              variant,\n              size,\n              disabled: disabled || Element === \"div\",\n              className,\n            })\n          )\n        )}\n        disabled={disabled}\n        ref={ref as any}\n        {...props}\n      >\n        {leftIcon && (\n          <Icon\n            iconName={leftIcon}\n            size={getIconSize(size)}\n            className={leftIconClassName}\n          />\n        )}\n        {children}\n        {rightIcon && (\n          <Icon\n            iconName={rightIcon}\n            size={getIconSize(size)}\n            className={rightIconClassName}\n          />\n        )}\n      </Component>\n    );\n  }\n);\nLink.displayName = \"Link\";\n\nexport { Link };\n"
  },
  {
    "path": "maestro-studio/web/src/components/design-system/spinner.tsx",
    "content": "import React from \"react\";\nimport { VariantProps, cva } from \"class-variance-authority\";\nimport { twMerge } from \"tailwind-merge\";\nimport clsx from \"clsx\";\n\nconst spinnerVariants = cva(\"\", {\n  variants: {\n    size: {\n      \"12\": \"h-3 w-3 min-w-3\",\n      \"14\": \"h-3.5 w-3.5 min-w-3.5\",\n      \"16\": \"h-4 w-4 min-w-4\",\n      \"18\": \"h-[18px] w-[18px] min-w-[18px]\",\n      \"20\": \"h-5 w-5 min-w-5\",\n      \"24\": \"h-6 w-6 min-w-6\",\n      \"28\": \"h-7 w-7 min-w-7\",\n      \"32\": \"h-8 w-8 min-w-8\",\n    },\n  },\n  defaultVariants: {\n    size: \"24\",\n  },\n});\n\nconst strokeClasses = cva(\"\", {\n  variants: {\n    size: {\n      \"12\": \"stroke-2\",\n      \"14\": \"stroke-2\",\n      \"16\": \"stroke-2\",\n      \"18\": \"stroke-2\",\n      \"20\": \"stroke-2\",\n      \"24\": \"stroke-2\",\n      \"28\": \"stroke-2\",\n      \"32\": \"stroke-2\",\n    },\n  },\n  defaultVariants: {\n    size: \"24\",\n  },\n});\n\nexport interface SpinnerProps\n  extends React.SVGAttributes<SVGElement>,\n    VariantProps<typeof spinnerVariants> {}\n\nfunction Spinner({ className, size, ...props }: SpinnerProps) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      width=\"38\"\n      height=\"38\"\n      viewBox=\"0 0 38 38\"\n      className={twMerge(clsx(spinnerVariants({ size, className })))}\n      {...props}\n    >\n      <defs>\n        <linearGradient x1=\"8.042%\" y1=\"0%\" x2=\"65.682%\" y2=\"23.865%\" id=\"a\">\n          <stop stopColor=\"currentColor\" stop-opacity=\"0\" offset=\"0%\" />\n          <stop stopColor=\"currentColor\" stopOpacity=\".631\" offset=\"63.146%\" />\n          <stop stop-color=\"currentColor\" offset=\"100%\" />\n        </linearGradient>\n      </defs>\n      <g fill=\"none\" fill-rule=\"evenodd\">\n        <g transform=\"translate(1 1)\">\n          <path\n            d=\"M36 18c0-9.94-8.06-18-18-18\"\n            id=\"Oval-2\"\n            stroke=\"url(#a)\"\n            strokeWidth=\"2\"\n            className={twMerge(clsx(strokeClasses({ size })))}\n          >\n            <animateTransform\n              attributeName=\"transform\"\n              type=\"rotate\"\n              from=\"0 18 18\"\n              to=\"360 18 18\"\n              dur=\"0.5s\"\n              repeatCount=\"indefinite\"\n            />\n          </path>\n          <circle fill=\"currentColor\" cx=\"36\" cy=\"18\" r=\"1\">\n            <animateTransform\n              attributeName=\"transform\"\n              type=\"rotate\"\n              from=\"0 18 18\"\n              to=\"360 18 18\"\n              dur=\"0.5s\"\n              repeatCount=\"indefinite\"\n            />\n          </circle>\n        </g>\n      </g>\n    </svg>\n  );\n}\n\nexport { Spinner, spinnerVariants };\n"
  },
  {
    "path": "maestro-studio/web/src/components/design-system/tabs.tsx",
    "content": "import * as React from \"react\";\nimport * as TabsPrimitive from \"@radix-ui/react-tabs\";\n\nimport clsx from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nconst Tabs = TabsPrimitive.Root;\n\nconst TabsList = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.List>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.List\n    ref={ref}\n    className={twMerge(\n      clsx(\"inline-flex items-center justify-start\", className)\n    )}\n    {...props}\n  />\n));\nTabsList.displayName = TabsPrimitive.List.displayName;\n\nconst TabsTrigger = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Trigger>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Trigger\n    ref={ref}\n    className={twMerge(\n      clsx(\n        \"inline-flex border-b-2 border-transparent items-center text-gray-400 hover:text-gray-600 justify-center whitespace-nowrap py-2 text-sm font-semibold ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:border-purple-500 data-[state=active]:text-purple-500\",\n        className\n      )\n    )}\n    {...props}\n  />\n));\nTabsTrigger.displayName = TabsPrimitive.Trigger.displayName;\n\nconst TabsContent = React.forwardRef<\n  React.ElementRef<typeof TabsPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>\n>(({ className, ...props }, ref) => (\n  <TabsPrimitive.Content\n    ref={ref}\n    tabIndex={-1}\n    className={twMerge(\n      clsx(\n        \"ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n        className\n      )\n    )}\n    {...props}\n  />\n));\nTabsContent.displayName = TabsPrimitive.Content.displayName;\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent };\n"
  },
  {
    "path": "maestro-studio/web/src/components/design-system/utils/functions.tsx",
    "content": "export const checkImageUrl = async (imageUrl: string) => {\n  try {\n    const response = await fetch(imageUrl);\n    if (\n      response.ok &&\n      response.headers.get(\"content-type\")?.startsWith(\"image/\")\n    ) {\n      return true;\n    } else {\n      return false;\n    }\n  } catch (error) {\n    return false;\n  }\n};\n"
  },
  {
    "path": "maestro-studio/web/src/components/design-system/utils/images.tsx",
    "content": "export const StatusImage = ({ ...props }) => (\n  <svg width=\"18\" height=\"18\" viewBox=\"0 0 18 18\" fill=\"none\" {...props}>\n    <path\n      d=\"M2 9C2 5.13401 5.13401 2 9 2V2C12.866 2 16 5.13401 16 9V9C16 12.866 12.866 16 9 16V16C5.13401 16 2 12.866 2 9V9Z\"\n      fill=\"currentColor\"\n    />\n    <path\n      fillRule=\"evenodd\"\n      clipRule=\"evenodd\"\n      d=\"M0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 13.6944 13.6944 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9ZM9 3.5C5.96243 3.5 3.5 5.96243 3.5 9C3.5 12.0376 5.96243 14.5 9 14.5C12.0376 14.5 14.5 12.0376 14.5 9C14.5 5.96243 12.0376 3.5 9 3.5Z\"\n      fill=\"white\"\n    />\n  </svg>\n);\n\nexport const VerifiedImage = ({ ...props }) => (\n  <svg width=\"18\" height=\"18\" viewBox=\"0 0 18 18\" fill=\"none\" {...props}>\n    <path\n      d=\"M13.5486 0.905725L13.6528 1.11381L14.439 3.20462C14.5008 3.36927 14.6307 3.49919 14.7954 3.56096L16.814 4.31829C17.6999 4.65063 18.1759 5.59722 17.9399 6.49282L17.8831 6.67107L16.9439 8.74803C16.8712 8.90813 16.8712 9.09187 16.9439 9.25197L17.8358 11.2149C18.2272 12.0763 17.8945 13.0822 17.0943 13.5486L16.8862 13.6528L14.7954 14.439C14.6307 14.5008 14.5008 14.6307 14.439 14.7954L13.6817 16.814C13.3494 17.6999 12.4028 18.1759 11.5072 17.9399L11.3289 17.8831L9.25197 16.9439C9.09187 16.8712 8.90813 16.8712 8.74803 16.9439L6.78511 17.8358C5.92372 18.2272 4.91778 17.8945 4.45141 17.0943L4.34718 16.8862L3.56096 14.7954C3.49919 14.6307 3.36927 14.5008 3.20462 14.439L1.18597 13.6817C0.300118 13.3494 -0.17591 12.4028 0.0601429 11.5072L0.116938 11.3289L1.05606 9.25197C1.12881 9.09187 1.12881 8.90813 1.05606 8.74803L0.164176 6.78511C-0.227213 5.92372 0.105523 4.91778 0.905725 4.45141L1.11381 4.34718L3.20462 3.56096C3.36927 3.49919 3.49919 3.36927 3.56096 3.20462L4.31829 1.18597C4.65063 0.300118 5.59722 -0.17591 6.49282 0.0601429L6.67107 0.116938L8.74803 1.05606C8.90813 1.12881 9.09187 1.12881 9.25197 1.05606L11.2149 0.164176C12.0763 -0.227213 13.0822 0.105523 13.5486 0.905725Z\"\n      fill=\"#1C92FF\"\n    />\n    <path\n      d=\"M7.60196 10.8376L11.9468 5.87208C12.1683 5.61891 12.5532 5.59326 12.8063 5.81478C13.0595 6.0363 13.0852 6.42112 12.8636 6.67429L8.09003 12.1298C7.85801 12.395 7.45007 12.4086 7.20092 12.1594L5.15509 10.1136C4.91722 9.87574 4.91722 9.49007 5.15509 9.25219C5.39296 9.01432 5.77863 9.01432 6.01651 9.25219L7.60196 10.8376Z\"\n      fill=\"white\"\n    />\n  </svg>\n);\n\nexport const CheckboxCheck = ({ ...props }) => (\n  <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" {...props}>\n    <path\n      d=\"M12.813 5.914L6.743 11.985L3.5 8.742L4.914 7.328L6.743 9.157L11.399 4.5L12.813 5.914Z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\n\nexport const CheckboxIntermediate = ({ ...props }) => (\n  <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" {...props}>\n    <rect x=\"4\" y=\"7\" width=\"8\" height=\"2\" fill=\"currentColor\" />\n  </svg>\n);\n\nexport const TextAreaResizer = ({ ...props }) => (\n  <svg\n    width=\"20\"\n    height=\"20\"\n    viewBox=\"0 0 20 20\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <path\n      fillRule=\"evenodd\"\n      clipRule=\"evenodd\"\n      d=\"M4 13.0711L13.0711 4L14.4853 5.41421L5.41421 14.4853L4 13.0711Z\"\n      fill=\"currentColor\"\n    />\n    <path\n      fillRule=\"evenodd\"\n      clipRule=\"evenodd\"\n      d=\"M9 14.2426L14.2426 9L15.6569 10.4142L10.4142 15.6569L9 14.2426Z\"\n      fill=\"currentColor\"\n    />\n  </svg>\n);\n\nexport const EnterKey = ({ ...props }) => (\n  <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" {...props}>\n    <path\n      fill=\"currentColor\"\n      d=\"M19.0003 13.9999L19.0004 5.00003L17.0004 5L17.0003 11.9999L6.82845 12L10.7782 8.05027L9.36396 6.63606L3 13L9.36396 19.364L10.7782 17.9498L6.8284 14L19.0003 13.9999Z\"\n    ></path>\n  </svg>\n);\n\nexport const AiSparkles = ({ ...props }) => (\n  <svg\n    width=\"18\"\n    height=\"18\"\n    viewBox=\"0 0 18 18\"\n    fill=\"none\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n    {...props}\n  >\n    <path\n      fill=\"currentColor\"\n      d=\"M9.022 4.39C9.118 4.39 9.179 4.329 9.2 4.233C9.391 3.119 9.384 3.105 10.56 2.873C10.656 2.853 10.717 2.797 10.717 2.695C10.717 2.599 10.656 2.538 10.56 2.524C9.384 2.305 9.405 2.284 9.2 1.164C9.18 1.068 9.118 1 9.022 1C8.926 1 8.865 1.068 8.844 1.164C8.639 2.271 8.667 2.284 7.484 2.524C7.388 2.538 7.327 2.599 7.327 2.694C7.327 2.797 7.388 2.852 7.491 2.873C8.66 3.098 8.653 3.105 8.844 4.233C8.864 4.329 8.926 4.39 9.022 4.39ZM5.7 9.237C5.85 9.237 5.966 9.127 5.987 8.984C6.219 7.186 6.267 7.172 8.154 6.824C8.22258 6.81652 8.28587 6.78362 8.3314 6.7318C8.37693 6.67997 8.40141 6.61297 8.4 6.544C8.4 6.394 8.29 6.277 8.147 6.264C6.274 6.004 6.219 5.949 5.987 4.11C5.967 3.96 5.857 3.85 5.7 3.85C5.55 3.85 5.44 3.96 5.42 4.117C5.2 5.915 5.126 5.915 3.252 6.263C3.102 6.283 3 6.393 3 6.543C3 6.701 3.102 6.803 3.28 6.823C5.126 7.111 5.2 7.173 5.42 8.97C5.44 9.128 5.55 9.237 5.7 9.237ZM10.52 16.777C10.731 16.777 10.895 16.627 10.93 16.401C11.428 12.731 11.94 12.149 15.585 11.739C15.6897 11.7324 15.7881 11.6866 15.8604 11.6105C15.9328 11.5345 15.9737 11.4339 15.975 11.329C15.975 11.109 15.81 10.946 15.585 10.912C11.975 10.481 11.462 9.955 10.929 6.25C10.889 6.03 10.731 5.88 10.519 5.88C10.307 5.88 10.143 6.03 10.109 6.25C9.609 9.927 9.097 10.508 5.454 10.912C5.228 10.939 5.064 11.11 5.064 11.329C5.064 11.541 5.228 11.712 5.454 11.739C9.056 12.231 9.555 12.703 10.109 16.401C10.149 16.627 10.308 16.777 10.52 16.777Z\"\n    />\n  </svg>\n);\n"
  },
  {
    "path": "maestro-studio/web/src/components/device-and-device-elements/ActionModal.tsx",
    "content": "import {\n  ReactNode,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport _ from \"lodash\";\nimport {\n  Dialog,\n  DialogTrigger,\n  DialogContent,\n  DialogHeader,\n  DialogDescription,\n  DialogTitle,\n} from \"../design-system/dialog\";\nimport {\n  Tabs,\n  TabsList,\n  TabsTrigger,\n  TabsContent,\n} from \"../design-system/tabs\";\nimport { Button } from \"../design-system/button\";\nimport copy from \"copy-to-clipboard\";\nimport { Link } from \"../design-system/link\";\nimport { Input, InputWrapper } from \"../design-system/input\";\nimport Fuse from \"fuse.js\";\nimport {\n  CommandExample,\n  getCommandExamples,\n} from \"../../helpers/commandExample\";\nimport SelectedElementViewer from \"./SelectedElementViewer\";\nimport clsx from \"clsx\";\nimport { Icon } from \"../design-system/icon\";\nimport { EnterKey } from \"../design-system/utils/images\";\nimport KeyboardKey from \"../design-system/keyboard-key\";\nimport { useDeviceContext } from \"../../context/DeviceContext\";\n\ninterface ActionModalProps {\n  children?: ReactNode;\n  onEdit: (example: CommandExample) => void;\n  onRun: (example: CommandExample) => void;\n}\n\nexport default function ActionModal({\n  children,\n  onEdit,\n  onRun,\n}: ActionModalProps) {\n  const isMac = navigator.platform.toUpperCase().indexOf(\"MAC\") >= 0;\n  const { deviceScreen, inspectedElement, setInspectedElement } =\n    useDeviceContext();\n  const inputElementRef = useRef<HTMLInputElement>(null);\n  const prevCommandListRef = useRef<Record<string, CommandExample[]>>();\n  const [query, setQuery] = useState<string>(\"\");\n  const [selectedTab, setSelectedTab] = useState<string>(\"Tap\");\n  const [commandBeingCopied, setCommandBeingCopied] =\n    useState<string | null>(null);\n  const [commandList, setCommandList] = useState<\n    Record<string, CommandExample[]>\n  >({});\n  const [selectedCommand, setSelectedCommand] = useState<CommandExample>();\n\n  /**\n   * Saving the filters and unfilteredCommands\n   */\n  const { unfilteredExamples, fuse } = useMemo(() => {\n    const unfilteredExamples = getCommandExamples(\n      deviceScreen,\n      inspectedElement\n    ).filter((item: CommandExample) => item.status === \"available\");\n    const fuse = new Fuse(unfilteredExamples, { keys: [\"title\", \"content\"] });\n    return { unfilteredExamples, fuse };\n  }, [deviceScreen, inspectedElement]);\n\n  /**\n   * Filtering and getting the new command lists\n   */\n  useEffect(() => {\n    const examples = query\n      ? fuse.search(query).map((r) => r.item)\n      : unfilteredExamples;\n    const newCommandList = examples.reduce(\n      (acc: Record<string, CommandExample[]>, current: CommandExample) => {\n        let key = current.title.split(\" > \")[0];\n        if (!acc[key]) {\n          acc[key] = [];\n        }\n        // Push the current item to the list\n        acc[key].push(current);\n        // Use _.uniqBy to filter out duplicates, comparing based on 'id' property\n        acc[key] = _.uniqBy(acc[key], \"content\");\n        return acc;\n      },\n      {}\n    );\n    setCommandList(newCommandList);\n  }, [query, fuse, unfilteredExamples]);\n\n  const updateSelectedCommand = useCallback(\n    ({\n      val,\n      direction,\n      currentTab,\n    }: {\n      val?: string;\n      direction?: \"first\" | \"next\" | \"prev\";\n      currentTab?: string;\n    }) => {\n      if (direction === \"first\") {\n        if (commandList && currentTab) {\n          setSelectedCommand(commandList[currentTab][0]);\n        }\n      } else if (direction === \"next\") {\n        setSelectedCommand((prev) => {\n          if (prev === undefined || !currentTab) {\n            return undefined;\n          }\n          const commandObject = commandList[currentTab];\n          const currentIndex = _.findIndex(commandObject, prev);\n          if (commandObject.length <= 1 || currentIndex === -1) {\n            return prev;\n          } else {\n            const nextIndex = (currentIndex + 1) % commandObject.length;\n            return commandObject[nextIndex];\n          }\n        });\n      } else if (direction === \"prev\") {\n        setSelectedCommand((prev) => {\n          if (prev === undefined || !currentTab) {\n            return undefined;\n          }\n          const commandObject = commandList[currentTab];\n          if (commandObject.length <= 1) {\n            return prev;\n          }\n          const currentIndex = _.findIndex(commandObject, prev);\n          if (currentIndex === -1) {\n            return commandObject[commandObject.length - 1];\n          } else {\n            const nextIndex =\n              (currentIndex - 1 + commandObject.length) % commandObject.length;\n            return commandObject[nextIndex];\n          }\n        });\n      }\n    },\n    [commandList]\n  );\n\n  const updateTabs = useCallback(\n    ({ val, direction }: { val?: string; direction?: \"right\" | \"left\" }) => {\n      if (val) {\n        setSelectedTab(val);\n        updateSelectedCommand({ direction: \"first\", currentTab: val });\n      } else if (direction === \"right\") {\n        const currentCommandList = Object.keys(commandList);\n        setSelectedTab((prev) => {\n          const currentIndex = currentCommandList.indexOf(prev);\n          if (currentCommandList.length <= 1 || currentIndex === -1) {\n            return prev;\n          } else {\n            const val =\n              currentCommandList[\n                (currentIndex + 1) % currentCommandList.length\n              ];\n            updateSelectedCommand({ direction: \"first\", currentTab: val });\n            return val;\n          }\n        });\n      } else if (direction === \"left\") {\n        const currentCommandList = Object.keys(commandList);\n        setSelectedTab((prev) => {\n          if (currentCommandList.length <= 1) {\n            return prev;\n          }\n          const currentIndex = currentCommandList.indexOf(prev);\n          if (currentIndex === -1) {\n            return currentCommandList[currentCommandList.length - 1];\n          } else {\n            const val =\n              currentCommandList[\n                (currentIndex - 1 + currentCommandList.length) %\n                  currentCommandList.length\n              ];\n            updateSelectedCommand({ direction: \"first\", currentTab: val });\n            return val;\n          }\n        });\n      }\n    },\n    [commandList, updateSelectedCommand]\n  );\n\n  const copyCommand = useCallback((command: string) => {\n    copy(command);\n    setCommandBeingCopied(command);\n    setTimeout(() => {\n      setCommandBeingCopied(null);\n    }, 1000);\n  }, []);\n\n  /**\n   * Change the tabs on search\n   */\n  useEffect(() => {\n    const prevCommandList = prevCommandListRef.current;\n    if (JSON.stringify(prevCommandList) !== JSON.stringify(commandList)) {\n      if (document.activeElement === inputElementRef.current) {\n        const firstKey: string = Object.keys(commandList)[0];\n        if (firstKey) {\n          updateTabs({ val: firstKey });\n        }\n      }\n    }\n    prevCommandListRef.current = commandList;\n  }, [commandList, updateTabs]);\n\n  /**\n   * Keyboard Actions\n   */\n  const handleKeyPress = useCallback(\n    (e: KeyboardEvent) => {\n      switch (e.code) {\n        case \"ArrowRight\":\n          e.preventDefault();\n          updateTabs({ direction: \"right\" });\n          break;\n        case \"ArrowLeft\":\n          e.preventDefault();\n          updateTabs({ direction: \"left\" });\n          break;\n        case \"ArrowDown\":\n          e.preventDefault();\n          updateSelectedCommand({ direction: \"next\", currentTab: selectedTab });\n          break;\n        case \"ArrowUp\":\n          e.preventDefault();\n          updateSelectedCommand({ direction: \"prev\", currentTab: selectedTab });\n          break;\n        case \"Enter\":\n          e.preventDefault();\n          if (\n            (isMac && e.metaKey) || // If mac - Command is pressed\n            (!isMac && e.ctrlKey && !e.altKey && !e.shiftKey) // Or If not mac - Only control key is pressed\n          ) {\n            selectedCommand && onRun(selectedCommand);\n          } else {\n            selectedCommand && onEdit(selectedCommand);\n          }\n          break;\n        case \"KeyD\":\n          e.preventDefault();\n          if (\n            (isMac && e.metaKey) || // If mac - Command is pressed\n            (!isMac && e.ctrlKey && !e.altKey && !e.shiftKey) // Or If not mac - Only control key is pressed\n          ) {\n            const documentation = selectedCommand?.documentation;\n            if (!documentation) return;\n            window.open(documentation, \"_blank\", \"noreferrer\");\n          }\n          break;\n        case \"KeyC\":\n          if (\n            (isMac && e.metaKey) || // If mac - Command is pressed\n            (!isMac && e.ctrlKey && !e.altKey && !e.shiftKey) // Or If not mac - Only control key is pressed\n          ) {\n            // If no text is selected\n            if (window && window.getSelection()?.toString() === \"\") {\n              e.preventDefault();\n              if (typeof selectedCommand?.content === \"string\") {\n                copyCommand(selectedCommand.content);\n              }\n            }\n          }\n          break;\n        case \"Tab\":\n          e.preventDefault();\n          if (document.activeElement !== inputElementRef.current) {\n            inputElementRef.current?.focus();\n          }\n          break;\n      }\n    },\n    [\n      isMac,\n      copyCommand,\n      onEdit,\n      onRun,\n      selectedCommand,\n      selectedTab,\n      updateSelectedCommand,\n      updateTabs,\n    ]\n  );\n\n  /**\n   * Add Keyboard Actions\n   */\n  useEffect(() => {\n    function conditionalHandleKeyPress(event: KeyboardEvent) {\n      if (!inspectedElement) return;\n      handleKeyPress(event);\n    }\n    if (inspectedElement) {\n      const timeoutId = setTimeout(() => {\n        window.addEventListener(\"keydown\", conditionalHandleKeyPress);\n      }, 0); // Introducing a delay to bypass current event loop\n\n      return () => {\n        clearTimeout(timeoutId); // Clear timeout in case the component unmounts before it executes\n        window.removeEventListener(\"keydown\", conditionalHandleKeyPress);\n      };\n    } else {\n      window.removeEventListener(\"keydown\", conditionalHandleKeyPress);\n    }\n  }, [handleKeyPress, inspectedElement]);\n\n  return (\n    <Dialog\n      open={!!inspectedElement}\n      onOpenChange={() => setInspectedElement(null)}\n    >\n      <DialogTrigger asChild>{children}</DialogTrigger>\n      <DialogContent className=\"sm:max-w-6xl w-[95vw]\">\n        <KeyboardShortcutsHeader />\n        <div className=\"flex gap-20 p-8 items-stretch\">\n          <SelectedElementViewer uiElement={inspectedElement} />\n          <div className=\"flex-grow min-w-0\">\n            <DialogHeader className=\"pb-4\">\n              <DialogTitle className=\"text-left\">\n                Examples of how you can interact with this element:\n              </DialogTitle>\n            </DialogHeader>\n            <DialogDescription>\n              <div className=\"mb-6\">\n                <InputWrapper size=\"sm\">\n                  <Input\n                    ref={inputElementRef}\n                    leftIcon=\"RiSearchLine\"\n                    placeholder=\"Search commands\"\n                    value={query}\n                    onChange={(e) => setQuery(e.target.value)}\n                  />\n                </InputWrapper>\n              </div>\n              {Object.keys(commandList).length > 0 ? (\n                <Tabs\n                  value={selectedTab}\n                  onValueChange={(val: string) => updateTabs({ val })}\n                  defaultValue={Object.keys(commandList)[0]}\n                >\n                  <TabsList className=\"flex w-full border-b border-slate-200 dark:border-slate-800 gap-3\">\n                    {Object.keys(commandList).map((key: string) => {\n                      return <TabsTrigger value={key}>{key}</TabsTrigger>;\n                    })}\n                  </TabsList>\n                  {Object.keys(commandList).map((key: string) => {\n                    return (\n                      <TabsContent value={key} className=\"py-8\">\n                        <div className=\"flex flex-col gap-3\">\n                          <div className=\"flex justify-end\">\n                            <Link\n                              href={commandList[key][0].documentation}\n                              target=\"_blank\"\n                              rel=\"noopener noreferrer\"\n                              variant=\"info\"\n                              rightIcon=\"RiArrowRightUpLine\"\n                              className=\"mb-3\"\n                            >\n                              View {key} Documentation\n                            </Link>\n                          </div>\n                          {commandList[key].map(\n                            (item: CommandExample, index: number) => {\n                              return (\n                                <ActionCommandListItem\n                                  key={`command-${item.title}-${index}`}\n                                  selected={\n                                    JSON.stringify(selectedCommand) ===\n                                    JSON.stringify(item)\n                                  }\n                                  setSelectedCommand={setSelectedCommand}\n                                  isBeingCopied={\n                                    item.content === commandBeingCopied\n                                  }\n                                  copyCommand={copyCommand}\n                                  command={item}\n                                  onRun={onRun}\n                                  onEdit={onEdit}\n                                />\n                              );\n                            }\n                          )}\n                        </div>\n                      </TabsContent>\n                    );\n                  })}\n                </Tabs>\n              ) : (\n                <div className=\"pb-4\">\n                  <div className=\"p-3 bg-slate-100 dark:bg-slate-800 rounded-md text-center font-semibold text-sm\">\n                    Couldn't find any command for the element\n                  </div>\n                </div>\n              )}\n            </DialogDescription>\n          </div>\n        </div>\n      </DialogContent>\n    </Dialog>\n  );\n}\n\ninterface ActionCommandListItemProps {\n  selected?: boolean;\n  command: CommandExample;\n  isBeingCopied: boolean;\n  setSelectedCommand: (example: CommandExample) => void;\n  copyCommand: (command: string) => void;\n  onEdit: (example: CommandExample) => void;\n  onRun: (example: CommandExample) => void;\n}\n\nconst ActionCommandListItem = ({\n  selected = false,\n  command,\n  isBeingCopied,\n  setSelectedCommand,\n  copyCommand,\n  onRun,\n  onEdit,\n}: ActionCommandListItemProps) => {\n  const commandItemRef = useRef<HTMLDivElement>(null);\n\n  /**\n   * Scroll selected element in view\n   */\n  useEffect(() => {\n    if (selected && commandItemRef.current) {\n      commandItemRef.current?.scrollIntoView({ behavior: \"smooth\" });\n    }\n  }, [selected]);\n\n  return (\n    <div\n      ref={commandItemRef}\n      onClick={() => setSelectedCommand(command)}\n      onDoubleClick={() => onRun(command)}\n      className={clsx(\n        `relative border rounded-md flex gap-2 overflow-hidden cursor-pointer group`,\n        selected\n          ? \"border-purple-500 ring-4 ring-offset-0 ring-purple-100/20\"\n          : \"border-slate-200 dark:border-slate-800\"\n      )}\n    >\n      <pre className=\"overflow-auto font-mono text-gray-700 dark:text-white flex-grow pt-3 pb-5 pl-3 pr-40 hide-scrollbar\">\n        {command.content}\n      </pre>\n      <div className=\"bg-gradient-to-r from-transparent to-white dark:to-slate-900 w-80 absolute top-0 right-0 bottom-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-all\" />\n      <div className=\"absolute flex gap-2 right-2 top-2\">\n        <Button\n          onClick={(e) => {\n            e.preventDefault();\n            e.stopPropagation();\n            onRun(command);\n          }}\n          className=\"opacity-0 group-hover:opacity-100\"\n          variant=\"primary\"\n          size=\"sm\"\n          icon=\"RiPlayLine\"\n        />\n        <Button\n          onClick={(e) => {\n            e.preventDefault();\n            e.stopPropagation();\n            onEdit(command);\n          }}\n          className=\"opacity-0 group-hover:opacity-100\"\n          variant=\"secondary\"\n          size=\"sm\"\n          icon=\"RiCodeLine\"\n        />\n        {isBeingCopied ? (\n          <Button variant=\"primary-green\" size=\"sm\" icon=\"RiCheckLine\" />\n        ) : (\n          <Button\n            onClick={(e) => {\n              e.preventDefault();\n              e.stopPropagation();\n              copyCommand(command.content);\n            }}\n            className=\"opacity-0 group-hover:opacity-100\"\n            variant=\"tertiary\"\n            size=\"sm\"\n            icon=\"RiFileCopyLine\"\n          />\n        )}\n      </div>\n    </div>\n  );\n};\n\nconst KeyboardShortcutsHeader = () => {\n  const isMac = navigator.platform.toUpperCase().indexOf(\"MAC\") >= 0;\n\n  return (\n    <div className=\"hidden md:flex pl-8 pr-12 py-3 border-b border-slate-200 dark:border-slate-800 gap-x-8 gap-y-3 flex-wrap\">\n      <div className=\"flex gap-2\">\n        <p>Navigate:</p>\n        <div className=\"flex gap-1\">\n          <KeyboardKey>\n            <Icon iconName=\"RiArrowUpLine\" size=\"16\" />\n          </KeyboardKey>\n          <KeyboardKey>\n            <Icon iconName=\"RiArrowDownLine\" size=\"16\" />\n          </KeyboardKey>\n          <KeyboardKey>\n            <Icon iconName=\"RiArrowLeftLine\" size=\"16\" />\n          </KeyboardKey>\n          <KeyboardKey>\n            <Icon iconName=\"RiArrowRightLine\" size=\"16\" />\n          </KeyboardKey>\n        </div>\n      </div>\n      <div className=\"flex gap-2\">\n        <p>Copy:</p>\n        <div className=\"flex gap-1\">\n          {isMac ? (\n            <KeyboardKey>\n              <Icon iconName=\"RiCommandLine\" size=\"16\" />\n            </KeyboardKey>\n          ) : (\n            <KeyboardKey>Ctrl</KeyboardKey>\n          )}\n          <KeyboardKey>C</KeyboardKey>\n        </div>\n      </div>\n      <div className=\"flex gap-2\">\n        <p>Doc:</p>\n        <div className=\"flex gap-1\">\n          {isMac ? (\n            <KeyboardKey>\n              <Icon iconName=\"RiCommandLine\" size=\"16\" />\n            </KeyboardKey>\n          ) : (\n            <KeyboardKey>Ctrl</KeyboardKey>\n          )}\n          <KeyboardKey>D</KeyboardKey>\n        </div>\n      </div>\n      <div className=\"flex gap-2\">\n        <p>Edit:</p>\n        <div className=\"flex gap-1\">\n          <KeyboardKey>\n            <EnterKey className=\"w-4\" />\n          </KeyboardKey>\n        </div>\n      </div>\n      <div className=\"flex gap-2\">\n        <p>Run:</p>\n        <div className=\"flex gap-1\">\n          {isMac ? (\n            <KeyboardKey>\n              <Icon iconName=\"RiCommandLine\" size=\"16\" />\n            </KeyboardKey>\n          ) : (\n            <KeyboardKey>Ctrl</KeyboardKey>\n          )}\n          <KeyboardKey>\n            <EnterKey className=\"w-4\" />\n          </KeyboardKey>\n        </div>\n      </div>\n      <div className=\"flex gap-2\">\n        <p>Focus Search:</p>\n        <div className=\"flex gap-1\">\n          <KeyboardKey>Tab</KeyboardKey>\n        </div>\n      </div>\n      <div className=\"flex gap-2\">\n        <p>Close:</p>\n        <div className=\"flex gap-1\">\n          <KeyboardKey>Esc</KeyboardKey>\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "maestro-studio/web/src/components/device-and-device-elements/AnnotatedScreenshot.tsx",
    "content": "import { DeviceScreen, UIElement } from \"../../helpers/models\";\nimport { CSSProperties, useCallback, useEffect, useRef, useState } from \"react\";\nimport useMouse, { MousePosition } from \"@react-hook/mouse-position\";\nimport { useDeviceContext } from \"../../context/DeviceContext\";\n\ntype AnnotationState = \"default\" | \"hidden\" | \"hovered\" | \"selected\";\n\nconst toPercent = (n: number, total: number) =>\n  `${Math.round((100 * n) / total)}%`;\n\nconst Annotation = ({\n  element,\n  deviceWidth,\n  deviceHeight,\n  state,\n  onClick,\n}: {\n  element: UIElement;\n  deviceWidth: number;\n  deviceHeight: number;\n  state: AnnotationState;\n  onClick: () => void;\n}) => {\n  if (!element.bounds || state === \"hidden\") return null;\n  const { x, y, width, height } = element.bounds;\n  const l = `${(x / deviceWidth) * 100}%`;\n  const t = `${(y / deviceHeight) * 100}%`;\n  const w = `${(width / deviceWidth) * 100}%`;\n  const h = `${(height / deviceHeight) * 100}%`;\n\n  let className = \"border border-dashed border-pink-400/60\";\n  let style: CSSProperties = {};\n\n  if (state === \"hovered\") {\n    className = \"border-4 border-blue-500 active:active:bg-blue-400/40 z-10\";\n    style = {\n      boxShadow: \"0 0 0 9999px rgba(244, 114, 182, 0.4)\",\n    };\n  } else if (state === \"selected\") {\n    className = \"border-4 border-blue-500 z-10\";\n    style = {\n      boxShadow: \"0 0 0 9999px rgba(96, 165, 250, 0.4)\",\n    };\n  }\n\n  return (\n    <>\n      <div\n        className={`absolute ${className} shadow-pink-400`}\n        style={{\n          left: l,\n          top: t,\n          width: w,\n          height: h,\n          ...style,\n        }}\n        onClick={onClick}\n      />\n    </>\n  );\n};\n\nconst Crosshairs = ({\n  cx,\n  cy,\n  color,\n}: {\n  cx: number;\n  cy: number;\n  color: string;\n}) => {\n  return (\n    <>\n      <div\n        className={`absolute z-20 w-[1px] h-full ${color} -translate-x-1/2 pointer-events-none`}\n        style={{\n          top: 0,\n          bottom: 0,\n          left: `${cx * 100}%`,\n        }}\n      />\n      <div\n        className={`absolute z-20 h-[1px] w-full ${color} -translate-y-1/2 pointer-events-none`}\n        style={{\n          left: 0,\n          right: 0,\n          top: `${cy * 100}%`,\n        }}\n      />\n    </>\n  );\n};\n\nconst pointInBounds = (\n  boundsX: number,\n  boundsY: number,\n  boundsWidth: number,\n  boundsHeight: number,\n  x: number,\n  y: number\n) => {\n  return (\n    x >= boundsX &&\n    x <= boundsX + boundsWidth &&\n    y >= boundsY &&\n    y <= boundsY + boundsHeight\n  );\n};\n\nconst getHoveredElement = (\n  deviceScreen: DeviceScreen | undefined,\n  mouse: MousePosition\n): UIElement | null => {\n  if (!deviceScreen) {\n    return null;\n  }\n  const hoveredList = deviceScreen.elements.filter((element) => {\n    if (!element.bounds) return false;\n    const {\n      x: boundsX,\n      y: boundsY,\n      width: boundsWidth,\n      height: boundsHeight,\n    } = element.bounds;\n    const { x: mouseX, y: mouseY, elementWidth, elementHeight } = mouse;\n    if (\n      mouseX === null ||\n      mouseY === null ||\n      elementWidth === null ||\n      elementHeight === null\n    )\n      return false;\n    return pointInBounds(\n      boundsX / deviceScreen.width,\n      boundsY / deviceScreen.height,\n      boundsWidth / deviceScreen.width,\n      boundsHeight / deviceScreen.height,\n      mouseX / elementWidth,\n      mouseY / elementHeight\n    );\n  });\n  if (hoveredList.length === 0) return null;\n  return hoveredList.sort((a, b) => {\n    if (!a.bounds && !b.bounds) return 0;\n    if (!a.bounds) return 1;\n    if (!b.bounds) return -1;\n    return a.bounds.width * a.bounds.height - b.bounds.width * b.bounds.height;\n  })[0];\n};\n\nexport const AnnotatedScreenshot = ({\n  annotationsEnabled = true,\n}: {\n  annotationsEnabled?: boolean;\n}) => {\n  const {\n    deviceScreen,\n    hoveredElement,\n    inspectedElement,\n    setInspectedElement,\n    setFooterHint,\n    setHoveredElement,\n  } = useDeviceContext();\n  const ref = useRef(null);\n  const [hasMouseLeft, setHasMouseLeft] = useState<boolean>(false);\n  const mouse = useMouse(ref, { enterDelay: 100, leaveDelay: 100 });\n\n  const getMouseHint = (mouse: MousePosition): string | null => {\n    if (\n      typeof mouse.x !== \"number\" ||\n      typeof mouse.y !== \"number\" ||\n      typeof mouse.elementWidth !== \"number\" ||\n      typeof mouse.elementHeight !== \"number\"\n    ) {\n      return null;\n    }\n    const x = toPercent(mouse.x, mouse.elementWidth);\n    const y = toPercent(mouse.y, mouse.elementHeight);\n    return `${x}, ${y}`;\n  };\n\n  const getElementHint = useCallback(\n    (element: UIElement): string => {\n      if (!deviceScreen) {\n        return `0%, 0%`;\n      }\n      if (element.resourceId) return element.resourceId;\n      if (element.text) return element.text;\n      if (!element.bounds) return \"\";\n      const cx = toPercent(\n        element.bounds.x + element.bounds.width / 2,\n        deviceScreen.width\n      );\n      const cy = toPercent(\n        element.bounds.y + element.bounds.height / 2,\n        deviceScreen.height\n      );\n      return `${cx}, ${cy}`;\n    },\n    [deviceScreen]\n  );\n\n  const onHover = useCallback(\n    (element: UIElement | null, mouse: MousePosition | null) => {\n      const mouseHint = mouse == null ? null : getMouseHint(mouse);\n      const elementHint = element == null ? null : getElementHint(element);\n      setFooterHint(elementHint || mouseHint);\n      setHoveredElement(element?.id ? element : null);\n    },\n    [getElementHint, setFooterHint, setHoveredElement] // This is where you put dependencies for the onHover function\n  );\n\n  useEffect(() => {\n    if (mouse.isOver) {\n      setHasMouseLeft(false);\n      if (annotationsEnabled) {\n        const hoveredElement = getHoveredElement(deviceScreen, mouse);\n        onHover(hoveredElement, mouse);\n      } else {\n        onHover(null, mouse);\n      }\n    } else if (!hasMouseLeft) {\n      setHasMouseLeft(true);\n      onHover(null, null);\n    }\n  }, [deviceScreen, mouse, annotationsEnabled, hasMouseLeft, onHover]);\n\n  const createAnnotation = (element: UIElement) => {\n    let state: AnnotationState = \"default\";\n    if (inspectedElement?.id === element?.id) {\n      state = \"selected\";\n    } else if (inspectedElement !== null) {\n      state = \"hidden\";\n    } else if (hoveredElement?.id === element?.id) {\n      state = \"hovered\";\n    }\n    return (\n      <Annotation\n        key={element.id}\n        element={element}\n        deviceWidth={deviceScreen?.width || 0}\n        deviceHeight={deviceScreen?.height || 0}\n        state={state}\n        onClick={() => {\n          if (inspectedElement) {\n            setInspectedElement(null);\n          } else {\n            setInspectedElement(element);\n          }\n        }}\n      />\n    );\n  };\n\n  const focusedElement = inspectedElement || hoveredElement;\n\n  const createCrosshairs = () => {\n    if (annotationsEnabled || !mouse.isOver) {\n      const bounds = focusedElement?.bounds;\n      if (!bounds || !deviceScreen) return null;\n      const { x, y, width, height } = bounds;\n      const cx = (x + width / 2) / deviceScreen.width;\n      const cy = (y + height / 2) / deviceScreen.height;\n      const color =\n        focusedElement === inspectedElement ? \"bg-pink-400\" : \"bg-blue-400\";\n      return <Crosshairs cx={cx} cy={cy} color={color} />;\n    } else {\n      if (mouse.x && mouse.y && mouse.elementWidth && mouse.elementHeight) {\n        return (\n          <Crosshairs\n            cx={mouse.x / mouse.elementWidth}\n            cy={mouse.y / mouse.elementHeight}\n            color=\"bg-blue-400\"\n          />\n        );\n      } else {\n        return null;\n      }\n    }\n  };\n\n  return (\n    <div\n      ref={ref}\n      className=\"relative overflow-hidden\"\n      style={{\n        aspectRatio: deviceScreen\n          ? deviceScreen.width / deviceScreen.height\n          : 1,\n      }}\n      onClick={() => {\n        if (inspectedElement) {\n          setInspectedElement(null);\n        }\n      }}\n    >\n      <img\n        className=\"h-full pointer-events-none select-none\"\n        src={deviceScreen?.screenshot || undefined}\n        alt=\"screenshot\"\n      />\n      {createCrosshairs()}\n      {(annotationsEnabled || !mouse.isOver) && (\n        <div className=\"w-full h-full\">\n          {deviceScreen?.elements.map(createAnnotation)}\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "maestro-studio/web/src/components/device-and-device-elements/BrowserActionBar.tsx",
    "content": "import React, {useLayoutEffect} from \"react\";\nimport {twMerge} from \"tailwind-merge\";\nimport {Input} from \"../design-system/input\";\n\nconst GlobeIcon = ({ className }: { className?: string }) => (\n  <svg\n    className={className}\n    width=\"24\"\n    height=\"24\"\n    viewBox=\"0 0 256 256\"\n  >\n    <path\n      fill=\"currentColor\"\n      d=\"M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm88,104a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48ZM40,128a87.61,87.61,0,0,1,3.33-24H81.84a157.44,157.44,0,0,0,0,48H43.33A87.61,87.61,0,0,1,40,128ZM154,88H102a115.11,115.11,0,0,1,26-45A115.27,115.27,0,0,1,154,88Zm52.33,0H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM107.59,42.4A135.28,135.28,0,0,0,85.29,88H49.63A88.29,88.29,0,0,1,107.59,42.4ZM49.63,168H85.29a135.28,135.28,0,0,0,22.3,45.6A88.29,88.29,0,0,1,49.63,168Zm98.78,45.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z\"></path>\n  </svg>\n);\n\nconst BrowserActionBar = ({currentUrl, onUrlUpdated, isLoading}: {\n  currentUrl?: string,\n  onUrlUpdated: (url: string) => void,\n  isLoading: boolean\n}) => {\n  const [isEditing, setIsEditing] = React.useState(false)\n  const [editedUrl, setEditedUrl] = React.useState(currentUrl)\n  useLayoutEffect(() => {\n    if (!isEditing && !isLoading) {\n      setEditedUrl(currentUrl)\n    }\n  }, [isLoading, isEditing, currentUrl]);\n  return (\n    <div className=\"w-full relative\">\n      <div className=\"inset-y-0 absolute flex items-center px-1.5\">\n        <GlobeIcon className=\"text-gray-300\" />\n      </div>\n      <Input\n        className={twMerge(\n          \"w-full pl-8 pr-1 py-0.5 rounded-full border-2 bg-slate-50 dark:bg-gray-800\",\n          isLoading && \"bg-gray-100\",\n        )}\n        size=\"sm\"\n        disabled={isLoading}\n        value={(isEditing || isLoading) ? editedUrl : currentUrl}\n        onChange={(e) => setEditedUrl(e.target.value)}\n        onFocus={() => setIsEditing(true)}\n        onBlur={() => setIsEditing(false)}\n        onKeyDown={(e) => {\n          if (e.key === 'Enter' && isEditing && editedUrl) {\n            onUrlUpdated(editedUrl);\n            e.currentTarget.blur();\n            setIsEditing(false);\n          }\n        }}\n      />\n    </div>\n  )\n}\n\nexport default BrowserActionBar"
  },
  {
    "path": "maestro-studio/web/src/components/device-and-device-elements/DeviceWrapperAspectRatio.tsx",
    "content": "import { useEffect, useRef, useState, ReactNode } from \"react\";\nimport { useDeviceContext } from \"../../context/DeviceContext\";\n\ninterface AspectRatioContainerProps\n  extends React.HTMLAttributes<HTMLDivElement> {\n  children: ReactNode;\n}\n\n/**\n * This component is for make it work on different browsers.\n * Safari doesnt change aspect ratio even with max width, which cause overflow\n * So created this wrapper div that will calculate the width dynamically and then we can place aspect ratio inside it\n */\nconst DeviceWrapperAspectRatio = ({\n  children,\n  ...rest\n}: AspectRatioContainerProps) => {\n  const { deviceScreen } = useDeviceContext();\n  const [width, setWidth] = useState<number>(0);\n  const containerRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const updateWidth = () => {\n      if (containerRef.current) {\n        const { height } = containerRef.current.getBoundingClientRect();\n        const newWidth = height * (deviceScreen!.width / deviceScreen!.height);\n        setWidth(newWidth);\n      }\n    };\n    updateWidth();\n    window.addEventListener(\"resize\", updateWidth);\n    return () => {\n      window.removeEventListener(\"resize\", updateWidth);\n    };\n  }, [deviceScreen]);\n\n  return (\n    <div ref={containerRef} className=\"relative flex-1\">\n      <div\n        className=\"h-full max-w-full absolute top-0 left-0 right-0 bottom-0 mx-auto grid place-items-center\"\n        style={{ width }}\n        {...rest}\n      >\n        {children}\n      </div>\n    </div>\n  );\n};\n\nexport default DeviceWrapperAspectRatio;\n"
  },
  {
    "path": "maestro-studio/web/src/components/device-and-device-elements/ElementsPanel.tsx",
    "content": "import React, { useEffect, useMemo, useRef, useState } from \"react\";\nimport _ from \"lodash\";\nimport { Button } from \"../design-system/button\";\nimport { Input } from \"../design-system/input\";\nimport { UIElement } from \"../../helpers/models\";\nimport clsx from \"clsx\";\nimport Draggable from \"react-draggable\";\nimport { useDeviceContext } from \"../../context/DeviceContext\";\n\nconst compare = (a: string | undefined, b: string | undefined) => {\n  if (!a) return b ? 1 : 0;\n  if (!b) return -1;\n  return a.localeCompare(b);\n};\n\ninterface ElementsPanelProps {\n  closePanel: () => void;\n}\n\nexport default function ElementsPanel({ closePanel }: ElementsPanelProps) {\n  const {\n    deviceScreen,\n    hoveredElement,\n    setHoveredElement,\n    setInspectedElement,\n    setFooterHint,\n  } = useDeviceContext();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const previousSortedElementsRef = useRef<UIElement[] | null>(null);\n  const elementRefs = useRef<(HTMLElement | null)[]>([]);\n  const [query, setQuery] = useState<string>(\"\");\n  const [width, setWidth] = useState(\n    localStorage.sidebarWidth ? parseInt(localStorage.sidebarWidth) : 264\n  );\n  const minWidth = 264;\n  const maxWidth = 560;\n\n  useEffect(() => {\n    inputRef.current?.focus();\n  }, []);\n\n  useEffect(() => {\n    return () => {\n      localStorage.setItem(\"sidebarWidth\", width.toString());\n    };\n  }, [width]);\n\n  const handleDrag = (e: any, ui: any) => {\n    let newWidth = width + ui.deltaX;\n    if (newWidth < minWidth) {\n      newWidth = minWidth;\n    } else if (newWidth > maxWidth) {\n      newWidth = maxWidth;\n    }\n    setWidth(newWidth);\n  };\n\n  useEffect(() => {\n    inputRef.current?.focus();\n  }, []);\n\n  const sortedElements: UIElement[] = useMemo(() => {\n    if (!deviceScreen) {\n      return [];\n    }\n    const filteredElements = deviceScreen.elements.filter((element) => {\n      if (\n        !element.text &&\n        !element.resourceId &&\n        !element.hintText &&\n        !element.accessibilityText\n      )\n        return false;\n\n      return (\n        !query ||\n        element.text?.toLowerCase().includes(query.toLowerCase()) ||\n        element.resourceId?.toLowerCase().includes(query.toLowerCase()) ||\n        element.hintText?.toLowerCase().includes(query.toLowerCase()) ||\n        element.accessibilityText?.toLowerCase().includes(query.toLowerCase())\n      );\n    });\n\n    return filteredElements.sort((a, b) => {\n      const aTextPrefixMatch =\n        query && a.text?.toLowerCase().startsWith(query.toLowerCase());\n      const bTextPrefixMatch =\n        query && b.text?.toLowerCase().startsWith(query.toLowerCase());\n\n      if (aTextPrefixMatch && !bTextPrefixMatch) return -1;\n      if (bTextPrefixMatch && !aTextPrefixMatch) return 1;\n\n      return compare(a.text, b.text) || compare(a.resourceId, b.resourceId);\n    });\n  }, [query, deviceScreen]);\n\n  /**\n   * Change hovered element in case sortedElements chang\n   */\n  useEffect(() => {\n    // Check if the contents of sortedElements have changed\n    const didElementsChange = !_.isEqual(\n      previousSortedElementsRef.current,\n      sortedElements\n    );\n\n    if (didElementsChange) {\n      setHoveredElement(sortedElements[0]);\n    }\n\n    // Update the ref with the current value for the next comparison\n    previousSortedElementsRef.current = sortedElements;\n  }, [sortedElements, setHoveredElement]);\n\n  /**\n   * Keyboard Events\n   */\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n    const currentIndex = hoveredElement\n      ? sortedElements.findIndex((el) => el.id === hoveredElement.id)\n      : -1;\n\n    let newIndex = -1;\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault();\n        newIndex =\n          currentIndex + 1 >= sortedElements.length ? 0 : currentIndex + 1;\n        setHoveredElement(sortedElements[newIndex]);\n        break;\n      case \"ArrowUp\":\n        event.preventDefault();\n        newIndex =\n          currentIndex <= 0 ? sortedElements.length - 1 : currentIndex - 1;\n        setHoveredElement(sortedElements[newIndex]);\n        break;\n      case \"Enter\":\n        event.preventDefault();\n        if (hoveredElement) {\n          setInspectedElement(hoveredElement);\n        }\n        return; // Add a return here to avoid scrolling on Enter\n      default:\n        break;\n    }\n    // Scroll the new hovered element into view\n    elementRefs.current[newIndex]?.scrollIntoView({\n      behavior: \"smooth\",\n      block: \"nearest\",\n    });\n  };\n\n  return (\n    <div\n      style={{\n        width: width,\n        minWidth: width,\n        maxWidth: width,\n      }}\n      className=\"flex flex-col relative h-full overflow-visible z-10 border-r border-slate-200 dark:border-slate-800\"\n    >\n      <Button\n        onClick={closePanel}\n        variant=\"tertiary\"\n        icon=\"RiCloseLine\"\n        className=\"rounded-full absolute top-6 -right-4 z-10\"\n      />\n      <div className=\"px-8 py-6 border-b border-slate-200 dark:border-slate-800\">\n        <Input\n          ref={inputRef}\n          onChange={(e) => setQuery(e.target.value)}\n          onKeyDown={handleKeyDown}\n          size=\"sm\"\n          leftIcon=\"RiSearchLine\"\n          leftIconClassName=\"absolute left-1.5 top-1/2 transform -translate-y-1/2 pointer-events-none\"\n          inputClassName=\"px-6\"\n          placeholder=\"Text or Id\"\n          className=\"relative w-full rounded-md p-0\"\n        />\n      </div>\n      <div className=\"px-8 py-6 flex-grow overflow-y-scroll overflow-x-hidden hide-scrollbar\">\n        {sortedElements.map((item: UIElement, index: number) => {\n          const onClick = () => setInspectedElement(item);\n          const onMouseEnter = () => {\n            setHoveredElement(item);\n            setFooterHint(item?.resourceId || item?.text || null);\n          };\n          const onMouseLeave = () => {\n            setFooterHint(null);\n            if (hoveredElement?.id === item.id) {\n              setHoveredElement(null);\n            }\n          };\n          return (\n            <div\n              key={item.id}\n              ref={(ref) => (elementRefs.current[index] = ref)}\n            >\n              {item.resourceId !== \"\" && item.resourceId !== \" \" && (\n                <ElementListItem\n                  onClick={onClick}\n                  onMouseEnter={onMouseEnter}\n                  onMouseLeave={onMouseLeave}\n                  isHovered={hoveredElement?.id === item?.id}\n                  query={query as string}\n                  text={item.resourceId as string}\n                  elementType=\"id\"\n                />\n              )}\n              {item.text !== \"\" && item.text !== \" \" && (\n                <ElementListItem\n                  onClick={onClick}\n                  isHovered={hoveredElement?.id === item?.id}\n                  onMouseEnter={onMouseEnter}\n                  onMouseLeave={onMouseLeave}\n                  query={query as string}\n                  text={item.text as string}\n                  elementType=\"text\"\n                />\n              )}\n              {item.hintText !== \"\" && item.hintText !== \" \" && (\n                <ElementListItem\n                  onClick={onClick}\n                  isHovered={hoveredElement?.id === item?.id}\n                  onMouseEnter={onMouseEnter}\n                  onMouseLeave={onMouseLeave}\n                  query={query as string}\n                  text={item.hintText as string}\n                  elementType=\"hintText\"\n                />\n              )}\n              {item.accessibilityText !== \"\" &&\n                item.accessibilityText !== \" \" && (\n                  <ElementListItem\n                    onClick={onClick}\n                    isHovered={hoveredElement?.id === item?.id}\n                    onMouseEnter={onMouseEnter}\n                    onMouseLeave={onMouseLeave}\n                    query={query as string}\n                    text={item.accessibilityText as string}\n                    elementType=\"accessibilityText\"\n                  />\n                )}\n            </div>\n          );\n        })}\n      </div>\n      <Draggable axis=\"x\" onDrag={handleDrag} position={{ x: 0, y: 0 }}>\n        <div\n          style={{\n            cursor:\n              (width === maxWidth && \"w-resize\") ||\n              (width === minWidth && \"e-resize\") ||\n              \"ew-resize\",\n          }}\n          className=\"w-2 absolute top-0 -right-1 bottom-0 \"\n        />\n      </Draggable>\n    </div>\n  );\n}\n\ninterface ElementListItemProps\n  extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n  query: string;\n  text: string;\n  elementType: \"id\" | \"text\" | \"hintText\" | \"accessibilityText\";\n  isHovered: boolean;\n}\n\nconst ElementListItem = ({\n  query,\n  text,\n  elementType,\n  isHovered,\n  ...rest\n}: ElementListItemProps) => {\n  if (!text) {\n    return null;\n  }\n\n  const regEx = new RegExp(`(${query.toString()})`, \"gi\");\n  const textParts: string[] = text.split(regEx);\n\n  return (\n    <button\n      className={clsx(\n        \"px-2 py-2 rounded-md transition w-full text-sm font-bold text-left\",\n        isHovered\n          ? \"text-blue-500 bg-slate-100 dark:bg-slate-800\"\n          : \"bg-transparent\"\n      )}\n      style={{ overflowWrap: \"anywhere\" }}\n      {...rest}\n    >\n      {textParts.map((part, index) => (\n        <>\n          {index % 2 === 0 ? (\n            <span>{part}</span>\n          ) : (\n            <span className=\"text-purple-500 dark:text-purple-400\">\n              {query}\n            </span>\n          )}\n        </>\n      ))}\n      <span className=\"text-gray-400 whitespace-nowrap\"> • {elementType}</span>\n    </button>\n  );\n};\n"
  },
  {
    "path": "maestro-studio/web/src/components/device-and-device-elements/InteractableDevice.tsx",
    "content": "import React, { MouseEventHandler, useState } from \"react\";\nimport { DivProps } from \"../../helpers/models\";\nimport { AnnotatedScreenshot } from \"./AnnotatedScreenshot\";\nimport { isHotkeyPressed } from \"react-hotkeys-hook\";\nimport { useDeviceContext } from \"../../context/DeviceContext\";\nimport clsx from \"clsx\";\nimport { useRepl } from '../../context/ReplContext';\n\nconst useMetaKeyDown = () => {\n  return isHotkeyPressed(\"meta\");\n};\n\nexport default function InteractableDevice({\n  enableGestureControl = true,\n}: {\n  enableGestureControl?: boolean;\n}) {\n  const { deviceScreen } = useDeviceContext();\n  const { runCommandYaml } = useRepl();\n  const metaKeyDown = useMetaKeyDown();\n\n  const onTapGesture = async (x: number, y: number) => {\n    await runCommandYaml(`- tapOn:\n    point: \"${Math.round(100 * x)}%,${Math.round(100 * y)}%\"`);\n  };\n\n  const onSwipeGesture = async (\n    startX: number,\n    startY: number,\n    endX: number,\n    endY: number,\n    duration: number\n  ) => {\n    const startXPercent = Math.round(startX * 100);\n    const startYPercent = Math.round(startY * 100);\n    const endXPercent = Math.round(endX * 100);\n    const endYPercent = Math.round(endY * 100);\n    await runCommandYaml(`\n      swipe:\n        start: \"${startXPercent}%,${startYPercent}%\"\n        end: \"${endXPercent}%,${endYPercent}%\"\n        duration: ${Math.round(duration)}\n    `);\n  };\n\n  return (\n    <GestureDiv\n      className={clsx(\n        \"rounded-lg overflow-hidden w-full\",\n        enableGestureControl ? \"border-2 box-content border-pink-500\" : \"\"\n      )}\n      style={{\n        aspectRatio: deviceScreen\n          ? deviceScreen.width / deviceScreen.height\n          : 1,\n      }}\n      onTap={onTapGesture}\n      onSwipe={onSwipeGesture}\n      gesturesEnabled={enableGestureControl ? metaKeyDown : false}\n    >\n      <AnnotatedScreenshot\n        annotationsEnabled={enableGestureControl ? !metaKeyDown : true}\n      />\n    </GestureDiv>\n  );\n}\n\ntype GestureEvent = {\n  x: number;\n  y: number;\n  timestamp: number;\n};\n\nconst createGestureEvent = (\n  e: React.MouseEvent<HTMLDivElement, MouseEvent>\n): GestureEvent => {\n  const { top, left } = e.currentTarget.getBoundingClientRect();\n  return {\n    x: e.pageX - left,\n    y: e.pageY - top,\n    timestamp: e.timeStamp,\n  };\n};\n\nconst GestureDiv = ({\n  onTap,\n  onSwipe,\n  gesturesEnabled = true,\n  ...rest\n}: {\n  onTap: (x: number, y: number) => void;\n  onSwipe: (\n    startX: number,\n    startY: number,\n    endX: number,\n    endY: number,\n    duration: number\n  ) => void;\n  gesturesEnabled?: boolean;\n} & DivProps) => {\n  const [start, setStart] = useState<GestureEvent>();\n\n  const onStart: MouseEventHandler<HTMLDivElement> = (e) => {\n    if (!gesturesEnabled) return;\n    setStart(createGestureEvent(e));\n  };\n\n  const onEnd: MouseEventHandler<HTMLDivElement> = (e) => {\n    if (!gesturesEnabled || !start) return;\n    const end = createGestureEvent(e);\n\n    const { width: clientWidth, height: clientHeight } =\n      e.currentTarget.getBoundingClientRect();\n\n    const duration = end.timestamp - start.timestamp;\n    const distance = Math.hypot(end.x - start.x, end.y - start.y);\n\n    if (duration < 100 || distance < 10) {\n      onTap(start.x / clientWidth, start.y / clientHeight);\n    } else {\n      onSwipe(\n        start.x / clientWidth,\n        start.y / clientHeight,\n        end.x / clientWidth,\n        end.y / clientHeight,\n        duration\n      );\n    }\n  };\n\n  const onCancel = () => {\n    setStart(undefined);\n  };\n\n  return (\n    <div\n      {...rest}\n      onMouseDown={onStart}\n      onMouseUp={onEnd}\n      onMouseLeave={onCancel}\n    />\n  );\n};\n"
  },
  {
    "path": "maestro-studio/web/src/components/device-and-device-elements/SelectedElementViewer.tsx",
    "content": "import { useEffect, useRef } from \"react\";\nimport InteractableDevice from \"./InteractableDevice\";\nimport { UIElement } from \"../../helpers/models\";\nimport { useDeviceContext } from \"../../context/DeviceContext\";\nimport { Button } from \"../design-system/button\";\nimport copy from \"copy-to-clipboard\";\n\nexport default function SelectedElementViewer({\n  uiElement,\n}: {\n  uiElement: UIElement | null;\n}) {\n  const { deviceScreen } = useDeviceContext();\n  const defaultWrapperSize = 320;\n  const containerElementRef = useRef<HTMLDivElement>(null);\n  const deviceRef = useRef<HTMLDivElement>(null);\n\n  /**\n   * Set The element size and position based on Element selected\n   */\n  useEffect(() => {\n    if (!uiElement || !deviceScreen) {\n      return;\n    }\n\n    if (deviceRef.current && uiElement.bounds) {\n      // Set the wrapper height to be used & increase height if element is big\n      let currentWrapperHeight = defaultWrapperSize;\n      const maxHeight =\n        defaultWrapperSize * (deviceScreen.height / deviceScreen.width) - 4;\n      // Chromium Screen (Smaller height)\n      if (deviceScreen.height < deviceScreen.width) {\n        containerElementRef.current &&\n          (containerElementRef.current.style.height = `${maxHeight}px`);\n        return;\n      }\n      // Selected element is large in height\n      else if (uiElement.bounds.height > deviceScreen.width) {\n        currentWrapperHeight = Math.min(\n          defaultWrapperSize * (uiElement.bounds.height / deviceScreen.width) +\n            5000,\n          maxHeight\n        );\n        containerElementRef.current &&\n          (containerElementRef.current.style.height = `${currentWrapperHeight}px`);\n      }\n      // Current Element Position Ratio\n      const selectedRatio =\n        (2 * uiElement.bounds.y + uiElement.bounds.height) /\n        (2 * deviceScreen.height);\n      // Current Calculated top position for center\n      const currentTopPos =\n        currentWrapperHeight / 2 -\n        1 * deviceRef.current.clientHeight * selectedRatio;\n      // Max Value of top\n      const maxTopValue =\n        (-1 *\n          deviceRef.current.clientHeight *\n          (deviceScreen.height - deviceScreen.width)) /\n        deviceScreen.height;\n      // Min Value for top\n      const minTopValue = 0;\n      // Set position\n      deviceRef.current.style.top = `${Math.max(\n        Math.min(currentTopPos, minTopValue),\n        maxTopValue\n      )}px`;\n    }\n  }, [deviceScreen, uiElement]);\n\n  return (\n    <div\n      className=\"hidden md:block\"\n      style={{ width: defaultWrapperSize + \"px\" }}\n    >\n      <ElementHighInfo label=\"id\" value={uiElement?.resourceId} />\n      <ElementHighInfo label=\"text\" value={uiElement?.text} />\n      <div\n        style={{\n          minWidth: defaultWrapperSize + \"px\",\n        }}\n      >\n        <div\n          ref={containerElementRef}\n          style={{ height: defaultWrapperSize + \"px\" }}\n          className=\"relative overflow-hidden rounded-lg border border-black/20 dark:border-white/20\"\n        >\n          <div ref={deviceRef} className=\"absolute -top-1 -left-2 -right-2\">\n            <InteractableDevice enableGestureControl={false} />\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nconst ElementHighInfo = ({\n  label,\n  value,\n}: {\n  label: string;\n  value?: string;\n}) => {\n  const { setInspectedElement } = useDeviceContext();\n\n  const copyValue = () => {\n    if (value) {\n      copy(value);\n      setInspectedElement(null);\n    }\n  };\n\n  if (!value) {\n    return null;\n  }\n\n  return (\n    <div className=\"bg-gray-100 dark:bg-slate-800 pl-3 py-1 pr-1 rounded-lg mb-2 flex gap-3 items-start\">\n      <p className=\"text-sm py-1 min-w-[32px]\">{label}:</p>\n      <p\n        className=\"text-sm font-semibold flex-grow py-1\"\n        style={{ lineBreak: \"anywhere\" }}\n      >\n        {value}\n      </p>\n      <Button\n        onClick={(e) => {\n          e.preventDefault();\n          e.stopPropagation();\n          copyValue();\n        }}\n        tabIndex={-1}\n        variant=\"quaternary\"\n        size=\"sm\"\n        icon=\"RiFileCopyLine\"\n      />\n    </div>\n  );\n};\n"
  },
  {
    "path": "maestro-studio/web/src/components/interact/InteractPageLayout.tsx",
    "content": "import React, { useState } from \"react\";\nimport clsx from \"clsx\";\n\nimport InteractableDevice from \"../device-and-device-elements/InteractableDevice\";\nimport ReplView from \"../commands/ReplView\";\nimport ActionModal from \"../device-and-device-elements/ActionModal\";\nimport { Button } from \"../design-system/button\";\nimport { CommandExample } from \"../../helpers/commandExample\";\nimport ElementsPanel from \"../device-and-device-elements/ElementsPanel\";\nimport DeviceWrapperAspectRatio from \"../device-and-device-elements/DeviceWrapperAspectRatio\";\nimport { useDeviceContext } from \"../../context/DeviceContext\";\nimport { Spinner } from \"../design-system/spinner\";\nimport { useRepl } from '../../context/ReplContext';\nimport { DeviceScreen } from \"../../helpers/models\";\nimport BrowserActionBar from \"../device-and-device-elements/BrowserActionBar\";\n\nconst InteractPageLayout = () => {\n  const {\n    isLoading,\n    deviceScreen,\n    footerHint,\n    setInspectedElement,\n    setCurrentCommandValue,\n  } = useDeviceContext();\n  const { runCommandYaml } = useRepl();\n\n  const [showElementsPanel, setShowElementsPanel] = useState<boolean>(false);\n  const [isUrlLoading, setIsUrlLoading] = useState<boolean>(false);\n\n  const onEdit = (example: CommandExample) => {\n    if (example.status === \"unavailable\") return;\n    setCurrentCommandValue(example.content.trim());\n    setInspectedElement(null);\n    // find textarea by id and focus on it if it exists\n    setTimeout(() => {\n      const textarea = document.getElementById(\"commandInputBox\");\n      if (textarea) {\n        textarea.focus();\n      }\n    }, 0);\n  };\n\n  const onRun = async (example: CommandExample) => {\n    if (example.status === \"unavailable\") return;\n    setInspectedElement(null);\n    await runCommandYaml(example.content);\n  };\n\n  const onUrlUpdated = (url: string) => {\n    setIsUrlLoading(true);\n    runCommandYaml(`openLink: ${url}`).finally(() => {\n      // Wait some time to update the url from the device screen\n      setTimeout(() => {\n        setIsUrlLoading(false);\n      }, 1000);\n    });\n  }\n\n  if (isLoading)\n    return (\n      <div className=\"flex items-center justify-center h-full\">\n        <Spinner size=\"32\" />\n      </div>\n    );\n\n  if (!deviceScreen) return null;\n\n  var widthClass = computeWidthClass(deviceScreen, showElementsPanel);\n\n  return (\n    <div className=\"flex h-full overflow-hidden\">\n      {showElementsPanel && (\n        <ElementsPanel closePanel={() => setShowElementsPanel(false)} />\n      )}\n      <div\n        className={clsx(\n          \"px-8 pt-6 pb-7 bg-white dark:bg-slate-900 relative gap-4 flex flex-col\",\n          widthClass\n        )}\n      >\n        {!showElementsPanel && (\n          <Button\n            variant=\"secondary\"\n            leftIcon=\"RiSearchLine\"\n            className=\"w-full min-h-[32px]\"\n            onClick={() => setShowElementsPanel(true)}\n          >\n            Search Elements with Text or Id\n          </Button>\n        )}\n        {deviceScreen?.platform === 'WEB' && (\n          <BrowserActionBar\n            currentUrl={deviceScreen.url}\n            onUrlUpdated={onUrlUpdated}\n            isLoading={isUrlLoading}\n          />\n        )}\n        <DeviceWrapperAspectRatio>\n          <InteractableDevice />\n        </DeviceWrapperAspectRatio>\n        <p className=\"text-xs text-center\">\n          Hold CMD (⌘) down to freely tap and swipe on the device screen\n        </p>\n        {footerHint && (\n          <div className=\"absolute bottom-0 left-0 right-0 text-xs text-center bg-slate-100 dark:bg-slate-800 dark:text-white h-auto text-slate-800 overflow-hidden\">\n            {footerHint}\n          </div>\n        )}\n      </div>\n      <div className=\"flex flex-col flex-1 h-full overflow-hidden border-l border-slate-200 dark:border-slate-800 relative dark:bg-slate-900 dark:text-white\">\n        <ReplView />\n      </div>\n      <ActionModal onEdit={onEdit} onRun={onRun} />\n    </div>\n  );\n};\n\nfunction computeWidthClass(deviceScreen: DeviceScreen, showElementsPanel: boolean) {\n  const wideDevice = deviceScreen.width > deviceScreen.height;\n\n  var widthModifier = \"basis-1/2\";\n  if (showElementsPanel) {\n    widthModifier += \" max-w-[33.333333%]\";\n\n    if (wideDevice) {\n      widthModifier += \" lg:basis-5/12\";\n    }\n  } else {\n    if (wideDevice) {\n      widthModifier += \" max-w-[80%]\";\n    } else {\n      widthModifier += \" lg:basis-4/12 max-w-[41.666667%]\";\n    }\n  }\n\n  return widthModifier;\n}\n\nexport default InteractPageLayout;\n\n"
  },
  {
    "path": "maestro-studio/web/src/context/AuthContext.tsx",
    "content": "import React, { ReactNode, createContext, useContext } from \"react\";\nimport { API } from \"../api/api\";\nimport _ from \"lodash\";\n\ninterface AuthProviderProps {\n  children: ReactNode;\n}\n\ninterface AuthState {\n  authToken: string | null | undefined;\n  openAiToken: string | null | undefined;\n  refetchAuth: () => void;\n  deleteOpenAiToken: () => void;\n}\n\nconst AuthContext = createContext<AuthState | undefined>(undefined);\n\nexport const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {\n  const { data, mutate: refetchAuth } = API.useAuth();\n\n  const deleteOpenAiToken = async () => {\n    await API.deleteOpenAiToken();\n    refetchAuth();\n  };\n\n  return (\n    <AuthContext.Provider\n      value={{\n        authToken: _.get(data, \"authToken\", undefined),\n        openAiToken: _.get(data, \"openAiToken\", undefined),\n        refetchAuth,\n        deleteOpenAiToken,\n      }}\n    >\n      {children}\n    </AuthContext.Provider>\n  );\n};\n\nexport const useAuth = (): AuthState => {\n  const context = useContext(AuthContext);\n  if (context === undefined) {\n    throw new Error(\"useAuth must be used within an AuthProvider\");\n  }\n  return context;\n};\n"
  },
  {
    "path": "maestro-studio/web/src/context/DeviceContext.tsx",
    "content": "import {\n  createContext,\n  useContext,\n  ReactNode,\n  useState,\n  useEffect,\n  useRef,\n} from \"react\";\nimport { UIElement, DeviceScreen } from \"../helpers/models\";\nimport { API } from \"../api/api\";\nimport _ from \"lodash\";\n\ninterface DeviceContextType {\n  isLoading: boolean;\n  hoveredElement: UIElement | null;\n  setHoveredElement: (element: UIElement | null) => void;\n  inspectedElement: UIElement | null;\n  setInspectedElement: (id: UIElement | null) => void;\n  deviceScreen: DeviceScreen | undefined;\n  footerHint: string | null;\n  setFooterHint: (id: string | null) => void;\n  currentCommandValue: string;\n  setCurrentCommandValue: (id: string) => void;\n}\n\nconst DeviceContext = createContext<DeviceContextType | undefined>(undefined);\n\ninterface DeviceProviderProps {\n  children: ReactNode;\n  defaultInspectedElement?: UIElement;\n}\n\nexport const DeviceProvider: React.FC<DeviceProviderProps> = ({\n  children,\n  defaultInspectedElement = null, // Default value\n}) => {\n  const { deviceScreen: fetchedDeviceScreen, error } = API.useDeviceScreen();\n\n  const prevDeviceScreenRef = useRef<DeviceScreen | undefined>();\n  const [deviceScreenState, setDeviceScreenState] =\n    useState<DeviceScreen | undefined>();\n  const [isLoading, setIsLoading] = useState(true);\n  const [hoveredElement, setHoveredElement] = useState<UIElement | null>(null);\n  const [inspectedElement, setInspectedElement] = useState<UIElement | null>(\n    defaultInspectedElement\n  );\n  const [footerHint, setFooterHint] = useState<string | null>(null);\n  const [currentCommandValue, setCurrentCommandValue] = useState<string>(\"\");\n\n  /**\n   * Update device only when it is changed.\n   * This is to stop unnecessary renders inside the app\n   */\n  useEffect(() => {\n    if (fetchedDeviceScreen) {\n      // Check if other device data (besides screenshot) has changed before setting it\n      const { screenshot: screenshotFetched, ...restOfFetchedData } =\n        fetchedDeviceScreen;\n      let restOfPrevData = {};\n      if (prevDeviceScreenRef.current) {\n        const { screenshot: __, ...restData } = prevDeviceScreenRef.current;\n        restOfPrevData = restData;\n      }\n      if (!_.isEqual(restOfPrevData, restOfFetchedData)) {\n        setDeviceScreenState(fetchedDeviceScreen);\n        prevDeviceScreenRef.current = fetchedDeviceScreen;\n      }\n    }\n  }, [fetchedDeviceScreen]);\n\n  /**\n   * It currently loading, set loading false when deviceScreenState or error is received\n   */\n  useEffect(() => {\n    if (isLoading) {\n      if (deviceScreenState || error) {\n        setIsLoading(false);\n      }\n    }\n  }, [deviceScreenState, error, isLoading]);\n\n  return (\n    <DeviceContext.Provider\n      value={{\n        isLoading,\n        hoveredElement,\n        setHoveredElement,\n        inspectedElement,\n        setInspectedElement,\n        footerHint,\n        setFooterHint,\n        deviceScreen: deviceScreenState,\n        currentCommandValue,\n        setCurrentCommandValue,\n      }}\n    >\n      {children}\n    </DeviceContext.Provider>\n  );\n};\n\nexport const useDeviceContext = () => {\n  const context = useContext(DeviceContext);\n  if (context === undefined) {\n    throw new Error(\"useDeviceContext must be used within a DeviceProvider\");\n  }\n  return context;\n};\n"
  },
  {
    "path": "maestro-studio/web/src/context/ReplContext.tsx",
    "content": "import React, { createContext, ReactNode, useContext, useEffect, useState } from 'react';\nimport { Repl, ReplCommand, ReplCommandStatus } from '../helpers/models';\nimport { v4 as uuidv4 } from 'uuid';\nimport { API } from '../api/api';\nimport YAML from 'yaml';\n\nconst initialState: Repl = {\n  commands: []\n};\n\nconst ReplContext = createContext<{\n  repl: Repl;\n  setRepl: React.Dispatch<React.SetStateAction<Repl>>;\n  errorMessage: string | null;\n  setErrorMessage: React.Dispatch<React.SetStateAction<string | null>>;\n}>({ repl: initialState, setRepl: () => {}, errorMessage: null, setErrorMessage: () => {} });\n\nconst restoreRepl = () => {\n  const savedRepl = localStorage.getItem('repl');\n  if (!savedRepl) return initialState\n  return JSON.parse(savedRepl, (key, value) => {\n    if (key === 'command' && !value) return []\n    return value\n  })\n}\n\nexport const ReplProvider = ({ children }: {\n  children: ReactNode\n}) => {\n  const [repl, setRepl] = useState<Repl>(() => restoreRepl());\n  const [errorMessage, setErrorMessage] = useState<string | null>(null);\n\n  useEffect(() => {\n    localStorage.setItem('repl', JSON.stringify(repl));\n  }, [repl]);\n\n  return (\n    <ReplContext.Provider value={{ repl, setRepl, errorMessage, setErrorMessage }}>\n      {children}\n    </ReplContext.Provider>\n  );\n};\n\nexport const useRepl = () => {\n  const context = useContext(ReplContext);\n\n  const { repl, setRepl, errorMessage, setErrorMessage } = context;\n\n  const setCommandStatus = (id: string, commandStatus: ReplCommandStatus) => {\n    setRepl(prevRepl => ({\n      ...prevRepl,\n      commands: prevRepl.commands.map(command => command.id === id ? {\n        ...command,\n        status: commandStatus,\n      } : command),\n    }));\n  };\n\n  const deleteCommands = (ids: string[]) => {\n    setRepl(prevRepl => {\n      const newCommands = prevRepl.commands.filter(command => !ids.includes(command.id));\n      return {\n        ...prevRepl,\n        commands: newCommands,\n      };\n    });\n  }\n\n  const reorderCommands = (ids: string[]) => {\n    setRepl(prevRepl => {\n      const commandMap = prevRepl.commands.reduce((acc, command) => acc.set(command.id, command), new Map());\n      const newCommands: ReplCommand[] = [];\n      ids.forEach(id => {\n        const command = commandMap.get(id);\n        if (command) newCommands.push(command);\n      });\n      prevRepl.commands.forEach(command => {\n        if (!ids.includes(command.id)) {\n          newCommands.push(command);\n        }\n      });\n      return {\n        ...prevRepl,\n        commands: newCommands,\n      };\n    });\n  };\n\n  const runCommand = async (command: ReplCommand): Promise<boolean> => {\n    setCommandStatus(command.id, 'running');\n    try {\n      await API.runCommand(command.yaml);\n      setCommandStatus(command.id, 'success');\n      return true;\n    } catch (e: any) {\n      setCommandStatus(command.id, 'error');\n      return false;\n    }\n  };\n\n  const runCommands = async (commands: ReplCommand[]): Promise<boolean> => {\n    commands.forEach(command => setCommandStatus(command.id, 'pending'));\n    let abort = false;\n    for (const command of commands) {\n      if (abort) {\n        setCommandStatus(command.id, 'canceled');\n      } else {\n        const success = await runCommand(command);\n        if (!success) abort = true;\n      }\n    }\n    return !abort\n  }\n\n  const parseCommands = (yaml: string): ReplCommand[] => {\n    const parsed = YAML.parse(yaml);\n    const yamls = Array.isArray(parsed) ? parsed.map(o => YAML.stringify(o)) : [YAML.stringify(parsed)];\n    return yamls.map(yaml => ({\n      id: `${uuidv4()}`,\n      status: 'pending',\n      yaml,\n    }));\n  }\n\n  const runCommandYaml = async (yaml: string): Promise<boolean> => {\n    const commands = parseCommands(yaml);\n    for (const command of commands) {\n      try {\n        // Dry run to validate yaml\n        await API.runCommand(command.yaml, true);\n      } catch (e: any) {\n        setErrorMessage(e.message || 'Failed to run command');\n        return false;\n      }\n    }\n    setRepl(prevRepl => ({\n      ...prevRepl,\n      commands: [...prevRepl.commands, ...commands]\n    }))\n    return await runCommands(commands);\n  }\n\n  const runCommandIds = async (ids: string[]): Promise<boolean> => {\n    const commands = repl.commands.filter(command => ids.includes(command.id));\n    return await runCommands(commands);\n  }\n\n  return {\n    repl,\n    errorMessage,\n    setErrorMessage,\n    runCommandYaml,\n    runCommandIds,\n    deleteCommands,\n    reorderCommands,\n  };\n};\n\nexport default ReplContext;\n"
  },
  {
    "path": "maestro-studio/web/src/helpers/commandExample.ts",
    "content": "import { DeviceScreen, UIElement } from \"./models\";\nimport YAML from \"yaml\";\n\nconst YAML_STRINGIFY_OPTIONS: YAML.SchemaOptions = {\n  toStringDefaults: {\n    lineWidth: 0,\n    defaultKeyType: \"PLAIN\",\n    defaultStringType: \"QUOTE_DOUBLE\",\n  },\n};\n\nconst stringifyYaml = (value: any): string => {\n  return YAML.stringify(value, null, YAML_STRINGIFY_OPTIONS);\n};\n\nexport type CommandExample = {\n  status: \"available\" | \"unavailable\";\n  title: string;\n  content: string;\n  documentation: string;\n};\n\ntype Selector =\n  | {\n      title: string;\n      status: \"available\";\n      definition: any;\n      documentation?: string;\n    }\n  | {\n      title: string;\n      status: \"unavailable\";\n      message: string;\n      documentation?: string;\n    };\n\nconst toPercent = (n: number, total: number) =>\n  `${Math.round((100 * n) / total)}%`;\n\nconst getCoordinatesSelector = (\n  deviceWidth: number,\n  deviceHeight: number,\n  uiElement: UIElement\n): Selector => {\n  const bounds = uiElement.bounds || { x: 0, y: 0, width: 0, height: 0 };\n  const cx = toPercent(bounds.x + bounds.width / 2, deviceWidth);\n  const cy = toPercent(bounds.y + bounds.height / 2, deviceHeight);\n  return {\n    status: \"available\",\n    title: \"Coordinates\",\n    definition: { point: `${cx},${cy}` },\n  };\n};\n\nconst getSelectors = (\n  uiElement: UIElement,\n  deviceScreen: DeviceScreen\n): Selector[] => {\n  const selectors: Selector[] = [];\n  if (uiElement.resourceId) {\n    if (typeof uiElement.resourceIdIndex === \"number\") {\n      selectors.push({\n        title: \"Resource Id\",\n        status: \"available\",\n        definition: {\n          id: uiElement.resourceId,\n          index: uiElement.resourceIdIndex,\n        },\n      });\n    } else {\n      selectors.push({\n        title: \"Resource Id\",\n        status: \"available\",\n        definition: {\n          id: uiElement.resourceId,\n        },\n      });\n    }\n  } else {\n    const elementsWithSameBounds = deviceScreen.elements.filter((element) => {\n      if (element.resourceId === null || element.resourceId === undefined)\n        return false;\n      return (\n        element.bounds?.width === uiElement.bounds?.width &&\n        element.bounds?.height === uiElement.bounds?.height\n      );\n    });\n    if (elementsWithSameBounds.length > 0) {\n      elementsWithSameBounds.forEach((element) => {\n        selectors.push({\n          title: \"Resource Id\",\n          status: \"available\",\n          definition: {\n            id: element.resourceId,\n          },\n        });\n      });\n    } else {\n      selectors.push({\n        title: \"Resource Id\",\n        status: \"unavailable\",\n        message:\n          \"This element has no resource id associated with it. Type ‘\\u2318 D’ to view platform-specific documentation on how to assign resource ids to ui elements.\",\n        documentation:\n          \"https://maestro.mobile.dev/platform-support/supported-platforms\",\n      });\n    }\n  }\n  if (uiElement.text) {\n    if (typeof uiElement.textIndex === \"number\") {\n      selectors.push({\n        title: \"Text\",\n        status: \"available\",\n        definition: {\n          text: uiElement.text,\n          index: uiElement.textIndex,\n        },\n      });\n    } else {\n      selectors.push({\n        title: \"Text\",\n        status: \"available\",\n        definition: uiElement.text,\n      });\n    }\n  }\n  if (uiElement.hintText) {\n    selectors.push({\n      title: \"Hint Text\",\n      status: \"available\",\n      definition: uiElement.hintText,\n    });\n  }\n  if (uiElement.accessibilityText) {\n    selectors.push({\n      title: \"Accessibility Text\",\n      status: \"available\",\n      definition: uiElement.accessibilityText,\n    });\n  }\n  return selectors;\n};\n\nconst toTapExample = (selector: Selector): CommandExample => {\n  return {\n    status: selector.status,\n    title: `Tap > ${selector.title}`,\n    content:\n      selector.status === \"available\"\n        ? stringifyYaml([{ tapOn: selector.definition }])\n        : selector.message,\n    documentation:\n      selector.documentation ||\n      \"https://maestro.mobile.dev/api-reference/commands/tapon\",\n  };\n};\n\nconst toAssertExample = (selector: Selector): CommandExample => {\n  return {\n    status: selector.status,\n    title: `Assert > ${selector.title}`,\n    content:\n      selector.status === \"available\"\n        ? stringifyYaml([{ assertVisible: selector.definition }])\n        : selector.message,\n    documentation:\n      selector.documentation ||\n      \"https://maestro.mobile.dev/api-reference/commands/assertvisible\",\n  };\n};\n\nconst toConditionalExample = (selector: Selector): CommandExample => {\n  return {\n    status: selector.status,\n    title: `Conditional > ${selector.title}`,\n    content:\n      selector.status === \"available\"\n        ? stringifyYaml([\n            {\n              runFlow: {\n                when: { visible: selector.definition },\n                file: \"Subflow.yaml\",\n              },\n            },\n          ])\n        : selector.message,\n    documentation:\n      selector.documentation ||\n      \"https://maestro.mobile.dev/advanced/conditions\",\n  };\n};\n\nexport const getCommandExamples = (\n  deviceScreen?: DeviceScreen,\n  uiElement?: UIElement | null\n): CommandExample[] => {\n  if (!deviceScreen || !uiElement) {\n    return [];\n  }\n  const selectors = getSelectors(uiElement, deviceScreen);\n  const commands: CommandExample[] = [\n    ...selectors.map(toTapExample),\n    toTapExample(\n      getCoordinatesSelector(deviceScreen.width, deviceScreen.height, uiElement)\n    ),\n    ...selectors.map(toAssertExample),\n    ...selectors.map(toConditionalExample),\n  ];\n  return [\n    ...commands.filter((c) => c.status === \"available\"),\n    ...commands.filter((c) => c.status === \"unavailable\"),\n  ];\n};\n"
  },
  {
    "path": "maestro-studio/web/src/helpers/models.ts",
    "content": "import React from \"react\";\n\nexport type HTMLProps<T> = React.DetailedHTMLProps<React.HTMLAttributes<T>, T>;\nexport type TextAreaProps = React.DetailedHTMLProps<\n  React.TextareaHTMLAttributes<HTMLTextAreaElement>,\n  HTMLTextAreaElement\n>;\nexport type DivProps = HTMLProps<HTMLDivElement>;\n\nexport type UIElementBounds = {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n};\n\nexport type UIElement = {\n  id: string;\n  bounds?: UIElementBounds;\n  resourceId?: string;\n  resourceIdIndex?: number;\n  text?: string;\n  hintText?: string;\n  textIndex?: number;\n  accessibilityText?: string;\n};\n\nexport type DeviceScreen = {\n  platform: string;\n  screenshot: string;\n  width: number;\n  height: number;\n  elements: UIElement[];\n  url?: string;\n};\n\nexport type ReplCommandStatus =\n  | \"pending\"\n  | \"running\"\n  | \"success\"\n  | \"error\"\n  | \"canceled\";\n\nexport type ReplCommand = {\n  id: string;\n  yaml: string;\n  status: ReplCommandStatus;\n};\n\nexport type Repl = {\n  commands: ReplCommand[];\n};\n\nexport type FormattedFlow = {\n  config: string;\n  commands: string;\n};\n\nexport type BannerMessage = {\n  level: \"info\" | \"warning\" | \"error\" | \"none\";\n  message: string;\n};\n\nexport type AttributesType = {\n  accessibilityText?: string;\n  bounds?: string;\n  checked?: string;\n  enabled?: string;\n  focused?: string;\n  hintText?: string;\n  \"resource-id\"?: string;\n  selected?: string;\n  text?: string;\n  title?: string;\n  value?: string;\n};\n\nexport type ViewHierarchyType = {\n  attributes: AttributesType;\n  checked: boolean;\n  enabled: boolean;\n  focused: boolean;\n  selected: boolean;\n  children?: ViewHierarchyType[];\n};\n\nexport type AiResponseType = {\n  command?: string;\n};\n\nexport type AuthType = {\n  authToken: string;\n  openAiToken: string;\n};\n"
  },
  {
    "path": "maestro-studio/web/src/helpers/sampleElements.ts",
    "content": "import { UIElement } from \"./models\";\n\nexport const sampleElements: UIElement[] = [\n  {\n    id: \"c1e12891-1d2b-4472-89fc-07b7d79de81d\",\n  },\n  {\n    id: \"fe757756-612b-4170-b60b-5c8fde1019fd\",\n  },\n  {\n    id: \"cf997511-53b0-4eb6-89a7-7e41873bfa79\",\n  },\n  {\n    id: \"f24e599d-e648-4698-991a-893060710c3f\",\n  },\n  {\n    id: \"e8da4d6e-bb3b-4231-afe2-cc75568e3fbd\",\n  },\n  {\n    id: \"107d1d34-6ca7-4f14-9085-c54b8275ffde\",\n  },\n  {\n    id: \"18690a5e-18ea-4739-8447-ae6fe6fa8f5a\",\n  },\n  {\n    id: \"76a26937-73a2-483e-a567-3d7c4296a5b3\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 0,\n      height: 0,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"d7b6f5c9-b562-47d0-b9c2-2ab85a93b390\",\n  },\n  {\n    id: \"a36eddc5-9189-4e0d-a6a0-ff2817611742\",\n  },\n  {\n    id: \"addf552d-3ad0-4622-b7c5-dc00a437f1eb\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 0,\n      height: 0,\n    },\n    resourceId: \"com.android.systemui:id/back\",\n    text: \"Back\",\n    hintText: \"Back Hint Text\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"70c8ebdf-1894-4ca0-b81a-91edef3e78a9\",\n  },\n  {\n    id: \"ae9e4209-ca51-4e1c-96d5-a8d3296df7ee\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 0,\n      height: 0,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"5c117a17-e09b-4b2e-8615-6e3e32f96b31\",\n  },\n  {\n    id: \"6fec1a77-9bb5-4386-b141-fff020bd876d\",\n  },\n  {\n    id: \"3d16eba6-406f-40cc-8d7f-05926039b55b\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 0,\n      height: 0,\n    },\n    resourceId: \"com.android.systemui:id/recent_apps\",\n    text: \"Overview\",\n    hintText: \"Overview Hint text\",\n    accessibilityText: \"Overview text is here with content description\",\n  },\n  {\n    id: \"6979f0c5-8c61-428b-905e-8e645ccc6b5d\",\n  },\n  {\n    id: \"261e35ba-8dfb-4d54-a4f2-9e5a41bda30f\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 0,\n      height: 0,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"7573065c-3673-4482-805c-43311609c8e5\",\n  },\n  {\n    id: \"1769e940-70f1-431b-95c6-d3d56d48325b\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 0,\n      height: 0,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"2fb39c32-1c2a-404d-bf9b-ffd7a3e12b45\",\n  },\n  {\n    id: \"55bd39ea-6864-4604-824b-4e2ce515ee42\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 0,\n      height: 0,\n    },\n    resourceId: \"com.android.systemui:id/ends_group\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"199fcdbe-0477-476a-aa81-aa72e8887dcf\",\n  },\n  {\n    id: \"57068921-f62e-4f98-b4f4-0dfdecbd2561\",\n  },\n  {\n    id: \"3b449761-0cff-4f93-b574-c8859850de05\",\n  },\n  {\n    id: \"a0493d7c-bd37-4e01-9e16-6fecf262f7ae\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 0,\n      height: 0,\n    },\n    resourceId: \"com.android.systemui:id/home_button\",\n    text: \"Home\",\n  },\n  {\n    id: \"d4e03c4e-ebc5-4f3e-9f59-65ab9edeeeac\",\n  },\n  {\n    id: \"a44ea60d-481e-453a-b010-8013690ff318\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 0,\n      height: 0,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n  },\n  {\n    id: \"56c4051a-f968-47cc-a9e2-ad42359b9bb6\",\n  },\n  {\n    id: \"265d6526-e4ac-40bd-8b62-afae78b4b16a\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 0,\n      height: 0,\n    },\n    resourceId: \"com.android.systemui:id/white_cutout\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"7d83b5ee-4f42-41b5-ba03-7450d0258908\",\n  },\n  {\n    id: \"46c420df-930e-40df-9cf5-52561a40f67d\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 0,\n      height: 0,\n    },\n    resourceId: \"com.android.systemui:id/white\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"de1db1d2-4750-48db-8e56-d39f0f561ea2\",\n  },\n  {\n    id: \"31e2bc04-98a7-4b3e-a93b-967ec10027c4\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 0,\n      height: 0,\n    },\n    resourceId: \"com.android.systemui:id/home\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"e1ffed17-36bd-42eb-9f4a-98b1bf7fa6ea\",\n  },\n  {\n    id: \"8a9950be-99b5-474e-b1ae-e80d9eef4b7f\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 0,\n      height: 0,\n    },\n    resourceId: \"com.android.systemui:id/center_group\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"6d708e02-266d-4bf3-b3d9-253482949a9c\",\n  },\n  {\n    id: \"16569543-ea47-450f-9a13-dc6858efb2e2\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 0,\n      height: 0,\n    },\n    resourceId: \"com.android.systemui:id/nav_buttons\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"c8a1350c-fa4b-4426-aae7-ba4f735093aa\",\n  },\n  {\n    id: \"e108573a-2799-45f3-bd7d-15e7986b2232\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 0,\n      height: 0,\n    },\n    resourceId: \"com.android.systemui:id/horizontal\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"c8a35a99-adb6-464c-8535-2897f7ecca98\",\n  },\n  {\n    id: \"fc3e2f92-f3a5-4429-a9f3-fc772557e157\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 0,\n      height: 0,\n    },\n    resourceId: \"com.android.systemui:id/navigation_inflater\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"7f0ae60f-c700-43bb-843c-d4267cccbe07\",\n  },\n  {\n    id: \"83a4dc28-c781-4a8f-a13f-6156b7bf6caf\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 0,\n      height: 0,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"4b2fb989-4460-49f4-8e9d-4867725d7391\",\n  },\n  {\n    id: \"9963f8d8-da2b-4ace-90b9-6d09da387f18\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 0,\n      height: 0,\n    },\n    resourceId: \"com.android.systemui:id/navigation_bar_frame\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"c997da95-0071-427b-bf2e-d7da8ec6e16b\",\n  },\n  {\n    id: \"2c9341e4-4376-482d-9085-f0ca3faca50d\",\n  },\n  {\n    id: \"2a3f8e6e-fe93-42d6-b9b5-2a5a8518cd54\",\n  },\n  {\n    id: \"817f3335-1071-4a23-89e9-704796b695b5\",\n  },\n  {\n    id: \"84fa60ca-c43c-46cd-85c5-3d718ee43227\",\n  },\n  {\n    id: \"4ff8890d-e341-45f4-bac2-70350e489b70\",\n  },\n  {\n    id: \"5e2dae9b-f946-4a68-ad89-94b3f148f0f5\",\n  },\n  {\n    id: \"2a917aae-3e57-4853-a618-d34739fbe48a\",\n    bounds: {\n      x: 22,\n      y: 0,\n      width: 94,\n      height: 66,\n    },\n    resourceId: \"com.android.systemui:id/clock\",\n    text: \"3:22\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"e4d6868c-1c97-42c6-9e9e-93d0980c1ea0\",\n  },\n  {\n    id: \"82e8a5f0-31b3-4ce8-b3b7-7575ac38cedf\",\n  },\n  {\n    id: \"da7af6c5-f02a-48c2-a015-f7431d4b1249\",\n  },\n  {\n    id: \"073c3822-6ee4-4a91-ad88-4f845d134f15\",\n  },\n  {\n    id: \"ddda00b2-0e07-4324-a8af-0ce83774a248\",\n    bounds: {\n      x: 116,\n      y: 0,\n      width: 61,\n      height: 66,\n    },\n    resourceId: \"\",\n    text: \"Android System notification: Wi‑Fi will turn on automatically\",\n    hintText:\n      \"Android System notification: Wi‑Fi will turn on automatically hint text\",\n    accessibilityText: \"Android wifi bar with 3 signals\",\n  },\n  {\n    id: \"75c08fe2-3575-4c6d-9ed8-e990b1baf30d\",\n  },\n  {\n    id: \"44791c18-03c9-47c0-a162-dae46df9b06e\",\n    bounds: {\n      x: 116,\n      y: 0,\n      width: 424,\n      height: 66,\n    },\n    resourceId: \"com.android.systemui:id/notificationIcons\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"0b398670-d05b-4cfd-a953-a4e168d3041e\",\n  },\n  {\n    id: \"1bd30b52-fe27-4970-92eb-c4d9b7c4cdb4\",\n    bounds: {\n      x: 116,\n      y: 0,\n      width: 424,\n      height: 66,\n    },\n    resourceId: \"com.android.systemui:id/notification_icon_area_inner\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"704aad99-5559-4294-9ec7-087a3739cb8d\",\n  },\n  {\n    id: \"e4a9750b-fa73-46cf-85db-e349a7c551fd\",\n    bounds: {\n      x: 116,\n      y: 0,\n      width: 424,\n      height: 66,\n    },\n    resourceId: \"com.android.systemui:id/notification_icon_area\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"72e832b1-5aad-4316-ad36-c04fd8b54641\",\n  },\n  {\n    id: \"ff185be7-b426-4bca-97ba-0fde9c76d410\",\n    bounds: {\n      x: 22,\n      y: 0,\n      width: 518,\n      height: 66,\n    },\n    resourceId: \"com.android.systemui:id/status_bar_left_side\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"531b2b70-2647-4499-a59b-625a4e8afac4\",\n  },\n  {\n    id: \"8d4993cc-acc3-44bf-90d6-08d806aa52a6\",\n    bounds: {\n      x: 22,\n      y: 0,\n      width: 518,\n      height: 66,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"ee1d4399-1429-45e7-998a-d6eef712eaed\",\n  },\n  {\n    id: \"347f5e4c-db5f-43df-948a-f0b9a22b3072\",\n  },\n  {\n    id: \"88d1a4bb-3438-4961-9b63-7240f087a57c\",\n  },\n  {\n    id: \"aaa1615f-097a-4e70-b5ff-deebdc5498d7\",\n  },\n  {\n    id: \"a7946fec-7fc1-430e-ac6f-380da8a48e15\",\n  },\n  {\n    id: \"67dc41f6-ccc9-4934-a595-04825ffa8373\",\n  },\n  {\n    id: \"c3e099a4-85c4-47aa-89e3-59a8430afb3e\",\n    bounds: {\n      x: 925,\n      y: 12,\n      width: 54,\n      height: 41,\n    },\n    resourceId: \"com.android.systemui:id/mobile_type\",\n    text: \"No internet\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"f507541c-1244-4263-af13-7862d3a8aa46\",\n  },\n  {\n    id: \"633f9f3c-8618-4540-9ce6-7ed1f1bb6c49\",\n  },\n  {\n    id: \"9d3712de-7565-4bec-95db-31e251b5bbf1\",\n    bounds: {\n      x: 979,\n      y: 12,\n      width: 41,\n      height: 41,\n    },\n    resourceId: \"com.android.systemui:id/mobile_signal\",\n    text: \"\",\n    hintText: \"Mobile signal hint Text\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"9196a67c-61d2-455c-9fe0-44dcdd3924dd\",\n  },\n  {\n    id: \"8cfd96b2-4646-45af-ade3-33cc58915ce9\",\n    bounds: {\n      x: 979,\n      y: 12,\n      width: 41,\n      height: 41,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"c22eb9a1-15e1-4349-81cc-ab3af47b7f87\",\n  },\n  {\n    id: \"28a66cfb-72af-4918-aeb2-36596235027a\",\n    bounds: {\n      x: 925,\n      y: 2,\n      width: 95,\n      height: 61,\n    },\n    resourceId: \"com.android.systemui:id/mobile_group\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"f5b65f25-44cc-4096-9ae8-2374e0c37176\",\n  },\n  {\n    id: \"8afcf17a-3dcf-4c7c-bede-c8dd010637bb\",\n    bounds: {\n      x: 925,\n      y: 2,\n      width: 95,\n      height: 61,\n    },\n    resourceId: \"com.android.systemui:id/mobile_combo\",\n    text: \"Phone three bars.\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"1e817479-fcde-4bd9-8d8a-1cbe8d6d3271\",\n  },\n  {\n    id: \"19ff1c0b-8f69-4a77-b9e8-a5327889040e\",\n    bounds: {\n      x: 540,\n      y: 0,\n      width: 497,\n      height: 66,\n    },\n    resourceId: \"com.android.systemui:id/statusIcons\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"4ef0f688-838d-4fc7-bdb7-8259da32477f\",\n  },\n  {\n    id: \"1a552e5f-b766-4a71-af95-d67e366692f7\",\n  },\n  {\n    id: \"d635c5ac-6fb8-4586-a267-46edcfce460b\",\n    bounds: {\n      x: 1037,\n      y: 15,\n      width: 21,\n      height: 36,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"7627db15-076c-407d-980c-4c378256504c\",\n  },\n  {\n    id: \"2f8ec79e-bd1d-4322-aa32-7f7657653a21\",\n    bounds: {\n      x: 1037,\n      y: 0,\n      width: 21,\n      height: 66,\n    },\n    resourceId: \"com.android.systemui:id/battery\",\n    text: \"Battery 100 percent.\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"2bab18a1-d348-4eb5-8550-7a91bd2dabb3\",\n  },\n  {\n    id: \"941c0c32-da50-4fef-bf87-bf384ae62068\",\n    bounds: {\n      x: 540,\n      y: 0,\n      width: 518,\n      height: 66,\n    },\n    resourceId: \"com.android.systemui:id/system_icons\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"2f53ad8b-ff5e-4db1-b00c-6b29a39a10ae\",\n  },\n  {\n    id: \"d48fbde0-2398-4074-960b-ec574d7ad959\",\n    bounds: {\n      x: 540,\n      y: 0,\n      width: 518,\n      height: 66,\n    },\n    resourceId: \"com.android.systemui:id/system_icon_area\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"41ccab08-3809-45cc-af26-2bcda765bbaa\",\n  },\n  {\n    id: \"1134ccb2-9460-45ed-bf67-887f435b8b02\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 1080,\n      height: 66,\n    },\n    resourceId: \"com.android.systemui:id/status_bar_contents\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"478e44a9-8b32-4307-96a6-8eb30848ebef\",\n  },\n  {\n    id: \"6f80c120-345a-4fa7-8b8f-74d0688368b9\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 1080,\n      height: 66,\n    },\n    resourceId: \"com.android.systemui:id/status_bar\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"aac4db52-8567-438c-8142-3a054af610e6\",\n  },\n  {\n    id: \"a4cc1bc9-5c4c-42f1-871a-67761750309b\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 1080,\n      height: 66,\n    },\n    resourceId: \"com.android.systemui:id/status_bar_container\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"3290aa97-1d6f-43fe-addd-f5d6a32cd01e\",\n  },\n  {\n    id: \"90d245c0-3ea9-4400-9a61-669118abae8a\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 1080,\n      height: 66,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"27035d5e-6f22-4051-8440-3f15028b8b56\",\n  },\n  {\n    id: \"4363f909-ac63-46d2-911c-906d76d40954\",\n  },\n  {\n    id: \"0349db3b-c8ed-4bcb-adf9-740fecd04456\",\n  },\n  {\n    id: \"4d1a704e-f281-4a42-8146-5384d2c4ec60\",\n  },\n  {\n    id: \"fc26cb10-aae1-43c8-b06d-e68c5b32feb8\",\n  },\n  {\n    id: \"a91cfe50-2cd9-4745-a039-9fdb776c1223\",\n  },\n  {\n    id: \"fea9a4ac-fab2-4e1c-8daa-940c60cb5f25\",\n  },\n  {\n    id: \"2c16e886-d210-471a-9c87-772566a363d1\",\n  },\n  {\n    id: \"3187908d-f085-4dea-9752-ab9e108dc3f0\",\n  },\n  {\n    id: \"9db3f678-2f49-48e0-9b02-f07544e014d2\",\n  },\n  {\n    id: \"180ebf81-1616-4987-821b-6da93a420bef\",\n  },\n  {\n    id: \"ff2bb467-5b30-4df8-afab-9e1716bf28fb\",\n  },\n  {\n    id: \"490ea4a8-c01f-428c-ab6a-bea14ebe1053\",\n    bounds: {\n      x: 67,\n      y: 93,\n      width: 108,\n      height: 143,\n    },\n    resourceId: \"\",\n    text: \"Alarm\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"3ee3cf6d-0341-48ad-947c-0ac47eff32bf\",\n  },\n  {\n    id: \"5b39671d-75f8-4ce3-a1ef-09cf1214210e\",\n    bounds: {\n      x: 0,\n      y: 66,\n      width: 242,\n      height: 198,\n    },\n    resourceId: \"\",\n    text: \"Alarm\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"e83b075d-f655-4178-bac2-d843ea094265\",\n  },\n  {\n    id: \"13dbeba7-601f-41a8-b8e0-499b9a5c6465\",\n  },\n  {\n    id: \"61aefcd1-c1f7-4b70-accd-0cc611eb86a6\",\n    bounds: {\n      x: 310,\n      y: 93,\n      width: 105,\n      height: 143,\n    },\n    resourceId: \"\",\n    text: \"Clock\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"9f0bfe72-e5d9-41e5-8389-1522134a4a0c\",\n  },\n  {\n    id: \"5c0ef556-621a-4620-ae38-0456b537b38b\",\n    bounds: {\n      x: 242,\n      y: 66,\n      width: 242,\n      height: 198,\n    },\n    resourceId: \"\",\n    text: \"Clock\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"4ffae82a-5dce-43e2-b6ee-ccb514f7803a\",\n  },\n  {\n    id: \"b6742e03-d83e-4632-80dc-924d380d23d0\",\n  },\n  {\n    id: \"ec643050-9b26-4b68-a0e9-4f8438f75257\",\n    bounds: {\n      x: 552,\n      y: 93,\n      width: 105,\n      height: 143,\n    },\n    resourceId: \"\",\n    text: \"Timer\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"9dc8aee8-539d-48cb-b4a6-80ae77c194f9\",\n  },\n  {\n    id: \"6357f178-9b87-42c9-aae6-5bed32a5570f\",\n    bounds: {\n      x: 484,\n      y: 66,\n      width: 242,\n      height: 198,\n    },\n    resourceId: \"\",\n    text: \"Timer\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"f0376d4f-0405-4f76-b4b0-384dc3a2e4dd\",\n  },\n  {\n    id: \"e2d24bd8-50c6-47af-8660-0c1a4becbddc\",\n  },\n  {\n    id: \"62164de0-1577-4b20-9e2b-1d363882d18b\",\n    bounds: {\n      x: 750,\n      y: 93,\n      width: 194,\n      height: 143,\n    },\n    resourceId: \"\",\n    text: \"Stopwatch\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"342d3368-9adb-4fa4-9c9e-e5cb5c3f4210\",\n  },\n  {\n    id: \"2d15de45-6dc3-4b2c-9e1e-af58ba91b31f\",\n    bounds: {\n      x: 726,\n      y: 66,\n      width: 243,\n      height: 198,\n    },\n    resourceId: \"\",\n    text: \"Stopwatch\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"f6623dc5-c4fe-483f-9bf9-34ebf1b4698f\",\n  },\n  {\n    id: \"821d6e80-2706-488c-b731-e577f8dc9402\",\n    bounds: {\n      x: 0,\n      y: 66,\n      width: 969,\n      height: 198,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"1e466bc8-186a-44e8-8657-62bace159473\",\n  },\n  {\n    id: \"2ce3241a-e802-46bf-bd4a-6647c6f7b481\",\n    bounds: {\n      x: 0,\n      y: 66,\n      width: 969,\n      height: 198,\n    },\n    resourceId: \"com.google.android.deskclock:id/tabs\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"803841ef-498d-488b-bf9d-d77e0b82d68a\",\n  },\n  {\n    id: \"02457f6d-a1d6-446b-ba3e-0dbc7f0f6fe2\",\n  },\n  {\n    id: \"b3d27577-ee66-4d8c-a8ad-3f545de07d0c\",\n    bounds: {\n      x: 969,\n      y: 77,\n      width: 111,\n      height: 132,\n    },\n    resourceId: \"\",\n    text: \"More options\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"ca232272-4ba9-4583-b995-361f028faff8\",\n  },\n  {\n    id: \"501fa792-2d78-4c3e-8fb9-a8ee6fedc0b0\",\n    bounds: {\n      x: 969,\n      y: 66,\n      width: 111,\n      height: 154,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"fcb6cb42-b129-4be2-8d99-cc297f7b6de4\",\n  },\n  {\n    id: \"9716370e-dbe9-4883-a792-ef5e8f304a51\",\n    bounds: {\n      x: 0,\n      y: 66,\n      width: 1080,\n      height: 198,\n    },\n    resourceId: \"com.google.android.deskclock:id/toolbar\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"71f8dd1f-85a4-4e0f-a2c9-c6901f98d7aa\",\n  },\n  {\n    id: \"be21bb10-3e2d-44bb-bfd4-22d2f031f9cb\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 1080,\n      height: 264,\n    },\n    resourceId: \"com.google.android.deskclock:id/app_bar_layout\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"ff2510e8-d080-416c-8e4b-8cb8cc927ea9\",\n  },\n  {\n    id: \"15d61575-6cf6-4318-a63b-5884c87933a2\",\n  },\n  {\n    id: \"69b9b737-2c74-456e-9203-ec8afdabe67f\",\n  },\n  {\n    id: \"867ee6cc-a79c-4fbe-bcc5-964425040e2c\",\n  },\n  {\n    id: \"6e5a19fa-b0c7-4897-ad95-e3ab1f25ccc8\",\n  },\n  {\n    id: \"49682735-09ff-42a3-b9ec-413d295cf9e4\",\n  },\n  {\n    id: \"387c0a8a-6d51-426e-be9f-a2b943242be4\",\n  },\n  {\n    id: \"757ee49b-5324-49e6-a1de-48179897611f\",\n  },\n  {\n    id: \"d24b5a0e-eb37-4382-b727-6852e89e21d8\",\n    bounds: {\n      x: 64,\n      y: 392,\n      width: 819,\n      height: 190,\n    },\n    resourceId: \"com.google.android.deskclock:id/timer_setup_time\",\n    text: \"25h 22m 22s\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"ecd4c297-d796-4c37-af7c-8f07d6946f57\",\n  },\n  {\n    id: \"f45a58cc-9194-4a8c-84b4-843b06be5e26\",\n    bounds: {\n      x: 883,\n      y: 421,\n      width: 132,\n      height: 132,\n    },\n    resourceId: \"com.google.android.deskclock:id/timer_setup_delete\",\n    text: \"Delete 2\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"3fb4b208-4d5e-4ca0-898d-01312e4ee189\",\n  },\n  {\n    id: \"a2ee6cfc-9c3c-43cf-8a9a-8ff2a78cdd81\",\n    bounds: {\n      x: 64,\n      y: 264,\n      width: 951,\n      height: 446,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"d2fb27ad-c3da-4bf1-9ba0-be547c624859\",\n  },\n  {\n    id: \"8a6ac5af-618b-446e-bb2f-1dda1961beda\",\n    bounds: {\n      x: 64,\n      y: 710,\n      width: 951,\n      height: 3,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"4afe9470-9440-4799-b31d-076153fb4052\",\n  },\n  {\n    id: \"58b264d3-3c90-4f11-8604-e12d6fc1a4ec\",\n  },\n  {\n    id: \"65137a97-4505-41b7-8f0c-00f4f4e85009\",\n    bounds: {\n      x: 64,\n      y: 735,\n      width: 317,\n      height: 308,\n    },\n    resourceId: \"com.google.android.deskclock:id/timer_setup_digit_1\",\n    text: \"1\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"c01388fe-0900-4d4d-86d1-d70d1d543d84\",\n  },\n  {\n    id: \"d40b317e-d656-48a7-b01a-f4a5e0575e0d\",\n    bounds: {\n      x: 381,\n      y: 735,\n      width: 317,\n      height: 308,\n    },\n    resourceId: \"com.google.android.deskclock:id/timer_setup_digit_2\",\n    text: \"2\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"d1991ae4-ef7e-499a-ba8f-5ec734af61d6\",\n  },\n  {\n    id: \"e9e63788-6798-410b-b5d8-f5ec183efdba\",\n    bounds: {\n      x: 698,\n      y: 735,\n      width: 317,\n      height: 308,\n    },\n    resourceId: \"com.google.android.deskclock:id/timer_setup_digit_3\",\n    text: \"3\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"c497c02b-4cf6-4383-a48a-cb948c84483a\",\n  },\n  {\n    id: \"46adaa95-4d34-4894-9983-997fa6f220b7\",\n    bounds: {\n      x: 64,\n      y: 1043,\n      width: 317,\n      height: 308,\n    },\n    resourceId: \"com.google.android.deskclock:id/timer_setup_digit_4\",\n    text: \"4\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"894da1b9-099e-4e8c-8def-a26a6585b428\",\n  },\n  {\n    id: \"a774895b-fd97-4898-b4e2-6badfe83a55b\",\n    bounds: {\n      x: 381,\n      y: 1043,\n      width: 317,\n      height: 308,\n    },\n    resourceId: \"com.google.android.deskclock:id/timer_setup_digit_5\",\n    text: \"5\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"4e27654d-a0d5-4048-bf35-655d1619d7fc\",\n  },\n  {\n    id: \"51e373fb-7e58-4a43-b652-65b0e34e3c00\",\n    bounds: {\n      x: 698,\n      y: 1043,\n      width: 317,\n      height: 308,\n    },\n    resourceId: \"com.google.android.deskclock:id/timer_setup_digit_6\",\n    text: \"6\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"ac7c3c31-052d-4794-b066-a2eaf04808b4\",\n  },\n  {\n    id: \"4aac8d55-28bc-4962-aa44-daa18d712056\",\n    bounds: {\n      x: 64,\n      y: 1351,\n      width: 317,\n      height: 307,\n    },\n    resourceId: \"com.google.android.deskclock:id/timer_setup_digit_7\",\n    text: \"7\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"0bfcb2ef-db65-445f-8f5e-d7a73798a3b1\",\n  },\n  {\n    id: \"bef325d4-07c7-4d41-883d-c08647ba13af\",\n    bounds: {\n      x: 381,\n      y: 1351,\n      width: 317,\n      height: 307,\n    },\n    resourceId: \"com.google.android.deskclock:id/timer_setup_digit_8\",\n    text: \"8\",\n    hintText: \"Numerical hint text 8\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"6e37aff4-b89f-4505-8d04-7feec9edddea\",\n  },\n  {\n    id: \"0f70da3a-58e7-46c8-84eb-975dd59800dd\",\n    bounds: {\n      x: 698,\n      y: 1351,\n      width: 317,\n      height: 307,\n    },\n    resourceId: \"com.google.android.deskclock:id/timer_setup_digit_9\",\n    text: \"9\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"102f262f-dfab-4240-a9c6-0b5f52f28dfd\",\n  },\n  {\n    id: \"e774b033-27df-4572-aae2-f99262f557e9\",\n    bounds: {\n      x: 381,\n      y: 1658,\n      width: 317,\n      height: 308,\n    },\n    resourceId: \"com.google.android.deskclock:id/timer_setup_digit_0\",\n    text: \"0\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"7e4597fd-112f-49d5-8c49-3a368806519c\",\n  },\n  {\n    id: \"2d086f2f-069d-469e-8f12-1e321001e1bd\",\n    bounds: {\n      x: 64,\n      y: 735,\n      width: 951,\n      height: 1231,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"9c30e1e1-b331-48db-acc0-9c3313452f5a\",\n  },\n  {\n    id: \"41f822af-1d07-4d20-a4f8-bb1bd742368e\",\n    bounds: {\n      x: 64,\n      y: 264,\n      width: 951,\n      height: 1944,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"d9e9d761-36dc-41c1-ac87-7a556d32df14\",\n  },\n  {\n    id: \"39f9dd19-3924-472d-a2d2-cc239482a451\",\n    bounds: {\n      x: 0,\n      y: 264,\n      width: 1080,\n      height: 1944,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"0ced2c28-eeba-4d63-8f7b-b744d9c6ed96\",\n  },\n  {\n    id: \"7ec2d277-a962-470f-b439-81dc17b209cd\",\n    bounds: {\n      x: 0,\n      y: 264,\n      width: 1080,\n      height: 1944,\n    },\n    resourceId: \"com.google.android.deskclock:id/timer_setup\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"ff91f3b3-e008-4f00-8a37-98677f3c3d9b\",\n  },\n  {\n    id: \"b4b6ad99-e4be-4201-98d9-3ee426552bf1\",\n    bounds: {\n      x: 0,\n      y: 264,\n      width: 1080,\n      height: 1944,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"b7741874-d42e-41e2-84ae-011a91685d49\",\n  },\n  {\n    id: \"d2a3d34c-4ff4-436a-84af-c56874efc0ad\",\n    bounds: {\n      x: 0,\n      y: 264,\n      width: 1080,\n      height: 1944,\n    },\n    resourceId: \"com.google.android.deskclock:id/desk_clock_pager\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"33172deb-413c-40fe-8a4a-12ff6a5d6b54\",\n  },\n  {\n    id: \"8664c861-55c5-4166-a51d-df002a692da4\",\n    bounds: {\n      x: 0,\n      y: 264,\n      width: 1080,\n      height: 1944,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"1e5b1ee4-df8e-4776-b42a-1d55ab987427\",\n  },\n  {\n    id: \"246a0b80-48ac-4e48-b744-da67e035ff07\",\n  },\n  {\n    id: \"3d05b63f-6c78-4383-b5e3-0d8f63322145\",\n    bounds: {\n      x: 0,\n      y: 1966,\n      width: 419,\n      height: 242,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"dc37b3cd-ba86-4447-8808-508c046d4fea\",\n  },\n  {\n    id: \"c489b1fb-d7e0-4c05-852a-84c33cf10e99\",\n    bounds: {\n      x: 463,\n      y: 2010,\n      width: 154,\n      height: 154,\n    },\n    resourceId: \"com.google.android.deskclock:id/fab\",\n    text: \"Start\",\n    hintText: \"Start hint text\",\n    accessibilityText: \"Start content description accessibility text\",\n  },\n  {\n    id: \"5e508682-22cb-4615-aeb2-94dfaa5ec7d6\",\n  },\n  {\n    id: \"c985c15d-c6a4-4839-a4ab-a8ed28f61d7f\",\n    bounds: {\n      x: 661,\n      y: 1966,\n      width: 419,\n      height: 242,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"2d794c80-8814-42fe-ba76-f6b57adac072\",\n  },\n  {\n    id: \"ce240843-0307-4092-b620-f805258ef625\",\n    bounds: {\n      x: 0,\n      y: 1966,\n      width: 1080,\n      height: 242,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"2c0a840a-bd10-43e6-b1b7-f14e8a143867\",\n  },\n  {\n    id: \"2dc088fe-679a-44fe-a9e3-cd5e2a3c10c8\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 1080,\n      height: 2208,\n    },\n    resourceId: \"com.google.android.deskclock:id/content\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"cf4be9b4-d555-4099-a579-225b86af1c79\",\n  },\n  {\n    id: \"0f3713a0-ca88-466e-8eef-b7c1c86ed2b7\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 1080,\n      height: 2208,\n    },\n    resourceId: \"android:id/content\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"95f5a1cf-97ff-421f-bc5c-f6c181723597\",\n  },\n  {\n    id: \"14ba6eaa-4a2b-425e-9950-0ac90a31bd83\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 1080,\n      height: 2208,\n    },\n    resourceId: \"com.google.android.deskclock:id/action_bar_root\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"bb52ec3e-c714-4233-8163-9d9c9e243203\",\n  },\n  {\n    id: \"6b8d0373-97a6-4193-8b45-b40339d7cf73\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 1080,\n      height: 2208,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"a98007c7-5eb2-4e54-9d00-6fdf5803ff7b\",\n  },\n  {\n    id: \"2b3cd191-5ed4-4698-9038-fe0d68e248ff\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 1080,\n      height: 2208,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"13e0f21e-7c6f-40f6-a728-357bcc802786\",\n  },\n  {\n    id: \"db410b89-10f2-4666-908b-1feabca3a6dc\",\n    bounds: {\n      x: 0,\n      y: 0,\n      width: 1080,\n      height: 2208,\n    },\n    resourceId: \"\",\n    text: \"\",\n    hintText: \"\",\n    accessibilityText: \"\",\n  },\n  {\n    id: \"04c415fb-649d-47a4-833d-c0380c852b7b\",\n  },\n  {\n    id: \"f91ed88d-815d-4eb5-8bbf-52412382d319\",\n  },\n  {\n    id: \"6b351ab9-28cb-4a74-a58b-5b4342615d2f\",\n  },\n];\n"
  },
  {
    "path": "maestro-studio/web/src/index.tsx",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom/client\";\nimport { BrowserRouter as Router } from \"react-router-dom\";\nimport \"./style/index.css\";\nimport App from \"./App\";\n\nconst root = ReactDOM.createRoot(\n  document.getElementById(\"root\") as HTMLElement\n);\nroot.render(\n  <Router>\n    <React.StrictMode>\n      <App />\n    </React.StrictMode>\n  </Router>\n);\n"
  },
  {
    "path": "maestro-studio/web/src/pages/InteractPage.tsx",
    "content": "import { DeviceProvider } from \"../context/DeviceContext\";\nimport InteractPageLayout from \"../components/interact/InteractPageLayout\";\nimport { ReplProvider } from '../context/ReplContext';\n\nexport default function InteractPage() {\n  return (\n    <DeviceProvider>\n      <ReplProvider>\n        <InteractPageLayout />\n      </ReplProvider>\n    </DeviceProvider>\n  );\n}\n"
  },
  {
    "path": "maestro-studio/web/src/react-app-env.d.ts",
    "content": "/// <reference types=\"react-scripts\" />\n"
  },
  {
    "path": "maestro-studio/web/src/style/index.css",
    "content": "@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700');\n\n@font-face {\n  font-family: 'JetBrains Mono';\n  src: url('./fonts/JetBrainsMono.ttf') format('truetype');\n  font-weight: 100 1000;\n  font-style: normal;\n}\n\n@font-face {\n  font-family: 'JetBrains Mono';\n  src: url('./fonts/JetBrainsMono-Italic.ttf') format('truetype');\n  font-weight: 100 1000;\n  font-style: italic;\n}\n\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\nbody {\n  @apply antialiased;\n}\n\nhtml,\nbody,\n#root {\n  @apply m-0 h-full text-gray-900 dark:text-white bg-white dark:bg-slate-900;\n}\n\ninput::placeholder {\n  @apply text-slate-400;\n}\n\n.hide-scrollbar {\n  -ms-overflow-style: none; /* IE and Edge */\n  scrollbar-width: none; /* Firefox */\n}\n.hide-scrollbar::-webkit-scrollbar {\n  width: 0; /* For vertical scrollbars */\n  height: 0; /* For horizontal scrollbars */\n}\n\n.ai-loader {\n  position: relative;\n  overflow: hidden;\n}\n\n.ai-loader::before {\n  content: '';\n  position: absolute;\n  z-index: 0;\n  display: block;\n  top: 50%;\n  left: 50%;\n  width: 100vw;\n  height: 100vw;\n  background: conic-gradient(rgba(115, 87, 255, 1), rgba(115, 87, 255, 0));\n  animation: angleAnimate 5s linear infinite;\n}\n\n@keyframes angleAnimate {\n  0% {\n    transform: translate(-50%, -50%)rotate(0deg);\n  }\n  100% {\n    transform: translate(-50%, -50%)rotate(360deg);\n  }\n}"
  },
  {
    "path": "maestro-studio/web/tailwind.config.js",
    "content": "/** @type {import('tailwindcss').Config} */\nconst defaultTheme = require(\"tailwindcss/defaultTheme\");\n\nmodule.exports = {\n  content: [\"./src/**/*.{js,jsx,ts,tsx}\"],\n  theme: {\n    fontFamily: {\n      sans: [\"'Inter'\", \"sans-serif\"],\n      mono: [\"'JetBrains Mono'\", \"monospace\"],\n    },\n    extend: {\n      colors: {\n        slate: {\n          650: \"rgb(61 75 95)\",\n          750: \"rgb(41 53 72)\",\n          850: \"rgb(22 32 51)\",\n        },\n        purple: {\n          25: \"#f9f8ff\",\n          50: \"#f1eeff\",\n          75: \"#e2dcff\",\n          100: \"#c6bbff\",\n          200: \"#bcafff\",\n          300: \"#a797ff\",\n          400: \"#8b75ff\",\n          500: \"#7357ff\",\n          600: \"#6347f4\",\n          700: \"#553ade\",\n          800: \"#3c28a4\",\n          900: \"#21194d\",\n        },\n      },\n      boxShadow: {\n        up: \"0px -6px 12px -6px rgba(17, 12, 34, 0.1)\",\n      },\n      minWidth: {\n        ...defaultTheme.spacing,\n      },\n    },\n  },\n  plugins: [],\n  darkMode: \"class\",\n};\n"
  },
  {
    "path": "maestro-studio/web/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"es5\",\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"strict\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"node\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\"\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "maestro-test/build.gradle.kts",
    "content": "import org.jetbrains.kotlin.gradle.dsl.JvmTarget\nimport org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask\n\nplugins {\n    alias(libs.plugins.kotlin.jvm)\n}\n\njava {\n    sourceCompatibility = JavaVersion.VERSION_17\n    targetCompatibility = JavaVersion.VERSION_17\n}\n\nkotlin {\n    jvmToolchain {\n        languageVersion.set(JavaLanguageVersion.of(17))\n    }\n}\n\ntasks.named(\"compileKotlin\", KotlinCompilationTask::class.java) {\n    compilerOptions {\n        freeCompilerArgs.addAll(\"-Xjdk-release=17\")\n    }\n}\n\ndependencies {\n    implementation(project(\":maestro-orchestra\"))\n    implementation(project(\":maestro-client\"))\n    implementation(project(\":maestro-utils\"))\n\n    implementation(libs.google.truth)\n    implementation(libs.square.okio)\n\n    testImplementation(libs.junit.jupiter.api)\n    testRuntimeOnly(libs.junit.jupiter.engine)\n\n    testImplementation(libs.wiremock.jre8)\n}\n\ntasks.named<Test>(\"test\") {\n    useJUnitPlatform()\n}\n"
  },
  {
    "path": "maestro-test/src/main/kotlin/maestro/test/drivers/FakeDriver.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.test.drivers\n\nimport com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\nimport com.google.common.truth.Truth.assertThat\nimport maestro.Capability\nimport maestro.DeviceInfo\nimport maestro.device.DeviceOrientation\nimport maestro.Driver\nimport maestro.KeyCode\nimport maestro.MaestroException\nimport maestro.OnDeviceElementQuery\nimport maestro.Point\nimport maestro.ScreenRecording\nimport maestro.SwipeDirection\nimport maestro.TreeNode\nimport maestro.ViewHierarchy\nimport maestro.device.Platform\nimport maestro.utils.ScreenshotUtils\nimport okio.Sink\nimport okio.buffer\nimport java.awt.image.BufferedImage\nimport java.io.File\nimport javax.imageio.ImageIO\n\nclass FakeDriver : Driver {\n\n    private var state: State = State.NOT_INITIALIZED\n    private var layout: FakeLayoutElement = FakeLayoutElement()\n    private var installedApps = mutableSetOf<String>()\n\n    private val events = mutableListOf<Event>()\n\n    private var copiedText: String? = null\n\n    private var currentText: String = \"\"\n\n    private var airplaneMode: Boolean = false\n\n    // If true, keyboard will remain visible even after hideKeyboard() is called.\n    var keyboardRemainsVisible: Boolean = false\n\n    override fun name(): String {\n        return \"Fake Device\"\n    }\n\n    override fun open() {\n        if (state == State.OPEN) {\n            throw IllegalStateException(\"Already open\")\n        }\n\n        state = State.OPEN\n    }\n\n    override fun close() {\n        if (state == State.CLOSED) {\n            throw IllegalStateException(\"Already closed\")\n        }\n\n        if (state == State.NOT_INITIALIZED) {\n            throw IllegalStateException(\"Not open yet\")\n        }\n\n        state = State.CLOSED\n    }\n\n    override fun deviceInfo(): DeviceInfo {\n        ensureOpen()\n\n        return DeviceInfo(\n            platform = Platform.IOS,\n            widthPixels = 1080,\n            heightPixels = 1920,\n            widthGrid = 540,\n            heightGrid = 960,\n        )\n    }\n\n    override fun setOrientation(orientation: DeviceOrientation) {\n        ensureOpen()\n\n        events += Event.SetOrientation(orientation)\n    }\n\n    override fun launchApp(\n        appId: String,\n        launchArguments: Map<String, Any>,\n    ) {\n        ensureOpen()\n\n        if (appId !in installedApps) {\n            throw MaestroException.UnableToLaunchApp(\"App $appId is not installed\")\n        }\n\n        events.add(\n            Event.LaunchApp(\n                appId = appId,\n                launchArguments = launchArguments,\n            )\n        )\n    }\n\n    override fun stopApp(appId: String) {\n        ensureOpen()\n\n        events.add(Event.StopApp(appId))\n    }\n\n    override fun killApp(appId: String) {\n        ensureOpen()\n\n        events.add(Event.KillApp(appId))\n    }\n\n    override fun clearAppState(appId: String) {\n        ensureOpen()\n\n        if (appId !in installedApps) {\n            println(\"App $appId not installed. Skipping clearAppState.\")\n            return\n        }\n        events.add(Event.ClearState(appId))\n    }\n\n    override fun clearKeychain() {\n        ensureOpen()\n\n        events.add(Event.ClearKeychain)\n    }\n\n    override fun tap(point: Point) {\n        ensureOpen()\n\n        layout.dispatchClick(point.x, point.y)\n\n        events += Event.Tap(point)\n    }\n\n    override fun longPress(point: Point) {\n        ensureOpen()\n\n        events += Event.LongPress(point)\n    }\n\n    override fun pressKey(code: KeyCode) {\n        ensureOpen()\n\n        if (code == KeyCode.BACKSPACE) {\n            currentText = currentText.dropLast(1)\n        }\n\n        events += Event.PressKey(code)\n    }\n\n    override fun contentDescriptor(excludeKeyboardElements: Boolean): TreeNode {\n        ensureOpen()\n\n        return layout.toTreeNode()\n    }\n\n    override fun scrollVertical() {\n        ensureOpen()\n\n        events += Event.Scroll\n    }\n\n    override fun isKeyboardVisible(): Boolean {\n        ensureOpen()\n\n        if (keyboardRemainsVisible) {\n            return true\n        }\n\n        return !events.contains(Event.HideKeyboard)\n    }\n\n    override fun swipe(start: Point, end: Point, durationMs: Long) {\n        ensureOpen()\n\n        events += Event.Swipe(start, end, durationMs)\n    }\n\n    override fun swipe(swipeDirection: SwipeDirection, durationMs: Long) {\n        ensureOpen()\n\n        events += Event.SwipeWithDirection(swipeDirection, durationMs)\n    }\n\n    override fun swipe(elementPoint: Point, direction: SwipeDirection, durationMs: Long) {\n        ensureOpen()\n        val todo = mutableListOf(layout)\n        while (todo.isNotEmpty()) {\n            val next = todo.removeLast()\n            todo.addAll(next.children)\n            if (next.bounds != null) {\n                when (direction) {\n                    SwipeDirection.UP -> next.bounds = next.bounds!!.translate(x = 0, y = -300)\n                    SwipeDirection.DOWN -> next.bounds = next.bounds!!.translate(x = 0, y = 300)\n                    SwipeDirection.RIGHT -> next.bounds = next.bounds!!.translate(x = -300, y = 0)\n                    SwipeDirection.LEFT -> next.bounds = next.bounds!!.translate(x = 300, y = 0)\n                }\n            }\n        }\n        events += Event.SwipeElementWithDirection(elementPoint, direction, durationMs)\n    }\n\n    override fun backPress() {\n        ensureOpen()\n\n        events += Event.BackPress\n    }\n\n    override fun hideKeyboard() {\n        ensureOpen()\n\n        events += Event.HideKeyboard\n    }\n\n    override fun takeScreenshot(out: Sink, compressed: Boolean) {\n        ensureOpen()\n\n        val deviceInfo = deviceInfo()\n        val image = BufferedImage(\n            deviceInfo.widthPixels,\n            deviceInfo.heightPixels,\n            BufferedImage.TYPE_INT_ARGB,\n        )\n\n        val canvas = image.graphics\n        layout.draw(canvas)\n        canvas.dispose()\n\n        ImageIO.write(\n            image,\n            \"png\",\n            out.buffer().outputStream(),\n        )\n\n        events += Event.TakeScreenshot\n    }\n\n    override fun startScreenRecording(out: Sink): ScreenRecording {\n        ensureOpen()\n\n        out.buffer().writeUtf8(\"Screen recording\").close()\n\n        events += Event.StartRecording\n\n        return object : ScreenRecording {\n            override fun close() {\n                events += Event.StopRecording\n            }\n        }\n    }\n\n    override fun setLocation(latitude: Double, longitude: Double) {\n        ensureOpen()\n\n        events += Event.SetLocation(latitude, longitude)\n    }\n\n    override fun eraseText(charactersToErase: Int) {\n        ensureOpen()\n\n        currentText = if (charactersToErase == MAX_ERASE_CHARACTERS) {\n            \"\"\n        } else {\n            currentText.dropLast(charactersToErase)\n        }\n        events += Event.EraseAllText\n    }\n\n    override fun inputText(text: String) {\n        ensureOpen()\n\n        currentText += text\n\n        events += Event.InputText(text)\n    }\n\n    override fun openLink(link: String, appId: String?, autoVerify: Boolean, browser: Boolean) {\n        ensureOpen()\n\n        if (browser) {\n            events += Event.OpenBrowser(link)\n        } else {\n            events += Event.OpenLink(link, autoVerify)\n        }\n    }\n\n    override fun setProxy(host: String, port: Int) {\n        ensureOpen()\n\n        events += Event.SetProxy(host, port)\n    }\n\n    override fun resetProxy() {\n        ensureOpen()\n\n        events += Event.ResetProxy\n    }\n\n    override fun isShutdown(): Boolean {\n        return state != State.OPEN\n    }\n\n    override fun isUnicodeInputSupported(): Boolean {\n        return false\n    }\n\n    fun setLayout(layout: FakeLayoutElement) {\n        this.layout = layout\n    }\n\n    fun addInstalledApp(appId: String) {\n        installedApps.add(appId)\n    }\n\n    fun assertEvents(expected: List<Event>) {\n        assertThat(events)\n            .containsAtLeastElementsIn(expected)\n            .inOrder()\n    }\n\n    fun assertEventCount(event: Event, expectedCount: Int) {\n        assertThat(events.count { it == event })\n            .isEqualTo(expectedCount)\n    }\n\n    fun assertHasEvent(event: Event) {\n        if (!events.contains(event)) {\n            throw AssertionError(\"Expected event: $event\\nActual events: $events\")\n        }\n    }\n\n    fun assertNoEvent(event: Event) {\n        if (events.contains(event)) {\n            throw AssertionError(\"Expected no event: $event\\nActual events: $events\")\n        }\n    }\n\n    fun assertAnyEvent(condition: ((event: Event) -> Boolean)) {\n        assertThat(events.any { condition(it) }).isTrue()\n    }\n\n    fun assertAllEvent(condition: ((event: Event) -> Boolean)) {\n        assertThat(events.all { condition(it) }).isTrue()\n    }\n\n    fun assertNoInteraction() {\n        if (events.isNotEmpty()) {\n            throw AssertionError(\"Expected no interaction, but got: $events\")\n        }\n    }\n\n    fun assertCurrentTextInput(expected: String) {\n        assertThat(currentText).isEqualTo(expected)\n    }\n\n    private fun ensureOpen() {\n        if (state != State.OPEN) {\n            throw IllegalStateException(\"Driver is not opened yet\")\n        }\n    }\n\n    override fun waitForAppToSettle(initialHierarchy: ViewHierarchy?, appId: String?, timeoutMs: Int?): ViewHierarchy {\n        return ScreenshotUtils.waitForAppToSettle(initialHierarchy, this, timeoutMs)\n    }\n\n    override fun waitUntilScreenIsStatic(timeoutMs: Long): Boolean {\n        return ScreenshotUtils.waitUntilScreenIsStatic(timeoutMs, 0.005, this)\n    }\n\n    override fun capabilities(): List<Capability> {\n        return emptyList()\n    }\n\n    override fun setPermissions(appId: String, permissions: Map<String, String>) {\n        ensureOpen()\n\n        events.add(Event.SetPermissions(appId, permissions))\n    }\n\n    override fun addMedia(mediaFiles: List<File>) {\n        ensureOpen()\n\n        mediaFiles.forEach { _ -> events.add(Event.AddMedia) }\n    }\n\n    override fun isAirplaneModeEnabled(): Boolean {\n        return this.airplaneMode\n    }\n\n    override fun setAirplaneMode(enabled: Boolean) {\n        this.airplaneMode = enabled\n    }\n\n    override fun queryOnDeviceElements(query: OnDeviceElementQuery): List<TreeNode> {\n        if (query is OnDeviceElementQuery.Css) {\n            return searchCssRecursive(layout, query.css)\n        } else {\n            return super.queryOnDeviceElements(query)\n        }\n    }\n\n    private fun searchCssRecursive(element: FakeLayoutElement, css: String): List<TreeNode> {\n        val result = mutableListOf<TreeNode>()\n\n        if (element.matchesCssFilter == css) {\n            result.add(element.toTreeNode())\n        }\n\n        for (child in element.children) {\n            result.addAll(searchCssRecursive(child, css))\n        }\n\n        return result\n    }\n\n    sealed class Event {\n\n        data class Tap(\n            val point: Point\n        ) : Event(), UserInteraction\n\n        data class LongPress(\n            val point: Point\n        ) : Event(), UserInteraction\n\n        object Scroll : Event(), UserInteraction\n\n        object BackPress : Event(), UserInteraction\n\n        object HideKeyboard : Event(), UserInteraction\n\n        data class InputText(\n            val text: String\n        ) : Event(), UserInteraction\n\n        data class Swipe(\n            val start: Point,\n            val End: Point,\n            val durationMs: Long\n        ) : Event(), UserInteraction\n\n        data class SwipeWithDirection(val swipeDirection: SwipeDirection, val durationMs: Long) : Event(),\n            UserInteraction\n\n        data class SwipeElementWithDirection(\n            val point: Point,\n            val swipeDirection: SwipeDirection,\n            val durationMs: Long\n        ) : Event(), UserInteraction\n\n        data class LaunchApp(\n            val appId: String,\n            val launchArguments: Map<String, Any> = emptyMap()\n        ) : Event(), UserInteraction\n\n        data class StopApp(\n            val appId: String\n        ) : Event()\n\n        data class KillApp(\n            val appId: String\n        ) : Event()\n\n        data class ClearState(\n            val appId: String\n        ) : Event()\n\n        data class OpenLink(\n            val link: String,\n            val autoLink: Boolean = false\n        ) : Event()\n\n        data class OpenBrowser(\n            val link: String,\n        ) : Event()\n\n        data class PressKey(\n            val code: KeyCode,\n        ) : Event()\n\n        data class SetOrientation(\n            val orientation: DeviceOrientation,\n        ) : Event()\n\n        object TakeScreenshot : Event()\n\n        object ClearKeychain : Event()\n\n        data class SetLocation(\n            val latitude: Double,\n            val longitude: Double,\n        ) : Event()\n\n        object EraseAllText : Event()\n\n        data class SetProxy(\n            val host: String,\n            val port: Int,\n        ) : Event()\n\n        object ResetProxy : Event()\n\n        data class SetPermissions(\n            val appId: String,\n            val permissions: Map<String, String>,\n        ) : Event()\n\n        object AddMedia : Event()\n\n        object StartRecording : Event()\n\n        object StopRecording : Event()\n    }\n\n    interface UserInteraction\n\n    private enum class State {\n        CLOSED,\n        OPEN,\n        NOT_INITIALIZED,\n    }\n\n    companion object {\n\n        private val MAPPER = jacksonObjectMapper()\n        private const val MAX_ERASE_CHARACTERS = 50\n    }\n}\n"
  },
  {
    "path": "maestro-test/src/main/kotlin/maestro/test/drivers/FakeLayoutElement.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.test.drivers\n\nimport maestro.TreeNode\nimport java.awt.Color\nimport java.awt.Graphics\n\ndata class FakeLayoutElement(\n    var id: String? = null,\n    var text: String? = null,\n    var bounds: Bounds? = null,\n    var clickable: Boolean = false,\n    var enabled: Boolean? = true,\n    var selected: Boolean? = false,\n    var checked: Boolean? = false,\n    var focused: Boolean? = false,\n    var color: Color = Color.BLACK,\n    var onClick: (FakeLayoutElement) -> Unit = {},\n    val children: MutableList<FakeLayoutElement> = mutableListOf(),\n    var mutatingText: (() -> String)? = null,\n    var matchesCssFilter: String? = null,\n) {\n\n    fun toTreeNode(): TreeNode {\n        val attributes = mutableMapOf<String, String>()\n\n        bounds?.let {\n            attributes += \"bounds\" to \"${it.left},${it.top},${it.right},${it.bottom}\"\n        }\n\n        val textNode = if (mutatingText != null) mutatingText!!() else text\n\n        textNode?.let {\n            attributes += \"text\" to it\n        }\n\n        id?.let {\n            attributes += \"resource-id\" to it\n        }\n\n        enabled?.let {\n            attributes += \"enabled\" to it.toString()\n        }\n\n        selected?.let {\n            attributes += \"selected\" to it.toString()\n        }\n\n        checked?.let {\n            attributes += \"checked\" to it.toString()\n        }\n\n        focused?.let {\n            attributes += \"focused\" to it.toString()\n        }\n\n        return TreeNode(\n            attributes = attributes,\n            clickable = clickable,\n            enabled = enabled,\n            selected = selected,\n            checked = checked,\n            focused = focused,\n            children = children.map { it.toTreeNode() }\n        )\n    }\n\n    fun element(builder: FakeLayoutElement.() -> Unit): FakeLayoutElement {\n        val child = FakeLayoutElement()\n        child.builder()\n        children.add(child)\n        return child\n    }\n\n    fun draw(graphics: Graphics) {\n        bounds?.let { b ->\n            val previousColor = graphics.color\n\n            graphics.color = color\n            graphics.drawRect(\n                b.left,\n                b.top,\n                b.right - b.left,\n                b.bottom - b.top\n            )\n\n            text?.let {\n                graphics.drawString(it, b.left, b.top)\n            }\n\n            graphics.color = previousColor\n        }\n\n        children.forEach { it.draw(graphics) }\n    }\n\n    fun dispatchClick(x: Int, y: Int): Boolean {\n        children.forEach {\n            if (it.dispatchClick(x, y)) {\n                return true\n            }\n        }\n\n        return if (bounds?.contains(x, y) == true) {\n            onClick(this)\n            true\n        } else {\n            false\n        }\n    }\n\n    data class Bounds(\n        val left: Int,\n        val top: Int,\n        val right: Int,\n        val bottom: Int,\n    ) {\n\n        fun contains(x: Int, y: Int): Boolean {\n            return x in left..right && y in top..bottom\n        }\n\n        fun translate(x: Int = 0, y: Int = 0): Bounds {\n            return Bounds(\n                left = left + x,\n                top = top + y,\n                right = right + x,\n                bottom = bottom + y,\n            )\n        }\n\n        companion object {\n\n            fun ofSize(width: Int, height: Int): Bounds {\n                return Bounds(0, 0, width, height)\n            }\n\n        }\n\n    }\n\n}"
  },
  {
    "path": "maestro-test/src/main/kotlin/maestro/test/drivers/FakeTimer.kt",
    "content": "package maestro.test.drivers\n\nimport maestro.utils.MaestroTimer\n\nclass FakeTimer {\n\n    private val events = mutableListOf<Event>()\n\n    fun timer(): (MaestroTimer.Reason, Long) -> Unit {\n        return { reason, time ->\n            events.add(Event(reason, time))\n        }\n    }\n\n    fun assertNoEvent(reason: MaestroTimer.Reason) {\n        if (events.any { it.reason == reason }) {\n            throw AssertionError(\"Timer event for $reason was not expected\")\n        }\n    }\n\n    private data class Event(\n        val reason: MaestroTimer.Reason,\n        val time: Long,\n    )\n\n}\n"
  },
  {
    "path": "maestro-test/src/test/kotlin/maestro/test/DeepestMatchingElementTest.kt",
    "content": "package maestro.test\n\nimport maestro.Filters\nimport maestro.TreeNode\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.assertThrows\nimport org.junit.jupiter.api.Assertions.assertEquals\n\nclass DeepestMatchingElementTest {\n\n    @Test\n    fun `deepestMatchingElement should return only the deepest matching elements`() {\n        // Given: A hierarchy with nested elements that match the filter\n        val root = TreeNode(\n            attributes = mutableMapOf(\"text\" to \"some_text\"),\n            children = listOf(\n                TreeNode(\n                    attributes = mutableMapOf(\"text\" to \"some_text\", \"id\" to \"some_id\"),\n                    children = listOf(\n                        TreeNode(\n                            attributes = mutableMapOf(\"text\" to \"some_text\", \"id\" to \"\")\n                        )\n                    )\n                )\n            )\n        )\n\n        val textFilter = Filters.textMatches(\"some_text\".toRegex())\n        val deepestFilter = Filters.deepestMatchingElement(textFilter)\n\n        // When: Apply the deepestMatchingElement filter\n        val result = deepestFilter(listOf(root))\n\n        // Then: Should return only the deepest matching elements\n        // Expected: Only the innermost element with text=\"some_text\" and id=\"\"\n        assertEquals(1, result.size)\n        assertEquals(\"\", result[0].attributes[\"id\"])\n        assertEquals(\"some_text\", result[0].attributes[\"text\"])\n    }\n\n    @Test\n    fun `deepestMatchingElement should return parent if no children match`() {\n        // Given: A hierarchy where only the parent matches\n        val root = TreeNode(\n            attributes = mutableMapOf(\"text\" to \"some_text\"),\n            children = listOf(\n                TreeNode(\n                    attributes = mutableMapOf(\"text\" to \"different_text\")\n                )\n            )\n        )\n\n        val textFilter = Filters.textMatches(\"some_text\".toRegex())\n        val deepestFilter = Filters.deepestMatchingElement(textFilter)\n\n        // When: Apply the deepestMatchingElement filter\n        val result = deepestFilter(listOf(root))\n\n        // Then: Should return the parent element\n        assertEquals(1, result.size)\n        assertEquals(\"some_text\", result[0].attributes[\"text\"])\n    }\n\n    @Test\n    fun `deepestMatchingElement should return empty list if no elements match`() {\n        // Given: A hierarchy where no elements match\n        val root = TreeNode(\n            attributes = mutableMapOf(\"text\" to \"different_text\"),\n            children = listOf(\n                TreeNode(\n                    attributes = mutableMapOf(\"text\" to \"another_text\")\n                )\n            )\n        )\n\n        val textFilter = Filters.textMatches(\"some_text\".toRegex())\n        val deepestFilter = Filters.deepestMatchingElement(textFilter)\n\n        // When: Apply the deepestMatchingElement filter\n        val result = deepestFilter(listOf(root))\n\n        // Then: Should return empty list\n        assertEquals(0, result.size)\n    }\n\n    @Test\n    fun `deepestMatchingElement should handle multiple root elements correctly`() {\n        // Given: Multiple root elements with different nesting levels\n        val root1 = TreeNode(\n            attributes = mutableMapOf(\"text\" to \"some_text\")\n        )\n        val root2 = TreeNode(\n            attributes = mutableMapOf(\"text\" to \"some_text\"),\n            children = listOf(\n                TreeNode(\n                    attributes = mutableMapOf(\"text\" to \"some_text\", \"id\" to \"some_id\"),\n                    children = listOf(\n                        TreeNode(\n                            attributes = mutableMapOf(\"text\" to \"some_text\", \"id\" to \"\")\n                        )\n                    )\n                )\n            )\n        )\n\n        val textFilter = Filters.textMatches(\"some_text\".toRegex())\n        val deepestFilter = Filters.deepestMatchingElement(textFilter)\n\n        // When: Apply the deepestMatchingElement filter\n        val result = deepestFilter(listOf(root1, root2))\n\n        // Then: Should return only the deepest matching elements\n        // Expected: root1 (no children) and the innermost element from root2\n        assertEquals(2, result.size)\n        assertEquals(\"some_text\", result[0].attributes[\"text\"])\n        assertEquals(\"\", result[1].attributes[\"id\"])\n        assertEquals(\"some_text\", result[1].attributes[\"text\"])\n    }\n\n    @Test\n    fun `deepestMatchingElement should match integration test scenario exactly`() {\n        val element0 = TreeNode(\n            attributes = mutableMapOf(\"text\" to \"some_text\", \"bounds\" to \"0,0,200,200\")\n        )\n        val element3 = TreeNode(\n            attributes = mutableMapOf(\"text\" to \"some_text\", \"resource-id\" to \"\", \"bounds\" to \"50,50,150,150\")\n        )\n        val element2 = TreeNode(\n            attributes = mutableMapOf(\"text\" to \"some_text\", \"resource-id\" to \"some_id\", \"bounds\" to \"0,0,200,200\"),\n            children = listOf(element3)\n        )\n        val element1 = TreeNode(\n            attributes = mutableMapOf(\"text\" to \"some_text\", \"bounds\" to \"0,0,200,200\"),\n            children = listOf(element2)\n        )\n\n        val textFilter = Filters.textMatches(\"some_text\".toRegex())\n        val deepestFilter = Filters.deepestMatchingElement(textFilter)\n\n        // When: Apply the deepestMatchingElement filter\n        val result = deepestFilter(listOf(element0, element1))\n\n        // Then: Should return only the deepest matching elements\n        // Expected: element0 (no children) and element3 (deepest child of element1)\n        assertEquals(2, result.size)\n        assertEquals(\"some_text\", result[0].attributes[\"text\"])\n        assertEquals(\"\", result[1].attributes[\"resource-id\"])\n        assertEquals(\"some_text\", result[1].attributes[\"text\"])\n    }\n}\n"
  },
  {
    "path": "maestro-test/src/test/kotlin/maestro/test/FlowControllerTest.kt",
    "content": "package maestro.test\n\nimport maestro.js.JsEngine\nimport maestro.orchestra.FlowController\nimport kotlinx.coroutines.channels.Channel\n\nclass FlowControllerTest : FlowController {\n    private var _isPaused = false\n    private val pauseChannel = Channel<Unit>()\n    private val resumeChannel = Channel<Unit>()\n\n    override suspend fun waitIfPaused() {\n        if (_isPaused) {\n            // Wait for resume signal\n            resumeChannel.receive()\n        }\n    }\n\n    override fun pause() {\n        _isPaused = true\n        pauseChannel.trySend(Unit)\n    }\n\n    override fun resume() {\n        _isPaused = false\n        resumeChannel.trySend(Unit)\n    }\n\n    override val isPaused: Boolean get() = _isPaused\n\n    // Test helper methods\n    suspend fun waitForPause() {\n        pauseChannel.receive()\n    }\n\n    suspend fun waitForResume() {\n        resumeChannel.receive()\n    }\n} "
  },
  {
    "path": "maestro-test/src/test/kotlin/maestro/test/GraalJsEngineTest.kt",
    "content": "package maestro.test\n\nimport com.google.common.truth.Truth.assertThat\nimport maestro.js.GraalJsEngine\nimport org.graalvm.polyglot.PolyglotException\nimport org.junit.jupiter.api.BeforeEach\nimport org.junit.jupiter.api.Test\n\nclass GraalJsEngineTest : JsEngineTest() {\n\n    @BeforeEach\n    fun setUp() {\n        engine = GraalJsEngine()\n    }\n\n    @Test\n    fun `Allow redefinitions of variables`() {\n        engine.evaluateScript(\"const foo = null\")\n        engine.evaluateScript(\"const foo = null\")\n    }\n\n    @Test\n    fun `You can't share variables between scopes`() {\n        engine.evaluateScript(\"const foo = 'foo'\")\n        val result = engine.evaluateScript(\"foo\").toString()\n        assertThat(result).contains(\"undefined\")\n    }\n\n    @Test\n    fun `Backslash and newline are supported`() {\n        engine.setCopiedText(\"\\\\\\n\")\n        engine.putEnv(\"FOO\", \"\\\\\\n\")\n\n        val result = engine.evaluateScript(\"maestro.copiedText + FOO\").toString()\n\n        assertThat(result).isEqualTo(\"\\\\\\n\\\\\\n\")\n    }\n\n    @Test\n    fun `parseInt returns an int representation`() {\n        val result = engine.evaluateScript(\"parseInt('1')\").toString()\n        assertThat(result).isEqualTo(\"1\")\n    }\n\n    @Test\n    fun `sandboxing works`() {\n        try {\n            engine.evaluateScript(\"require('fs')\")\n            assert(false)\n        } catch (e: PolyglotException) {\n            assertThat(e.message).contains(\"undefined is not a function\")\n        }\n    }\n\n    @Test\n    fun `Environment variables are isolated between env scopes`() {\n        // Set a variable in the root scope\n        engine.putEnv(\"ROOT_VAR\", \"root_value\")\n        \n        // Enter new env scope and set a variable\n        engine.enterEnvScope()\n        engine.putEnv(\"SCOPED_VAR\", \"scoped_value\")\n        \n        // Both variables should be accessible in the child scope\n        assertThat(engine.evaluateScript(\"ROOT_VAR\").toString()).isEqualTo(\"root_value\")\n        assertThat(engine.evaluateScript(\"SCOPED_VAR\").toString()).isEqualTo(\"scoped_value\")\n        \n        // Leave the env scope\n        engine.leaveEnvScope()\n        \n        // Root variable should still be accessible\n        assertThat(engine.evaluateScript(\"ROOT_VAR\").toString()).isEqualTo(\"root_value\")\n        \n        // Scoped variable should no longer be accessible (undefined)\n        assertThat(engine.evaluateScript(\"SCOPED_VAR\").toString()).contains(\"undefined\")\n    }\n\n    @Test\n    fun `Can user faker providers`() {\n        val result = engine.evaluateScript(\"faker.name().firstName()\").toString()\n        assertThat(result).matches(\"^[A-Za-z]+$\")\n    }\n\n    @Test\n    fun `Can evaluate faker expressions`() {\n        val result = engine.evaluateScript(\"faker.expression('#{name.firstName} #{name.lastName}')\").toString()\n        assertThat(result).matches(\"^[A-Za-z]+ [A-Za-z']+$\")\n    }\n\n    @Test\n    fun `runInSubScope should isolate environment variables`() {\n        // Set a base environment variable\n        engine.putEnv(\"MY_VAR\", \"original\")\n\n        // Verify original value is accessible\n        assertThat(engine.evaluateScript(\"MY_VAR\").toString()).isEqualTo(\"original\")\n\n        // Execute script with runInSubScope=true and different env var\n        val envVars = mapOf(\"MY_VAR\" to \"scoped\")\n        engine.evaluateScript(\"console.log('Log from runScript')\", envVars, \"test.js\", runInSubScope = true)\n\n        // MY_VAR should still be original - the scoped value should not leak\n        assertThat(engine.evaluateScript(\"MY_VAR\").toString()).isEqualTo(\"original\")\n    }\n\n    @Test\n    fun `memory should remain bounded after many evaluations without binding modifications`() {\n        // This test simulates a repeat loop with a condition like:\n        //   - repeat:\n        //       while:\n        //         notVisible: '${MARKET}'\n        //       commands:\n        //         - swipe: ...\n        //\n        // The ${MARKET} expression is evaluated on every iteration.\n        //\n        // PROBLEM: If each evaluateScript() creates a new GraalJS context that is\n        // never released until close() is called, this causes:\n        //   - 100 evaluations = 100 open contexts\n        //   - ~68 MB memory growth for just 100 simple evaluations (~0.68 MB each)\n        //   - OOM errors for flows with 1000+ iterations\n        //\n        // SOLUTION: Either close contexts after evaluation (PR #2881 approach) or\n        // reuse a single context with IIFE isolation (alternative approach).\n\n        val graalEngine = engine as GraalJsEngine\n        graalEngine.putEnv(\"MARKET\", \"some_value\")\n\n        // Force GC and measure baseline memory\n        System.gc()\n        Thread.sleep(100)\n        val runtime = Runtime.getRuntime()\n        val baselineMemory = runtime.totalMemory() - runtime.freeMemory()\n\n        val iterations = 100\n        repeat(iterations) {\n            graalEngine.evaluateScript(\"MARKET\")\n        }\n\n        // Measure memory after evaluations\n        System.gc()\n        Thread.sleep(100)\n        val finalMemory = runtime.totalMemory() - runtime.freeMemory()\n        val memoryGrowthMB = (finalMemory - baselineMemory) / (1024.0 * 1024.0)\n\n        // Log the results for visibility\n        println(\n            \"After $iterations evaluations (no binding modifications): \" +\n            \"memory growth: ${\"%.2f\".format(memoryGrowthMB)} MB \" +\n            \"(baseline: ${\"%.2f\".format(baselineMemory / (1024.0 * 1024.0))} MB, \" +\n            \"final: ${\"%.2f\".format(finalMemory / (1024.0 * 1024.0))} MB)\"\n        )\n\n        // Memory growth should be minimal - well under what 100 contexts would consume\n        // 100 contexts at ~0.68 MB each would be ~68 MB\n        // With proper isolation (single context or context closing), growth should be < 10 MB\n        assertThat(memoryGrowthMB).isLessThan(10.0)\n    }\n\n    @Test\n    fun `output bindings should persist across evaluations`() {\n        // When a script modifies shared bindings (output), the values must\n        // remain accessible in subsequent evaluations.\n\n        val graalEngine = engine as GraalJsEngine\n\n        // This script modifies the output binding\n        graalEngine.evaluateScript(\"output.myValue = 'test'\")\n\n        // The value should still be accessible in subsequent evaluations\n        val result = graalEngine.evaluateScript(\"output.myValue\")\n        assertThat(result.toString()).isEqualTo(\"test\")\n    }\n\n    @Test\n    fun `arrays stored in output should remain accessible`() {\n        // Arrays stored in output must remain accessible across evaluations.\n\n        val graalEngine = engine as GraalJsEngine\n\n        graalEngine.evaluateScript(\"output.list = [1, 2, 3]\")\n\n        // Array length should be accessible\n        val length = graalEngine.evaluateScript(\"output.list.length\")\n        assertThat(length.toString()).isEqualTo(\"3\")\n\n        // Array elements should be accessible\n        val firstElement = graalEngine.evaluateScript(\"output.list[0]\")\n        assertThat(firstElement.toString()).isEqualTo(\"1\")\n    }\n\n    @Test\n    fun `objects stored in output should remain accessible`() {\n        // Objects stored in output must remain accessible across evaluations.\n\n        val graalEngine = engine as GraalJsEngine\n\n        graalEngine.evaluateScript(\"output.user = { name: 'Alice', age: 30 }\")\n\n        // Object properties should be accessible\n        val name = graalEngine.evaluateScript(\"output.user.name\")\n        assertThat(name.toString()).isEqualTo(\"Alice\")\n\n        val age = graalEngine.evaluateScript(\"output.user.age\")\n        assertThat(age.toString()).isEqualTo(\"30\")\n    }\n\n    @Test\n    fun `env var should not override static bindings`() {\n        // Static bindings (http, faker, output, maestro) must never be overridden\n        // by environment variables with the same name. The old code set static\n        // bindings after env vars, so this was never possible. The new single-context\n        // approach must preserve this guarantee.\n\n        val graalEngine = engine as GraalJsEngine\n\n        // Store a value in output to verify it survives\n        graalEngine.evaluateScript(\"output.myValue = 'preserved'\")\n\n        // Set env vars that collide with static binding names\n        graalEngine.putEnv(\"output\", \"should_not_override\")\n        graalEngine.putEnv(\"maestro\", \"should_not_override\")\n\n        // output should still be the real binding, not the env var string\n        val result = graalEngine.evaluateScript(\"output.myValue\")\n        assertThat(result.toString()).isEqualTo(\"preserved\")\n\n        // maestro should still be the real binding with platform info\n        val platform = graalEngine.evaluateScript(\"maestro.platform\")\n        assertThat(platform.toString()).isNotEqualTo(\"should_not_override\")\n    }\n\n}"
  },
  {
    "path": "maestro-test/src/test/kotlin/maestro/test/IntegrationTest.kt",
    "content": "package maestro.test\n\nimport com.google.common.truth.Truth.assertThat\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.SupervisorJob\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.launch\nimport kotlinx.coroutines.runBlocking\nimport kotlinx.coroutines.cancel\nimport kotlinx.coroutines.CancellationException\nimport kotlinx.coroutines.CompletableDeferred\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.Job\nimport kotlinx.coroutines.withTimeout\nimport kotlinx.coroutines.yield\nimport maestro.device.DeviceOrientation\nimport maestro.KeyCode\nimport maestro.Maestro\nimport maestro.MaestroException\nimport maestro.Point\nimport maestro.SwipeDirection\nimport maestro.orchestra.ApplyConfigurationCommand\nimport maestro.orchestra.AssertConditionCommand\nimport maestro.orchestra.BackPressCommand\nimport maestro.orchestra.Condition\nimport maestro.orchestra.DefineVariablesCommand\nimport maestro.orchestra.HideKeyboardCommand\nimport maestro.orchestra.ElementSelector\nimport maestro.orchestra.LaunchAppCommand\nimport maestro.orchestra.MaestroCommand\nimport maestro.orchestra.MaestroConfig\nimport maestro.orchestra.Orchestra\nimport maestro.orchestra.RunFlowCommand\nimport maestro.orchestra.error.UnicodeNotSupportedError\nimport maestro.js.JsEngine\nimport maestro.js.GraalJsEngine\nimport maestro.orchestra.util.Env.withDefaultEnvVars\nimport maestro.orchestra.util.Env.withEnv\nimport maestro.orchestra.yaml.YamlCommandReader\nimport maestro.test.drivers.FakeDriver\nimport maestro.test.drivers.FakeDriver.Event\nimport maestro.test.drivers.FakeLayoutElement\nimport maestro.test.drivers.FakeLayoutElement.Bounds\nimport maestro.test.drivers.FakeTimer\nimport maestro.utils.MaestroTimer\nimport org.junit.jupiter.api.AfterEach\nimport org.junit.jupiter.api.BeforeEach\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.assertThrows\nimport org.junit.jupiter.api.fail\nimport org.slf4j.LoggerFactory\nimport java.awt.Color\nimport java.io.File\nimport java.nio.file.Paths\nimport kotlin.system.measureTimeMillis\nimport javax.imageio.ImageIO\n\nclass IntegrationTest {\n\n    val fakeTimer = FakeTimer()\n\n    @BeforeEach\n    fun setUp() {\n        MaestroTimer.setTimerFunc(fakeTimer.timer())\n    }\n\n    @AfterEach\n    internal fun tearDown() {\n        File(\"028_env.mp4\").delete()\n        File(\"041_take_screenshot_with_filename.png\").delete()\n        File(\"099_screen_recording.mp4\").delete()\n        File(\"134_screenshots\").delete()\n        File(\"134_screenshots/filename.png\").delete()\n        File(\"135_recordings\").delete()\n        File(\"135_recordings/filename.mp4\").delete()\n        File(\"137_shard_device_env_vars_test-device_shard1_idx0.png\").delete()\n        File(\"138_take_cropped_screenshot_with_filename.png\").delete()\n    }\n\n    @Test\n    fun `Case 001 - Assert element visible by id`() {\n        // Given\n        val commands = readCommands(\"001_assert_visible_by_id\")\n\n        val driver = driver {\n            element {\n                id = \"element_id\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertNoInteraction()\n    }\n\n    @Test\n    fun `Case 002 - Assert element visible by text`() {\n        // Given\n        val commands = readCommands(\"002_assert_visible_by_text\")\n\n        val driver = driver {\n            element {\n                text = \"Element Text\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertNoInteraction()\n    }\n\n    @Test\n    fun `Case 003 - Assert element visible by size`() {\n        // Given\n        val commands = readCommands(\"003_assert_visible_by_size\")\n\n        val driver = driver {\n            element {\n                text = \"Element Text\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertNoInteraction()\n    }\n\n    @Test\n    fun `Case 004 - Assert visible - no element with id`() {\n        // Given\n        val commands = readCommands(\"004_assert_no_visible_element_with_id\")\n\n        val driver = driver {\n            element {\n                id = \"another_id\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        // When & Then\n        assertThrows<MaestroException.AssertionFailure> {\n            Maestro(driver).use {\n                runBlocking {\n                    orchestra(it).runFlow(commands)\n                }\n            }\n        }\n    }\n\n    @Test\n    fun `Case 005 - Assert visible - no element with text`() {\n        // Given\n        val commands = readCommands(\"005_assert_no_visible_element_with_text\")\n\n        val driver = driver {\n            element {\n                text = \"Some other text\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        // When & Then\n        assertThrows<MaestroException.AssertionFailure> {\n            Maestro(driver).use {\n                runBlocking {\n                    orchestra(it).runFlow(commands)\n                }\n            }\n        }\n    }\n\n    @Test\n    fun `Case 006 - Assert visible - no element with size`() {\n        // Given\n        val commands = readCommands(\"005_assert_no_visible_element_with_text\")\n\n        val driver = driver {\n            element {\n                text = \"Some other text\"\n                bounds = Bounds(0, 0, 101, 101)\n            }\n        }\n\n        // When & Then\n        assertThrows<MaestroException.AssertionFailure> {\n            Maestro(driver).use {\n                runBlocking {\n                    orchestra(it).runFlow(commands)\n                }\n            }\n        }\n    }\n\n    @Test\n    fun `Case 007 - Assert element visible by size with tolerance`() {\n        // Given\n        val commands = readCommands(\"007_assert_visible_by_size_with_tolerance\")\n\n        val driver = driver {\n            element {\n                text = \"Element Text\"\n                bounds = Bounds(0, 0, 101, 101)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertNoInteraction()\n    }\n\n    @Test\n    fun `Case 008 - Tap on element - Do not retry by default if no UI change`() {\n        // Given\n        val commands = readCommands(\"008_tap_on_element\")\n\n        val driver = driver {\n            element {\n                text = \"Primary button\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEventCount(Event.Tap(Point(50, 50)), expectedCount = 1)\n    }\n\n    @Test\n    fun `Case 008 - Tap on element - Do not retry if view hierarchy changed`() {\n        // Given\n        val commands = readCommands(\"008_tap_on_element\")\n\n        val driver = driver {\n            element {\n                text = \"Primary button\"\n                bounds = Bounds(0, 0, 100, 100)\n\n                onClick = { element ->\n                    element.text = \"Updated text\"\n                }\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEventCount(Event.Tap(Point(50, 50)), expectedCount = 1)\n    }\n\n    @Test\n    fun `Case 008 - Tap on element - Do not retry if screenshot changed`() {\n        // Given\n        val commands = readCommands(\"008_tap_on_element\")\n\n        val driver = driver {\n            element {\n                text = \"Primary button\"\n                bounds = Bounds(0, 0, 100, 100)\n\n                onClick = { element ->\n                    element.color = Color.RED\n                }\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEventCount(Event.Tap(Point(50, 50)), expectedCount = 1)\n    }\n\n    @Test\n    fun `Case 009 - Skip optional elements`() {\n        // Given\n        val commands = readCommands(\"009_skip_optional_elements\")\n\n        val driver = driver {\n            element {\n                text = \"Non Optional\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n    }\n\n    @Test\n    fun `Case 010 - Scroll`() {\n        // Given\n        val commands = readCommands(\"010_scroll\")\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.Scroll)\n    }\n\n    @Test\n    fun `Case 011 - Back press`() {\n        // Given\n        val commands = readCommands(\"011_back_press\")\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.BackPress)\n    }\n\n    @Test\n    fun `Case 012 - Input text`() {\n        // Given\n        val commands = readCommands(\"012_input_text\")\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.InputText(\"Hello World\"))\n        driver.assertHasEvent(Event.InputText(\"user@example.com\"))\n        driver.assertCurrentTextInput(\"Hello Worlduser@example.com\")\n    }\n\n    @Test\n    fun `Case 013 - Launch app`() {\n        // Given\n        val commands = readCommands(\"013_launch_app\")\n\n        val driver = driver {\n        }\n        driver.addInstalledApp(\"com.example.app\")\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.StopApp(\"com.example.app\"),\n                Event.LaunchApp(\"com.example.app\")\n            )\n        )\n    }\n\n    @Test\n    fun `Case 014 - Tap on point`() {\n        // Given\n        val commands = readCommands(\"014_tap_on_point\")\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.Tap(Point(100, 200)))\n    }\n\n    @Test\n    fun `Case 015 - Tap on element relative position`() {\n        // Given\n        val commands = readCommands(\"015_element_relative_position\")\n\n        val driver = driver {\n            element {\n                text = \"Top Left\"\n                bounds = Bounds(0, 100, 100, 200)\n            }\n            element {\n                text = \"Top\"\n                bounds = Bounds(100, 100, 200, 200)\n            }\n            element {\n                text = \"Top Right\"\n                bounds = Bounds(200, 100, 300, 200)\n            }\n            element {\n                text = \"Left\"\n                bounds = Bounds(0, 200, 100, 300)\n            }\n            element {\n                text = \"Middle\"\n                bounds = Bounds(100, 200, 200, 300)\n            }\n            element {\n                text = \"Right\"\n                bounds = Bounds(200, 200, 300, 300)\n            }\n            element {\n                text = \"Bottom Left\"\n                bounds = Bounds(0, 300, 100, 400)\n            }\n            element {\n                text = \"Bottom\"\n                bounds = Bounds(100, 300, 200, 400)\n            }\n            element {\n                text = \"Bottom Right\"\n                bounds = Bounds(200, 300, 300, 400)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.Tap(Point(150, 150)), // Top\n                Event.Tap(Point(150, 350)), // Bottom\n                Event.Tap(Point(50, 250)), // Left\n                Event.Tap(Point(250, 250)), // Right\n                Event.Tap(Point(50, 150)), // Top Left\n                Event.Tap(Point(250, 150)), // Top Right\n                Event.Tap(Point(50, 350)), // Bottom Left\n                Event.Tap(Point(250, 350)), // Bottom Right\n            )\n        )\n    }\n\n    @Test\n    fun `Case 016 - Multiline text`() {\n        // Given\n        val commands = readCommands(\"016_multiline_text\")\n\n        val driver = driver {\n            element {\n                text = \"Hello World\\nHere is a second line\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.Tap(Point(50, 50)))\n    }\n\n    @Test\n    fun `Case 017 - Swipe`() {\n        // Given\n        val commands = readCommands(\"017_swipe\")\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.Swipe(start = Point(100, 500), End = Point(100, 200), durationMs = 3000))\n    }\n\n    @Test\n    fun `Case 018 - Contains child`() {\n        // Given\n        val commands = readCommands(\"018_contains_child\")\n\n        val driver = driver {\n            element {\n                bounds = Bounds(0, 0, 200, 200)\n\n                element {\n                    text = \"Child\"\n                    bounds = Bounds(0, 0, 100, 100)\n                }\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.Tap(Point(100, 100)))\n    }\n\n    @Test\n    fun `Case 019 - Do not wait until visible`() {\n        // Given\n        val commands = readCommands(\"019_dont_wait_for_visibility\")\n\n        val driver = driver {\n            element {\n                text = \"Button\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n            element {\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.Tap(Point(50, 50)))\n        fakeTimer.assertNoEvent(MaestroTimer.Reason.WAIT_UNTIL_VISIBLE)\n    }\n\n    @Test\n    fun `Case 020 - Parse config`() {\n        // When\n        val commands = readCommands(\"020_parse_config\")\n\n        // Then\n        assertThat(commands).isEqualTo(\n            listOf(\n                MaestroCommand(\n                    DefineVariablesCommand(\n                        env = mapOf(\n                            \"MAESTRO_FILENAME\" to \"020_parse_config\",\n                            \"MAESTRO_SHARD_ID\" to \"1\",\n                            \"MAESTRO_SHARD_INDEX\" to \"0\",\n                        )\n                    )\n                ),\n                MaestroCommand(\n                    ApplyConfigurationCommand(\n                        config = MaestroConfig(\n                            appId = \"com.example.app\"\n                        )\n                    )\n                ),\n                MaestroCommand(\n                    LaunchAppCommand(\n                        appId = \"com.example.app\"\n                    )\n                )\n            )\n        )\n    }\n\n    @Test\n    fun `Case 021 - Launch app with clear state`() {\n        // Given\n        val commands = readCommands(\"021_launch_app_with_clear_state\")\n\n        val driver = driver {\n        }\n        driver.addInstalledApp(\"com.example.app\")\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.ClearState(\"com.example.app\"))\n        driver.assertHasEvent(Event.LaunchApp(\"com.example.app\"))\n    }\n\n    @Test\n    fun `Case 022 - Launch app that is not installed`() {\n        // Given\n        val commands = readCommands(\"022_launch_app_that_is_not_installed\")\n\n        val driver = driver {\n        }\n\n        // When & Then\n        assertThrows<MaestroException.UnableToLaunchApp> {\n            Maestro(driver).use {\n                runBlocking {\n                    orchestra(it).runFlow(commands)\n                }\n            }\n        }\n    }\n\n    @Test\n    fun `Case 025 - Tap on element relative position using shortcut`() {\n        // Given\n        val commands = readCommands(\"025_element_relative_position_shortcut\")\n\n        val driver = driver {\n            element {\n                text = \"Top Left\"\n                bounds = Bounds(0, 100, 100, 200)\n            }\n            element {\n                text = \"Top\"\n                bounds = Bounds(100, 100, 200, 200)\n            }\n            element {\n                text = \"Top Right\"\n                bounds = Bounds(200, 100, 300, 200)\n            }\n            element {\n                text = \"Left\"\n                bounds = Bounds(0, 200, 100, 300)\n            }\n            element {\n                text = \"Middle\"\n                bounds = Bounds(100, 200, 200, 300)\n            }\n            element {\n                text = \"Right\"\n                bounds = Bounds(200, 200, 300, 300)\n            }\n            element {\n                text = \"Bottom Left\"\n                bounds = Bounds(0, 300, 100, 400)\n            }\n            element {\n                text = \"Bottom\"\n                bounds = Bounds(100, 300, 200, 400)\n            }\n            element {\n                text = \"Bottom Right\"\n                bounds = Bounds(200, 300, 300, 400)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.Tap(Point(150, 150)), // Top\n                Event.Tap(Point(150, 350)), // Bottom\n                Event.Tap(Point(50, 250)), // Left\n                Event.Tap(Point(250, 250)), // Right\n                Event.Tap(Point(50, 150)), // Top Left\n                Event.Tap(Point(250, 150)), // Top Right\n                Event.Tap(Point(50, 350)), // Bottom Left\n                Event.Tap(Point(250, 350)), // Bottom Right\n            )\n        )\n    }\n\n    @Test\n    fun `Case 026 - Assert not visible - no element with id`() {\n        // Given\n        val commands = readCommands(\"026_assert_not_visible\")\n\n        val driver = driver {\n            element {\n                id = \"another_id\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n    }\n\n    @Test\n    fun `Case 026 - Assert not visible - element with id is present`() {\n        // Given\n        val commands = readCommands(\"026_assert_not_visible\")\n\n        val driver = driver {\n            element {\n                id = \"element_id\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        // When & Then\n        assertThrows<MaestroException.AssertionFailure> {\n            Maestro(driver).use {\n                runBlocking {\n                    orchestra(it).runFlow(commands)\n                }\n            }\n        }\n    }\n\n    @Test\n    fun `Case 027 - Open link`() {\n        // Given\n        val commands = readCommands(\"027_open_link\")\n\n        val driver = driver {}\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.OpenLink(\"https://example.com\")\n            )\n        )\n    }\n\n    @Test\n    fun `Case 028 - Env`() {\n        // Given\n        val commands = readCommands(\"028_env\") {\n            mapOf(\n                \"APP_ID\" to \"com.example.app\",\n                \"BUTTON_ID\" to \"button_id\",\n                \"BUTTON_TEXT\" to \"button_text\",\n                \"PASSWORD\" to \"testPassword\",\n                \"NON_EXISTENT_TEXT\" to \"nonExistentText\",\n                \"NON_EXISTENT_ID\" to \"nonExistentId\",\n                \"URL\" to \"secretUrl\",\n                \"LAT\" to \"37.82778\",\n                \"LNG\" to \"-122.48167\",\n            )\n        }\n\n        val driver = driver {\n\n            element {\n                id = \"button_id\"\n                text = \"button_text\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n\n        }\n        driver.addInstalledApp(\"com.example.app\")\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.LaunchApp(appId = \"com.example.app\"),\n                Event.Tap(Point(50, 50)),\n                Event.Tap(Point(50, 50)),\n                Event.InputText(\"\\${PASSWORD} is testPassword\"),\n                Event.OpenLink(\"https://example.com/secretUrl\"),\n                Event.SetLocation(latitude = 37.82778, longitude = -122.48167),\n                Event.StartRecording,\n            )\n        )\n        assert(File(\"028_env.mp4\").exists())\n    }\n\n    @Test\n    fun `Case 029 - Long press on element`() {\n        // Given\n        val commands = readCommands(\"029_long_press_on_element\")\n\n        val driver = driver {\n            element {\n                text = \"Primary button\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.LongPress(Point(50, 50)))\n    }\n\n    @Test\n    fun `Case 030 - Long press on point`() {\n        // Given\n        val commands = readCommands(\"030_long_press_on_point\")\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.LongPress(Point(100, 200)))\n    }\n\n    @Test\n    fun `Case 031 - Traits`() {\n        // Given\n        val commands = readCommands(\"031_traits\")\n\n        val driver = driver {\n            element {\n                text = \"Text\"\n                bounds = Bounds(0, 0, 200, 100)\n            }\n            element {\n                text = \"Square\"\n                bounds = Bounds(0, 100, 100, 200)\n            }\n            element {\n                text = String(CharArray(500))   // Long text\n                bounds = Bounds(0, 200, 200, 400)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.Tap(Point(100, 50)),  // Text\n                Event.Tap(Point(50, 150)),  // Square\n                Event.Tap(Point(100, 300)),  // Long text\n            )\n        )\n    }\n\n    @Test\n    fun `Case 032 - Element index`() {\n        // Given\n        val commands = readCommands(\"032_element_index\")\n\n        val driver = driver {\n            element {\n                text = \"Item 2\"\n                bounds = Bounds(0, 200, 100, 300)\n            }\n            element {\n                text = \"Item 1\"\n                bounds = Bounds(0, 100, 100, 200)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.Tap(Point(50, 150)),  // Item 1\n                Event.Tap(Point(50, 250)),  // Item 2\n            )\n        )\n    }\n\n    @Test\n    fun `Case 033 - Text with number`() {\n        // Given\n        val commands = readCommands(\"033_int_text\")\n\n        val driver = driver {\n            element {\n                text = \"2022\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.Tap(Point(50, 50)))\n    }\n\n    @Test\n    fun `Case 034 - Press key`() {\n        // Given\n        val commands = readCommands(\"034_press_key\")\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        driver.assertHasEvent(Event.PressKey(KeyCode.ENTER))\n        driver.assertHasEvent(Event.PressKey(KeyCode.BACKSPACE))\n        driver.assertHasEvent(Event.PressKey(KeyCode.HOME))\n        driver.assertHasEvent(Event.PressKey(KeyCode.BACK))\n        driver.assertHasEvent(Event.PressKey(KeyCode.VOLUME_UP))\n        driver.assertHasEvent(Event.PressKey(KeyCode.VOLUME_DOWN))\n        driver.assertHasEvent(Event.PressKey(KeyCode.LOCK))\n        driver.assertHasEvent(Event.PressKey(KeyCode.REMOTE_UP))\n        driver.assertHasEvent(Event.PressKey(KeyCode.REMOTE_DOWN))\n        driver.assertHasEvent(Event.PressKey(KeyCode.REMOTE_LEFT))\n        driver.assertHasEvent(Event.PressKey(KeyCode.REMOTE_RIGHT))\n        driver.assertHasEvent(Event.PressKey(KeyCode.REMOTE_CENTER))\n        driver.assertHasEvent(Event.PressKey(KeyCode.REMOTE_PLAY_PAUSE))\n        driver.assertHasEvent(Event.PressKey(KeyCode.REMOTE_STOP))\n        driver.assertHasEvent(Event.PressKey(KeyCode.REMOTE_NEXT))\n        driver.assertHasEvent(Event.PressKey(KeyCode.REMOTE_PREVIOUS))\n        driver.assertHasEvent(Event.PressKey(KeyCode.REMOTE_REWIND))\n        driver.assertHasEvent(Event.PressKey(KeyCode.REMOTE_FAST_FORWARD))\n        driver.assertHasEvent(Event.PressKey(KeyCode.POWER))\n        driver.assertHasEvent(Event.PressKey(KeyCode.TAB))\n        driver.assertHasEvent(Event.PressKey(KeyCode.REMOTE_SYSTEM_NAVIGATION_UP))\n        driver.assertHasEvent(Event.PressKey(KeyCode.REMOTE_SYSTEM_NAVIGATION_DOWN))\n        driver.assertHasEvent(Event.PressKey(KeyCode.REMOTE_BUTTON_A))\n        driver.assertHasEvent(Event.PressKey(KeyCode.REMOTE_BUTTON_B))\n        driver.assertHasEvent(Event.PressKey(KeyCode.REMOTE_MENU))\n        driver.assertHasEvent(Event.PressKey(KeyCode.TV_INPUT))\n        driver.assertHasEvent(Event.PressKey(KeyCode.TV_INPUT_HDMI_1))\n        driver.assertHasEvent(Event.PressKey(KeyCode.TV_INPUT_HDMI_2))\n        driver.assertHasEvent(Event.PressKey(KeyCode.TV_INPUT_HDMI_3))\n    }\n\n    @Test\n    fun `Case 035 - Ignore duplicates when refreshing item position`() {\n        // Given\n        val commands = readCommands(\"035_refresh_position_ignore_duplicates\")\n\n        val driver = driver {\n\n            element {\n                id = \"icon\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n\n            element {\n                text = \"Item\"\n                bounds = Bounds(0, 100, 100, 200)\n            }\n\n            element {\n                id = \"icon\"\n                bounds = Bounds(0, 200, 100, 300)\n            }\n\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        driver.assertHasEvent(Event.Tap(Point(50, 250)))\n    }\n\n    @Test\n    fun `Case 036 - Erase text with numbers`() {\n        // Given\n        val commands = readCommands(\"036_erase_text\")\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        driver.assertCurrentTextInput(\"Hello\")\n    }\n\n    @Test\n    fun `Case 037 - Throw exception when trying to input text with unicode characters`() {\n        // Given\n        val commands = readCommands(\"037_unicode_input\")\n\n        val driver = driver {\n        }\n\n        // When & Then\n        assertThrows<UnicodeNotSupportedError> {\n            Maestro(driver).use {\n                runBlocking {\n                    orchestra(it).runFlow(commands)\n                }\n            }\n        }\n    }\n\n    @Test\n    fun `Case 038 - Partial id matching`() {\n        // Given\n        val commands = readCommands(\"038_partial_id\")\n\n        val driver = driver {\n            element {\n                id = \"com.google.android.inputmethod.latin:id/another_keyboard_area\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n\n            element {\n                id = \"com.google.android.inputmethod.latin:id/keyboard_area\"\n                bounds = Bounds(0, 100, 100, 200)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        driver.assertEvents(\n            listOf(\n                Event.Tap(Point(50, 150)),\n                Event.Tap(Point(50, 50)),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 039 - Hide keyboard`() {\n        // Given\n        val commands = readCommands(\"039_hide_keyboard\")\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.HideKeyboard,\n            )\n        )\n    }\n\n    @Test\n    fun `Case 040 - Escape regex characters`() {\n        // Given\n        val commands = readCommands(\"040_escape_regex\")\n\n        val driver = driver {\n            element {\n                text = \"+123456\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        driver.assertHasEvent(Event.Tap(Point(50, 50)))\n    }\n\n    @Test\n    fun `Case 041 - Take screenshot`() {\n        // Given\n        val commands = readCommands(\"041_take_screenshot\")\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.TakeScreenshot,\n            )\n        )\n        assert(File(\"041_take_screenshot_with_filename.png\").exists())\n    }\n\n    @Test\n    fun `Case 042 - Extended waitUntil`() {\n        // Given\n        val commands = readCommands(\"042_extended_wait\")\n\n        val driver = driver {\n            element {\n                text = \"Item\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertNoInteraction()\n    }\n\n    @Test\n    fun `Case 042 - Extended waitUntil - element not found`() {\n        // Given\n        val commands = readCommands(\"042_extended_wait\")\n\n        val driver = driver {\n        }\n\n        // When running flow - throw an exception\n        assertThrows<MaestroException.AssertionFailure> {\n            Maestro(driver).use {\n                runBlocking {\n                    orchestra(it).runFlow(commands)\n                }\n            }\n        }\n    }\n\n    @Test\n    fun `Case 043 - Stop app`() {\n        // Given\n        val commands = readCommands(\"043_stop_app\")\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.StopApp(\"com.example.app\"))\n        driver.assertHasEvent(Event.StopApp(\"another.app\"))\n    }\n\n    @Test\n    fun `Case 044 - Clear state`() {\n        // Given\n        val commands = readCommands(\"044_clear_state\")\n\n        val driver = driver {\n        }\n\n        driver.addInstalledApp(\"com.example.app\")\n        driver.addInstalledApp(\"another.app\")\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.ClearState(\"com.example.app\"))\n        driver.assertHasEvent(Event.ClearState(\"another.app\"))\n    }\n\n    @Test\n    fun `Case 045 - Clear keychain`() {\n        // Given\n        val commands = readCommands(\"045_clear_keychain\")\n\n        val driver = driver {\n        }\n\n        driver.addInstalledApp(\"com.example.app\")\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.ClearKeychain,\n                Event.ClearKeychain,\n                Event.LaunchApp(\"com.example.app\"),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 046 - Run flow`() {\n        // Given\n        val commands = readCommands(\"046_run_flow\")\n\n        val driver = driver {\n            element {\n                text = \"Primary button\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        driver.addInstalledApp(\"com.example.app\")\n        driver.addInstalledApp(\"com.other.app\")\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.LaunchApp(\"com.example.app\"),\n                Event.Tap(Point(50, 50)),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 047 - Nested run flow`() {\n        // Given\n        val commands = readCommands(\"047_run_flow_nested\")\n\n        val driver = driver {\n            element {\n                text = \"Primary button\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n            element {\n                text = \"Secondary button\"\n                bounds = Bounds(0, 0, 200, 200)\n            }\n        }\n\n        driver.addInstalledApp(\"com.example.app\")\n        driver.addInstalledApp(\"com.other.app\")\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.LaunchApp(\"com.example.app\"),\n                Event.Tap(Point(50, 50)),\n                Event.Tap(Point(100, 100)),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 048 - tapOn prioritises clickable elements`() {\n        // Given\n        val commands = readCommands(\"048_tapOn_clickable\")\n\n        val driver = driver {\n            element {\n                text = \"Button\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n            element {\n                text = \"Button\"\n                bounds = Bounds(0, 0, 200, 200)\n                clickable = true\n\n                onClick = {\n                    text = \"Clicked\"\n                }\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.Tap(Point(100, 100)),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 049 - Run flow conditionally`() {\n        // Given\n        val commands = readCommands(\"049_run_flow_conditionally\") {\n            mapOf(\n                \"NOT_CLICKED\" to \"Not Clicked\"\n            )\n        }\n\n        val driver = driver {\n            val indicator = element {\n                text = \"Not Clicked\"\n                bounds = Bounds(0, 100, 0, 200)\n            }\n\n            element {\n                text = \"button\"\n                bounds = Bounds(0, 0, 100, 100)\n                onClick = {\n                    indicator.text = \"Clicked\"\n                }\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEventCount(Event.Tap(Point(50, 50)), 1)\n    }\n\n    @Test\n    fun `Case 051 - Set location`() {\n        // Given\n        val commands = readCommands(\"051_set_location\")\n\n        val driver = driver {\n        }\n\n        driver.addInstalledApp(\"com.example.app\")\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.LaunchApp(\"com.example.app\"),\n                Event.SetLocation(12.5266, 78.2150),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 052 - Input random`() {\n        // Given\n        val commands = readCommands(\"052_text_random\")\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertAllEvent(condition = {\n            ((it as? Event.InputText?)?.text?.length ?: -1) >= 5\n        })\n        driver.assertAnyEvent(condition = {\n            val number = try {\n                (it as? Event.InputText?)?.text?.toInt() ?: -1\n            } catch (e: NumberFormatException) {\n                -1\n            }\n            number in 10000..99999\n        })\n\n        driver.assertAnyEvent(condition = {\n            val text = (it as? Event.InputText?)?.text ?: \"\"\n            text.contains(\"@\")\n        })\n\n        driver.assertAnyEvent(condition = {\n            val text = (it as? Event.InputText?)?.text ?: \"\"\n            text.contains(\" \")\n        })\n    }\n\n    @Test\n    fun `Case 053 - Repeat N times`() {\n        // Given\n        val commands = readCommands(\"053_repeat_times\")\n\n        var counter = 0\n        val driver = driver {\n\n            val indicator = element {\n                text = counter.toString()\n                bounds = Bounds(0, 100, 100, 200)\n            }\n\n            element {\n                text = \"Button\"\n                bounds = Bounds(0, 0, 100, 100)\n                onClick = {\n                    counter++\n                    indicator.text = counter.toString()\n                }\n            }\n\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.Tap(Point(50, 50)),\n                Event.Tap(Point(50, 50)),\n                Event.Tap(Point(50, 50)),\n                Event.Tap(Point(50, 50)),\n                Event.Tap(Point(50, 50)),\n                Event.Tap(Point(50, 50)),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 054 - Enabled state`() {\n        // Given\n        val commands = readCommands(\"054_enabled\")\n\n        val driver = driver {\n\n            element {\n                text = \"Button\"\n                bounds = Bounds(0, 0, 100, 100)\n                onClick = {\n                    enabled = false\n                }\n            }\n\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEventCount(\n            Event.Tap(Point(50, 50)),\n            1\n        )\n    }\n\n    @Test\n    fun `Case 055 - Tap on element - Compare regex`() {\n        // Given\n        val commands = readCommands(\"055_compare_regex\")\n\n        val driver = driver {\n            element {\n                text = \"(Secondary button)\"\n                bounds = Bounds(0, 100, 100, 200)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.Tap(Point(50, 150)))\n    }\n\n    @Test\n    fun `Case 056 - Ignore an error in Orchestra`() {\n        // Given\n        val commands = readCommands(\"056_ignore_error\")\n\n        val driver = driver {\n            element {\n                text = \"Button\"\n                bounds = Bounds(0, 100, 100, 200)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(\n                    maestro = it,\n                    onCommandFailed = { _, command, _ ->\n                        if (command.tapOnElement?.selector?.textRegex == \"Non existent text\") {\n                            Orchestra.ErrorResolution.CONTINUE\n                        } else {\n                            Orchestra.ErrorResolution.FAIL\n                        }\n                    },\n                ).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.Tap(Point(50, 150)))\n    }\n\n    @Test\n    fun `Case 057 - Pass inner env variables to runFlow`() {\n        // Given\n        val commands = readCommands(\"057_runFlow_env\") {\n            mapOf(\n                \"OUTER_ENV\" to \"Outer Parameter\"\n            )\n        }\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.InputText(\"Inner Parameter\"))\n        driver.assertHasEvent(Event.InputText(\"Outer Parameter\"))\n        driver.assertHasEvent(Event.InputText(\"Overridden Parameter\"))\n    }\n\n    @Test\n    fun `Case 058 - Inline env parameters`() {\n        // Given\n        val commands = readCommands(\"058_inline_env\")\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.InputText(\"Inline Parameter\"))\n        driver.assertHasEvent(Event.InputText(\"Overridden Parameter\"))\n    }\n\n    @Test\n    fun `Case 059 - Do a directional swipe command`() {\n        // given\n        val commands = readCommands(\"059_directional_swipe_command\")\n        val driver = driver { }\n\n        // when\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // then\n        driver.assertHasEvent(Event.SwipeWithDirection(SwipeDirection.RIGHT, 500))\n    }\n\n    @Test\n    fun `Case 060 - Pass env param to an env param`() {\n        // given\n        val commands = readCommands(\"060_pass_env_to_env\") {\n            mapOf(\n                \"PARAM\" to \"Value\"\n            )\n        }\n        val driver = driver { }\n\n        // when\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // then\n        driver.assertEventCount(Event.InputText(\"Value\"), expectedCount = 3)\n    }\n\n    @Test\n    fun `Case 061 - Launch app without stopping it`() {\n        // given\n        val commands = readCommands(\"061_launchApp_withoutStopping\")\n        val driver = driver { }\n        driver.addInstalledApp(\"com.example.app\")\n\n        // when\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // then\n        driver.assertEvents(\n            listOf(\n                Event.LaunchApp(\"com.example.app\"),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 062 - Copy paste text`() {\n\n        // Given\n        val commands = readCommands(\"062_copy_paste_text\")\n\n        val myCopiedText = \"Some text to copy\"\n\n        val driver = driver {\n            element {\n                id = \"com.google.android.inputmethod.latin:id/myId\"\n                text = myCopiedText\n                bounds = Bounds(0, 100, 100, 200)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertCurrentTextInput(myCopiedText)\n    }\n\n    @Test\n    fun `Case 063 - Javascript injection`() {\n        // given\n        val commands = readCommands(\"063_js_injection\")\n        val driver = driver { }\n\n        // when\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // then\n        driver.assertEvents(\n            listOf(\n                Event.InputText(\"1\"),\n                Event.InputText(\"2\"),\n                Event.InputText(\"12\"),\n                Event.InputText(\"3\"),\n                Event.InputText(\"\\${A} \\${B} 1 2\"),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 064 - Javascript files`() {\n        // given\n        val commands = readCommands(\"064_js_files\")\n        val driver = driver { }\n\n        // when\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // then\n        driver.assertEvents(\n            listOf(\n                Event.InputText(\"Main\"),\n                Event.InputText(\"Sub\"),\n                Event.InputText(\"Sub\"),\n                Event.InputText(\"Main\"),\n                Event.InputText(\"Sub\"),\n                Event.InputText(\"064_js_files\"),\n                Event.InputText(\"Hello, Input Parameter!\"),\n                Event.InputText(\"Hello, Evaluated Parameter!\"),\n                Event.InputText(\"064_js_files\"),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 065 - When True condition`() {\n        // given\n        val commands = readCommands(\"065_when_true\")\n        val driver = driver { }\n\n        // when\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // then\n        driver.assertEvents(\n            listOf(\n                Event.InputText(\"True\"),\n                Event.InputText(\"String\"),\n                Event.InputText(\"Positive Int\"),\n                Event.InputText(\"Object\"),\n                Event.InputText(\"Array\"),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 066 - Copy text into JS variable`() {\n        // Given\n        val commands = readCommands(\"066_copyText_jsVar\")\n\n        val myCopiedText = \"Maestro\"\n\n        val driver = driver {\n            element {\n                id = \"Field\"\n                text = myCopiedText\n                bounds = Bounds(0, 100, 100, 200)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.InputText(\"Hello, Maestro\"),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 067 - Assert True - Pass`() {\n        // Given\n        val commands = readCommands(\"067_assertTrue_pass\")\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n    }\n\n    @Test\n    fun `Case 067 - Assert True - Fail`() {\n        // Given\n        val commands = readCommands(\"067_assertTrue_fail\")\n\n        val driver = driver {\n        }\n\n        // Then\n        assertThrows<MaestroException.AssertionFailure> {\n            Maestro(driver).use {\n                runBlocking {\n                    orchestra(it).runFlow(commands)\n                }\n            }\n        }\n    }\n\n    @Test\n    fun `Case 068 - Erase all text`() {\n        // given\n        val commands = readCommands(\"068_erase_all_text\")\n        val driver = driver {\n        }\n\n        // when\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        driver.assertCurrentTextInput(\"\")\n    }\n\n    @Test\n    fun `Case 069 - Wait for animation to end`() {\n        // given\n        val commands = readCommands(\"069_wait_for_animation_to_end\")\n        val driver = driver {\n        }\n\n        // when\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.TakeScreenshot,\n                Event.TakeScreenshot\n            )\n        )\n    }\n\n    @Test\n    fun `Case 070 - Evaluate JS inline`() {\n        // Given\n        val commands = readCommands(\"070_evalScript\")\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.InputText(\"2\"),\n                Event.InputText(\"Result is: 2\"),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 071 - Tap on relative point`() {\n        // Given\n        val commands = readCommands(\"071_tapOnRelativePoint\")\n\n        val driver = driver {\n        }\n\n        val deviceInfo = driver.deviceInfo()\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.Tap(Point(0, 0)),\n                Event.Tap(Point(deviceInfo.widthGrid, deviceInfo.heightGrid)),\n                Event.Tap(Point(deviceInfo.widthGrid / 2, deviceInfo.heightGrid / 2)),\n                Event.Tap(Point(deviceInfo.widthGrid / 4, deviceInfo.heightGrid / 4)),\n                Event.Tap(Point(deviceInfo.widthGrid / 4, deviceInfo.heightGrid / 4)),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 072 - Assert element visible by id`() {\n        // Given\n        val commands = readCommands(\"072_searchDepthFirst\")\n\n        val driver = driver {\n            element {\n                text = \"Element\"\n                bounds = Bounds(0, 0, 100, 100)\n\n                element {\n                    text = \"Element\"\n                    bounds = Bounds(0, 0, 50, 50)\n                }\n            }\n\n            element {\n                text = \"Element\"\n                bounds = Bounds(0, 100, 100, 200)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.Tap(Point(25, 25)))\n    }\n\n    @Test\n    fun `Case 073 - Handle linebreaks`() {\n        // Given\n        val commands = readCommands(\"073_handle_linebreaks\")\n\n        val driver = driver {\n            val indicator = element {\n                text = \"Indicator\"\n                bounds = Bounds(0, 100, 100, 100)\n            }\n\n            element {\n                text = \"Hello\\nWorld\"\n                bounds = Bounds(0, 0, 100, 100)\n\n                onClick = {\n                    indicator.text += \"!\"\n                }\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEventCount(Event.Tap(Point(50, 50)), expectedCount = 2)\n    }\n\n    @Test\n    fun `Case 074 - Directional swipe on elements`() {\n        // given\n        val commands = readCommands(\"074_directional_swipe_element\")\n        val elementBounds = Bounds(0, 100, 100, 100)\n        val driver = driver {\n            element {\n                text = \"swiping element\"\n                bounds = elementBounds\n            }\n        }\n\n        // when\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // then\n        driver.assertHasEvent(\n            Event.SwipeElementWithDirection(\n                Point(50, 100),\n                SwipeDirection.RIGHT,\n                400\n            )\n        )\n    }\n\n    @Test\n    fun `Case 075 - Repeat while`() {\n        // Given\n        val commands = readCommands(\"075_repeat_while\")\n        val driver = driver {\n            var counter = 0\n\n            val counterView = element {\n                text = \"Value 0\"\n                bounds = Bounds(0, 100, 100, 100)\n            }\n\n            element {\n                text = \"Button\"\n                bounds = Bounds(0, 0, 100, 100)\n                onClick = {\n                    counter++\n                    counterView.text = \"Value $counter\"\n                }\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failures\n\n        driver.assertEventCount(\n            Event.Tap(Point(50, 50)),\n            expectedCount = 3\n        )\n    }\n\n    @Test\n    fun `Case 076 - Optional assertion`() {\n        // Given\n        val commands = readCommands(\"076_optional_assertion\")\n\n        val driver = driver {\n            // No elements\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n    }\n\n    @Test\n    fun `Case 077 - Env special characters`() {\n        // Given\n        val commands = readCommands(\"077_env_special_characters\") {\n            mapOf(\n                \"OUTER\" to \"!@#\\$&*()_+{}|:\\\"<>?[]\\\\\\\\;',./\"\n            )\n        }\n\n        val driver = driver {\n            // No elements\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        driver.assertEvents(\n            listOf(\n                Event.InputText(\"!@#\\$&*()_+{}|:\\\"<>?[]\\\\\\\\;',./\"),\n                Event.InputText(\"!@#\\$&*()_+{}|:\\\"<>?[]\\\\\\\\;',./\"),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 078 - Swipe with relative coordinates`() {\n        // given\n        val commands = readCommands(\"078_swipe_relative\")\n        val driver = driver {\n        }\n        val deviceInfo = driver.deviceInfo()\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        val expectedStart = Point(deviceInfo.widthGrid / 2, deviceInfo.heightGrid * 30 / 100)\n        val expectedEnd = Point(deviceInfo.widthGrid / 2, deviceInfo.heightGrid * 60 / 100)\n        driver.assertHasEvent(\n            Event.Swipe(start = expectedStart, End = expectedEnd, durationMs = 3000)\n        )\n    }\n\n    @Test\n    fun `Case 079 - Scroll until view is visible - no view`() {\n        // Given\n        val commands = readCommands(\"079_scroll_until_visible\")\n\n        // No view\n        val driver = driver {\n            // No elements\n        }\n\n        // Then fail\n        assertThrows<MaestroException.ElementNotFound> {\n            Maestro(driver).use {\n                runBlocking {\n                    assertThat(orchestra(it).runFlow(commands))\n                }\n            }\n        }\n    }\n\n    @Test\n    fun `Case 079-2 - Scroll until view is visible - with view`() {\n        // Given\n        val commands = readCommands(\"079_scroll_until_visible\")\n        val info = driver { }.deviceInfo()\n\n        val elementBounds = Bounds(0, 0 + info.heightGrid, 100, 100 + info.heightGrid)\n        val driver = driver {\n            element {\n                text = \"Test\"\n                bounds = elementBounds\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                assertThat(orchestra(it).runFlow(commands)).isTrue()\n            }\n        }\n\n        // Then\n        driver.assertEvents(\n            listOf(\n                Event.SwipeElementWithDirection(Point(270, 480), SwipeDirection.UP, 1),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 080 - Hierarchy pruning assert visible`() {\n        // Given\n        val commands = readCommands(\"080_hierarchy_pruning_assert_visible\")\n\n        val info = driver {}.deviceInfo()\n\n        val driver = driver {\n            element {\n                id = \"root\"\n                bounds = Bounds(0, 0, 500, 500)\n\n                element {\n                    id = \"visible_1\"\n                    bounds = Bounds(0, 0, 100, 100)\n                }\n\n                element {\n                    id = \"visible_2\"\n                    bounds = Bounds(info.widthGrid - 50, 0, info.widthGrid + 100, 100)\n                }\n\n                element {\n                    id = \"visible_3\"\n                    bounds = Bounds(0, info.heightGrid - 50, 100, info.heightGrid + 100)\n                }\n\n                element {\n                    id = \"visible_4\"\n                    bounds = Bounds(-100, -100, info.widthGrid + 200, info.heightGrid + 200)\n                }\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertNoInteraction()\n    }\n\n    @Test\n    fun `Case 081 - Hierarchy pruning assert not visible`() {\n        // Given\n        val commands = readCommands(\"081_hierarchy_pruning_assert_not_visible\")\n\n        val info = driver {}.deviceInfo()\n\n        val driver = driver {\n            element {\n                id = \"root\"\n                bounds = Bounds(0, 0, 500, 500)\n\n                element {\n                    id = \"not_visible_1\"\n                    bounds = Bounds(-100, -100, 0, 0)\n                }\n\n                element {\n                    id = \"not_visible_2\"\n                    bounds = Bounds(info.widthGrid, 0, info.widthGrid + 100, 100)\n                }\n\n                element {\n                    id = \"not_visible_3\"\n                    bounds = Bounds(0, info.heightGrid, 100, info.heightGrid + 100)\n                }\n\n                element {\n                    id = \"not_visible_4\"\n                    bounds = Bounds(0, info.heightGrid - 10, 100, info.heightGrid + 100)\n                }\n            }\n        }\n\n        // When & Then\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertNoInteraction()\n    }\n\n    @Test\n    fun `Case 082 - Repeat while true`() {\n        // Given\n        val commands = readCommands(\"082_repeat_while_true\")\n        val driver = driver {\n            var counter = 0\n\n            val counterView = element {\n                text = \"Value 0\"\n                bounds = Bounds(0, 100, 100, 100)\n            }\n\n            element {\n                text = \"Button\"\n                bounds = Bounds(0, 0, 100, 100)\n                onClick = {\n                    counter++\n                    counterView.text = \"Value $counter\"\n                }\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failures\n        driver.assertEventCount(\n            Event.Tap(Point(50, 50)),\n            expectedCount = 3\n        )\n    }\n\n    @Test\n    fun `Case 083 - Assert on properties`() {\n        // Given\n        val commands = readCommands(\"083_assert_properties\")\n\n        val driver = driver {\n            val field = element {\n                text = \"Field\"\n                checked = true\n                selected = true\n                focused = true\n                bounds = Bounds.ofSize(width = 100, height = 100)\n            }\n\n            element {\n                text = \"Flip\"\n                bounds = Bounds.ofSize(width = 100, height = 100)\n                    .translate(y = 100)\n                onClick = {\n                    field.checked = field.checked?.not()\n                    field.selected = field.selected?.not()\n                    field.enabled = field.enabled?.not()\n                    field.focused = field.focused?.not()\n                }\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.Tap(Point(50, 150)))\n    }\n\n    @Test\n    fun `Case 084 - Open Browser`() {\n        // given\n        val commands = readCommands(\"084_open_browser\")\n\n        val driver = driver {}\n\n        // when\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // then\n        driver.assertEvents(\n            listOf(\n                Event.OpenBrowser(\"https://example.com\")\n            )\n        )\n    }\n\n    @Test\n    fun `Case 085 - Open link with auto verify`() {\n        // Given\n        val commands = readCommands(\"085_open_link_auto_verify\")\n\n        val driver = driver {}\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.OpenLink(\"https://example.com\", autoLink = true)\n            )\n        )\n    }\n\n    @Test\n    fun `Case 086 - launchApp sets all permissions to allow`() {\n        // Given\n        val commands = readCommands(\"086_launchApp_sets_all_permissions_to_allow\")\n        val driver = driver {}\n        driver.addInstalledApp(\"com.example.app\")\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        driver.assertEvents(\n            listOf(\n                Event.SetPermissions(\"com.example.app\", mapOf(\"all\" to \"allow\")),\n                Event.LaunchApp(\"com.example.app\"),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 087 - launchApp with all permissions to deny`() {\n        // Given\n        val commands = readCommands(\"087_launchApp_with_all_permissions_to_deny\")\n        val driver = driver {}\n        driver.addInstalledApp(\"com.example.app\")\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        driver.assertEvents(\n            listOf(\n                Event.SetPermissions(\"com.example.app\", mapOf(\"all\" to \"deny\")),\n                Event.LaunchApp(\"com.example.app\"),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 088 - launchApp with all permissions to deny and notification to allow`() {\n        // Given\n        val commands = readCommands(\"088_launchApp_with_all_permissions_to_deny_and_notification_to_allow\")\n        val driver = driver {}\n        driver.addInstalledApp(\"com.example.app\")\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        driver.assertEvents(\n            listOf(\n                Event.SetPermissions(\"com.example.app\", mapOf(\"all\" to \"deny\", \"notifications\" to \"allow\")),\n                Event.LaunchApp(\"com.example.app\"),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 089 - launchApp with SMS permissions`() {\n        // Given\n        val commands = readCommands(\"089_launchApp_with_sms_permission_group_to_allow\")\n        val driver = driver {}\n        driver.addInstalledApp(\"com.example.app\")\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        driver.assertEvents(\n            listOf(\n                Event.SetPermissions(\"com.example.app\", mapOf(\"sms\" to \"allow\")),\n                Event.LaunchApp(\"com.example.app\"),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 090 - Travel`() {\n        // Given\n        val commands = readCommands(\"090_travel\")\n        val driver = driver {}\n        driver.addInstalledApp(\"com.example.app\")\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        driver.assertEvents(\n            listOf(\n                Event.SetLocation(0.0, 0.0),\n                Event.SetLocation(0.1, 0.0),\n                Event.SetLocation(0.1, 0.1),\n                Event.SetLocation(0.0, 0.1),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 091 - Assert visible by index`() {\n        // Given\n        val commands = readCommands(\"091_assert_visible_by_index\")\n        val driver = driver {\n\n            element {\n                text = \"Item\"\n                bounds = Bounds.ofSize(100, 100)\n            }\n\n            element {\n                text = \"Item\"\n                bounds = Bounds.ofSize(100, 100)\n                    .translate(y = 100)\n            }\n\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No failures\n    }\n\n    @Test\n    fun `Case 092 - Log messages`() {\n        // Given\n        val commands = readCommands(\"092_log_messages\")\n        val driver = driver {\n        }\n\n        val receivedLogs = mutableListOf<String>()\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(\n                    it,\n                    onCommandMetadataUpdate = { _, metadata ->\n                        receivedLogs += metadata.logMessages\n                    }\n                ).runFlow(commands)\n            }\n        }\n\n        // Then\n        assertThat(receivedLogs).containsExactly(\n            \"Log from evalScript\",\n            \"Log from runScript\",\n        ).inOrder()\n    }\n\n    @Test\n    fun `Case 093 - JS default values`() {\n        // Given\n        val commands = readCommands(\"093_js_default_value\")\n        val driver = driver {\n        }\n        driver.addInstalledApp(\"com.example.default\")\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        driver.assertHasEvent(Event.LaunchApp(\"com.example.default\"))\n    }\n\n    @Test\n    fun `Case 094 - Subflow with inlined commands`() {\n        // Given\n        val commands = readCommands(\"094_runFlow_inline\")\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        driver.assertHasEvent(Event.InputText(\"Inner Parameter\"))\n    }\n\n    @Test\n    fun `Case 095 - Launch arguments`() {\n        // Given\n        val commands = readCommands(\"095_launch_arguments\")\n        val driver = driver {\n        }\n        driver.addInstalledApp(\"com.example.app\")\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        driver.assertHasEvent(\n            Event.LaunchApp(\n                appId = \"com.example.app\",\n                launchArguments = mapOf(\n                    \"argumentA\" to true,\n                    \"argumentB\" to 4,\n                    \"argumentC\" to 4.0,\n                    \"argumentD\" to \"Hello String Value true\"\n                )\n            )\n        )\n    }\n\n    @Test\n    fun `Case 096 - platform condition`() {\n        // Given\n        val commands = readCommands(\"096_platform_condition\")\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        driver.assertHasEvent(Event.InputText(\"Hello iOS\"))\n        driver.assertHasEvent(Event.InputText(\"Hello ios\"))\n        driver.assertNoEvent(Event.InputText(\"Hello Android\"))\n    }\n\n    @Test\n    fun `Case 097 - Contains descendants`() {\n        // Given\n        val commands = readCommands(\"097_contains_descendants\")\n\n        val driver = driver {\n            element {\n                id = \"id1\"\n                bounds = Bounds(0, 0, 200, 200)\n\n                element {\n                    bounds = Bounds(0, 0, 200, 200)\n                    element {\n                        id = \"id2\"\n                        bounds = Bounds(0, 0, 200, 200)\n                        element {\n                            text = \"Child 1\"\n                            bounds = Bounds(0, 0, 100, 50)\n                        }\n                    }\n                    element {\n                        text = \"Child 2\"\n                        bounds = Bounds(0, 0, 100, 100)\n                        enabled = false\n                    }\n                }\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failures\n        driver.assertNoInteraction()\n    }\n\n    @Test\n    fun `Case 098a - Execute Javascript conditionally`() {\n        // Given\n        val commands = readCommands(\"098_runscript_conditionals\")\n\n        val driver = driver {\n            element {\n                text = \"Click me\"\n                bounds = Bounds(0, 0, 100, 100)\n                onClick = { element ->\n                    element.text = \"Clicked\"\n                }\n            }\n        }\n\n        val receivedLogs = mutableListOf<String>()\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(\n                    it,\n                    onCommandMetadataUpdate = { _, metadata ->\n                        receivedLogs += metadata.logMessages\n                        metadata.labeledCommand?.let { receivedLogs.add(it) }\n                    }\n                ).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEventCount(Event.Tap(Point(50, 50)), 1)\n        // Then\n        assertThat(receivedLogs).containsExactly(\n            \"Log from runScript\",\n        ).inOrder()\n    }\n\n    @Test\n    fun `Case 098b - Execute conditions eagerly`() {\n        // Given\n        val commands = readCommands(\"098_runscript_conditionals_eager\")\n\n        // 'Click me' is not present in the view hierarchy\n        val driver = driver {}\n\n        val receivedLogs = mutableListOf<String>()\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(\n                    maestro = it,\n                    onCommandMetadataUpdate = { _, metadata ->\n                        receivedLogs += metadata.logMessages\n                    }\n                ).runFlow(commands)\n            }\n        }\n\n        // Then\n        // test completes\n        driver.assertEvents(emptyList())\n        // and script did not run\n        assertThat(receivedLogs).isEmpty()\n    }\n\n    @Test\n    fun `Case 099 - Screen recording`() {\n        // Given\n        val commands = readCommands(\"099_screen_recording\")\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.StartRecording,\n                Event.StopRecording,\n            )\n        )\n        assert(File(\"099_screen_recording.mp4\").exists())\n    }\n\n    @Test\n    fun `Case 100 - tapOn multiple times`() {\n        // Given\n        val commands = readCommands(\"100_tapOn_multiple_times\")\n\n        val driver = driver {\n            element {\n                text = \"Button\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n\n        // Then\n        // No test failure\n        driver.assertEventCount(Event.Tap(Point(50, 50)), 3)\n    }\n\n    @Test\n    fun `Case 101 - doubleTapOn`() {\n        // Given\n        val commands = readCommands(\"101_doubleTapOn\")\n\n        val driver = driver {\n            element {\n                text = \"Button\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n\n        // Then\n        // No test failure\n        driver.assertEventCount(Event.Tap(Point(50, 50)), 2)\n    }\n\n    @Test\n    fun `Case 102 - GraalJs config`() {\n        // given\n        val commands = readCommands(\"102_graaljs\")\n        val driver = driver { }\n\n        // when\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // then\n        driver.assertEvents(\n            listOf(\n                Event.InputText(\"foo\"),\n                Event.InputText(\"bar\"),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 103 - execute onFlowStart and onFlowComplete hooks`() {\n        // given\n        val commands = readCommands(\"103_on_flow_start_complete_hooks\")\n        val driver = driver { }\n        val receivedLogs = mutableListOf<String>()\n\n        // when\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(\n                    it,\n                    onCommandMetadataUpdate = { _, metadata ->\n                        receivedLogs += metadata.logMessages\n                    }\n                ).runFlow(commands)\n            }\n        }\n\n        // Then\n        assertThat(receivedLogs).containsExactly(\n            \"setup\",\n            \"teardown\",\n        ).inOrder()\n        driver.assertEvents(\n            listOf(\n                Event.InputText(\"test1\"),\n                Event.Tap(Point(100, 200)),\n                Event.InputText(\"test2\"),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 104 - execute onFlowStart and onFlowComplete hooks when flow failed`() {\n        // Given\n        val commands = readCommands(\"104_on_flow_start_complete_hooks_flow_failed\")\n\n        val driver = driver {\n            element {\n                id = \"another_id\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        // When & Then\n        assertThrows<MaestroException.AssertionFailure> {\n            Maestro(driver).use {\n                runBlocking {\n                    orchestra(it).runFlow(commands)\n                }\n            }\n        }\n        driver.assertEvents(\n            listOf(\n                Event.InputText(\"test1\"),\n                Event.InputText(\"test2\"),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 105 - execute onFlowStart and onFlowComplete when js output is set`() {\n        // Given\n        val commands = readCommands(\"105_on_flow_start_complete_when_js_output_set\")\n\n        val driver = driver {\n        }\n        val receivedLogs = mutableListOf<String>()\n\n        // when\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(\n                    it,\n                    onCommandMetadataUpdate = { _, metadata ->\n                        receivedLogs += metadata.logMessages\n                    }\n                ).runFlow(commands)\n            }\n        }\n\n        // Then\n        assertThat(receivedLogs).containsExactly(\n            \"setup\",\n            \"teardown\",\n        ).inOrder()\n    }\n\n    @Test\n    fun `Case 106 - execute onFlowStart and onFlowComplete when js output is set with subflows`() {\n        // Given\n        val commands = readCommands(\"106_on_flow_start_complete_when_js_output_set_subflows\")\n\n        val driver = driver {\n        }\n        val receivedLogs = mutableListOf<String>()\n\n        // when\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(\n                    it,\n                    onCommandMetadataUpdate = { _, metadata ->\n                        receivedLogs += metadata.logMessages\n                    }\n                ).runFlow(commands)\n            }\n        }\n\n        // Then\n        assertThat(receivedLogs).containsExactly(\n            \"subflow\",\n            \"setup subflow\",\n            \"teardown subflow\",\n        ).inOrder()\n    }\n\n    @Test\n    fun `Case 107 - execute defineVariablesCommand before onFlowStart and onFlowComplete are executed`() {\n        // Given\n        val commands = readCommands(\"107_define_variables_command_before_hooks\")\n\n        val driver = driver {\n        }\n        driver.addInstalledApp(\"com.example.app\")\n        val receivedLogs = mutableListOf<String>()\n\n        // when\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(\n                    it,\n                    onCommandMetadataUpdate = { _, metadata ->\n                        receivedLogs += metadata.logMessages\n                    }\n                ).runFlow(commands)\n            }\n        }\n\n        // Then\n        assertThat(receivedLogs).containsExactly(\n            \"com.example.app\",\n        ).inOrder()\n        driver.assertEvents(\n            listOf(\n                Event.LaunchApp(\"com.example.app\")\n            )\n        )\n    }\n\n    @Test\n    fun `Case 108 - fail the flow and skip commands in case of onStart hook failure`() {\n        // Given\n        val commands = readCommands(\"108_failed_start_hook\")\n        val driver = driver {\n        }\n        val receivedLogs = mutableListOf<String>()\n\n        // When & Then\n        assertThrows<MaestroException.AssertionFailure> {\n            val result = Maestro(driver).use {\n                runBlocking {\n                    orchestra(\n                        it,\n                        onCommandMetadataUpdate = { _, metadata ->\n                            receivedLogs += metadata.logMessages\n                        }\n                    ).runFlow(commands)\n                }\n            }\n\n            assertThat(result).isFalse()\n        }\n        assertThat(receivedLogs).containsExactly(\n            \"on start\",\n            \"on complete\",\n        ).inOrder()\n    }\n\n    @Test\n    fun `Case 109 - fail the flow and execute commands in case of onComplete hook failure`() {\n        // Given\n        val commands = readCommands(\"109_failed_complete_hook\")\n        val driver = driver {\n        }\n        val receivedLogs = mutableListOf<String>()\n\n        // When & Then\n        assertThrows<MaestroException.AssertionFailure> {\n            val result = Maestro(driver).use {\n                runBlocking {\n                    orchestra(\n                        it,\n                        onCommandMetadataUpdate = { _, metadata ->\n                            receivedLogs += metadata.logMessages\n                        }\n                    ).runFlow(commands)\n                }\n            }\n\n            assertThat(result).isFalse()\n        }\n        assertThat(receivedLogs).containsExactly(\n            \"on start\",\n            \"main flow\",\n            \"on complete\",\n        ).inOrder()\n    }\n\n    @Test\n    fun `Case 110 - addMedia command emits add media event with correct path`() {\n        // given\n        val commands = readCommands(\"110_add_media_device\")\n        val driver = driver {}\n\n        // when\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // then\n        driver.assertEvents(listOf(Event.AddMedia))\n    }\n\n    @Test\n    fun `Case 111 - addMedia command allows adding multiple media`() {\n        // given\n        val commands = readCommands(\"111_add_multiple_media\")\n        val driver = driver { }\n\n        // when\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // then\n        driver.assertEvents(listOf(Event.AddMedia, Event.AddMedia, Event.AddMedia))\n    }\n\n    @Test\n    fun `Case 112 - Scroll until view is visible - with element center`() {\n        // Given\n        val commands = readCommands(\"112_scroll_until_visible_center\")\n        val info = driver { }.deviceInfo()\n\n        val elementBounds = Bounds(0, 0 + info.heightGrid, 100, 100 + info.heightGrid)\n        val driver = driver {\n            element {\n                text = \"Test\"\n                bounds = elementBounds\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                assertThat(orchestra(it).runFlow(commands)).isTrue()\n            }\n        }\n\n        // Then\n        driver.assertEvents(\n            listOf(\n                Event.SwipeElementWithDirection(Point(270, 480), SwipeDirection.UP, 1),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 113 - Tap on element - with app settle timeout`() {\n        // Given\n        val commands = readCommands(\"113_tap_on_element_settle_timeout\")\n\n        val driver = driver {\n            element {\n                mutatingText = {\n                    \"The time is ${System.nanoTime()}\"\n                }\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        // When\n        var elapsedTime: Long\n        Maestro(driver).use { maestro ->\n            elapsedTime = measureTimeMillis {\n                runBlocking {\n                    orchestra(maestro).runFlow(commands)\n                }\n            }\n        }\n\n        // Then\n        // No test failure\n        assertThat(elapsedTime).isAtMost(1000L)\n        driver.assertEventCount(Event.Tap(Point(50, 50)), expectedCount = 1)\n    }\n\n    @Test\n    fun `Case 114 - child of selector`() {\n        // Given\n        val commands = readCommands(\"114_child_of_selector\")\n\n        val driver = driver {\n            element {\n                id = \"id1\"\n                bounds = Bounds(0, 0, 200, 600)\n\n                element {\n                    bounds = Bounds(0, 0, 200, 200)\n                    text = \"parent_id_1\"\n                    element {\n                        text = \"child_id\"\n                        bounds = Bounds(0, 0, 100, 200)\n                    }\n                }\n                element {\n                    bounds = Bounds(0, 200, 200, 400)\n                    text = \"parent_id_2\"\n                    element {\n                        text = \"child_id\"\n                        bounds = Bounds(0, 200, 100, 400)\n                    }\n                }\n                element {\n                    bounds = Bounds(0, 400, 200, 600)\n                    text = \"parent_id_3\"\n                    element {\n                        text = \"child_id_1\"\n                        bounds = Bounds(0, 400, 100, 600)\n                    }\n                }\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failures\n        driver.assertNoInteraction()\n\n    }\n\n    @Test\n    fun `Case 115 - airplane mode`() {\n        val commands = readCommands(\"115_airplane_mode\")\n        val driver = driver { }\n\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n    }\n\n    @Test\n    fun `Case 116 - Kill app`() {\n        // Given\n        val commands = readCommands(\"116_kill_app\")\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.KillApp(\"com.example.app\"))\n        driver.assertHasEvent(Event.KillApp(\"another.app\"))\n    }\n\n    @Test\n    fun `Case 117 - Scroll until view is visible - with speed and timeout evaluate`() {\n        // Given\n        val commands = readCommands(\"117_scroll_until_visible_speed\")\n        val expectedDuration = \"601\"\n        val expectedTimeout = \"20000\"\n        val info = driver { }.deviceInfo()\n\n        val elementBounds = Bounds(0, 0 + info.heightGrid, 100, 100 + info.heightGrid)\n        val driver = driver {\n            element {\n                id = \"maestro\"\n                bounds = elementBounds\n            }\n        }\n\n        // When\n        var scrollDuration = \"0\"\n        var timeout = \"0\"\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it, onCommandMetadataUpdate = { _, metaData ->\n                    scrollDuration = metaData.evaluatedCommand?.scrollUntilVisible?.scrollDuration.toString()\n                    timeout = metaData.evaluatedCommand?.scrollUntilVisible?.timeout.toString()\n                }).runFlow(commands)\n            }\n        }\n\n        // Then\n        assertThat(scrollDuration).isEqualTo(expectedDuration)\n        assertThat(timeout).isEqualTo(expectedTimeout)\n        driver.assertEvents(\n            listOf(\n                Event.SwipeElementWithDirection(Point(270, 480), SwipeDirection.UP, expectedDuration.toLong()),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 118 - Scroll until view is visible - no negative values allowed`() {\n        // Given\n        val commands = readCommands(\"118_scroll_until_visible_negative\")\n        val expectedDuration = \"40\"\n        val expectedTimeout = \"20000\"\n        val info = driver { }.deviceInfo()\n\n        val elementBounds = Bounds(0, 0 + info.heightGrid, 100, 100 + info.heightGrid)\n        val driver = driver {\n            element {\n                id = \"maestro\"\n                bounds = elementBounds\n            }\n        }\n\n        // When\n        var scrollDuration = \"0\"\n        var timeout = \"0\"\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it, onCommandMetadataUpdate = { _, metaData ->\n                    scrollDuration = metaData.evaluatedCommand?.scrollUntilVisible?.scrollDuration.toString()\n                    timeout = metaData.evaluatedCommand?.scrollUntilVisible?.timeout.toString()\n                }).runFlow(commands)\n            }\n        }\n\n        // Then\n        assertThat(scrollDuration).isEqualTo(expectedDuration)\n        assertThat(timeout).isEqualTo(expectedTimeout)\n        driver.assertEvents(\n            listOf(\n                Event.SwipeElementWithDirection(Point(270, 480), SwipeDirection.UP, expectedDuration.toLong()),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 119 - Retry set of commands with n attempts`() {\n        // Given\n        val commands = readCommands(\"119_retry_commands\")\n\n        var counter = 0\n        val driver = driver {\n            val indicator = element {\n                text = counter.toString()\n                bounds = Bounds(0, 100, 100, 200)\n            }\n\n            element {\n                text = \"Button\"\n                bounds = Bounds(0, 0, 100, 100)\n                onClick = {\n                    counter++\n                    if (counter == 1) {\n                        throw RuntimeException(\"Exception for the first time\")\n                    }\n                    indicator.text = counter.toString()\n                }\n            }\n\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.Scroll,\n                Event.TakeScreenshot,\n                /**----after retry----**/\n                Event.Scroll,\n                Event.TakeScreenshot,\n                Event.Tap(Point(50, 50)),\n                Event.Scroll,\n            )\n        )\n    }\n\n    @Test\n    fun `Case 120 - Tap on element - Retry if no UI change opt-in`() {\n        // Given\n        val commands = readCommands(\"120_tap_on_element_retryTapIfNoChange\")\n\n        val driver = driver {\n            element {\n                text = \"Primary button\"\n                bounds = Bounds(0, 0, 100, 100)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEventCount(Event.Tap(Point(50, 50)), expectedCount = 2)\n    }\n\n    @Test\n    fun `Case 121 - Cancellation before the flow starts skips all the commands`() {\n        val commands = readCommands(\"098_runscript_conditionals\")\n        val info = driver { }.deviceInfo()\n\n        val elementBounds = Bounds(0, 0 + info.heightGrid, 100, 100 + info.heightGrid)\n        val driver = driver {\n            element {\n                id = \"maestro\"\n                bounds = elementBounds\n            }\n        }\n\n        var skipped = 0\n        var completed = 0\n        val expectedSkipped = 7\n\n\n        // When\n        Maestro(driver).use { maestro ->\n            runBlocking {\n                // Create a job that we can cancel\n                val job = Job()\n\n                // Create a supervisor scope so our skipped counter can still update after cancellation\n                val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())\n\n                // Launch the work in our cancellable scope\n                scope.launch(job) {\n                    // Cancel the job immediately\n                    coroutineContext.cancel()\n\n                    try {\n                        Orchestra(\n                            maestro,\n                            lookupTimeoutMs = 0L,\n                            optionalLookupTimeoutMs = 0L,\n                            onCommandComplete = { _, _ ->\n                                completed += 1\n                            },\n                            onCommandSkipped = { _, cmd ->\n                                skipped += 1\n                            },\n                        ).runFlow(commands)\n                    } catch (e: CancellationException) {\n                        // Expected cancellation\n                    }\n                }\n\n                // Actively wait for skipped count to reach expected value or timeout\n                withTimeout(3000) {\n                    while (skipped < expectedSkipped) {\n                        yield() // Cooperatively yield to let other coroutines run\n\n                        // Check every 10ms\n                        delay(10)\n                    }\n                }\n\n                // Clean up the scope\n                scope.cancel()\n            }\n        }\n\n        // Then\n        assertThat(skipped).isEqualTo(7)\n        assertThat(completed).isEqualTo(0)\n    }\n\n    @Test\n    fun `Case 122 - Pause and resume works`() {\n        // Given\n        val commands = readCommands(\"122_pause_resume\")\n        val driver = driver {\n            element {\n                text = \"Button\"\n                bounds = Bounds(0, 0, 100, 100)\n                clickable = true\n                onClick = { element ->\n                    element.text = \"Clicked\"\n                }\n            }\n        }\n        driver.addInstalledApp(\"com.example.app\")\n        val executedCommands = mutableListOf<String>()\n        val maestro = Maestro(driver)\n        val flowController = FlowControllerTest()\n        val orchestra = Orchestra(\n            maestro = maestro,\n            flowController = flowController\n        )\n\n        // When\n        runBlocking {\n            val flowJob = launch {\n                orchestra(\n                    maestro,\n                    onCommandMetadataUpdate = { cmd, metadata ->\n                        val commandName = when {\n                            cmd.launchAppCommand != null -> \"LaunchAppCommand\"\n                            cmd.inputTextCommand != null -> \"InputTextCommand\"\n                            cmd.tapOnElement != null -> \"TapOnCommand\"\n                            cmd.defineVariablesCommand != null -> \"DefineVariablesCommand\"\n                            cmd.applyConfigurationCommand != null -> \"ApplyConfigurationCommand\"\n                            else -> \"UnknownCommand\"\n                        }\n                        executedCommands.add(commandName)\n                    }\n                ).runFlow(commands)\n            }\n\n            delay(100)\n            orchestra.pause()\n            assertThat(orchestra.isPaused).isTrue()\n\n            val commandsBeforeResume = executedCommands.toList()\n            delay(100)\n            assertThat(executedCommands).isEqualTo(commandsBeforeResume)\n\n            orchestra.resume()\n            assertThat(orchestra.isPaused).isFalse()\n\n            flowJob.join()\n        }\n\n        // Then\n        assertThat(executedCommands).containsAtLeast(\n            \"LaunchAppCommand\",\n            \"InputTextCommand\",\n            \"TapOnCommand\"\n        ).inOrder()\n\n        driver.assertEvents(\n            listOf(\n                Event.LaunchApp(\"com.example.app\"),\n                Event.InputText(\"Test after pause resume\"),\n                Event.Tap(Point(50, 50))\n            )\n        )\n    }\n\n    @Test\n    fun `Case 123 - Pause and resume preserves JsEngine`() {\n        // Given\n        val commands = readCommands(\"123_pause_resume_preserves_js_engine\")\n        val driver = driver { }\n        driver.addInstalledApp(\"com.example.app\")\n        val executedCommands = mutableListOf<String>()\n        val maestro = Maestro(driver)\n        val flowController = FlowControllerTest()\n        val orchestra = Orchestra(\n            maestro = maestro,\n            flowController = flowController\n        )\n\n        // When\n        runBlocking {\n            val flowJob = launch {\n                orchestra(\n                    maestro,\n                    onCommandMetadataUpdate = { cmd, metadata ->\n                        val commandName = when {\n                            cmd.launchAppCommand != null -> \"LaunchAppCommand\"\n                            cmd.inputTextCommand != null -> \"InputTextCommand\"\n                            cmd.evalScriptCommand != null -> \"EvalScriptCommand\"\n                            cmd.defineVariablesCommand != null -> \"DefineVariablesCommand\"\n                            cmd.applyConfigurationCommand != null -> \"ApplyConfigurationCommand\"\n                            else -> \"UnknownCommand\"\n                        }\n                        executedCommands.add(commandName)\n                    }\n                ).runFlow(commands)\n            }\n\n            // Let both inputText commands run before pause\n            delay(100)\n\n            // Pause after both inputText commands\n            orchestra.pause()\n            assertThat(orchestra.isPaused).isTrue()\n\n            // Verify no new commands execute during pause\n            val commandsBeforeResume = executedCommands.toList()\n            delay(100)\n            assertThat(executedCommands).isEqualTo(commandsBeforeResume)\n\n            // Resume the flow\n            orchestra.resume()\n            assertThat(orchestra.isPaused).isFalse()\n\n            // Wait for the flow to complete\n            flowJob.join()\n        }\n\n        // Then\n        // Verify commands were executed in the expected order\n        assertThat(executedCommands).containsAtLeast(\n            \"DefineVariablesCommand\",\n            \"ApplyConfigurationCommand\",\n            \"LaunchAppCommand\",\n            \"EvalScriptCommand\",  // First evalScript that sets up variables\n            \"InputTextCommand\",    // First input using preMessage\n            \"InputTextCommand\",    // Second input using message\n            \"EvalScriptCommand\"    // Second evalScript that verifies state\n        ).inOrder()\n\n        // Verify the flow completed successfully with both messages\n        driver.assertEvents(\n            listOf(\n                Event.LaunchApp(\"com.example.app\"),\n                Event.InputText(\"Hello from pre-message\"),     // First message\n                Event.InputText(\"Hello from preserved JS state!\")  // Second message\n            )\n        )\n    }\n\n    @Test\n    fun `Case 124 - Cancellation during flow execution`() {\n        // Given\n        val commands = readCommands(\"124_cancellation_during_flow_execution\")\n        val driver = driver {\n            element {\n                text = \"Button\"\n                bounds = Bounds(0, 0, 100, 100)\n                clickable = true\n                onClick = { element ->\n                    element.text = \"Button was clicked\"\n                }\n            }\n        }\n        driver.addInstalledApp(\"com.example.app\")\n\n        var completed = 0\n        var skipped = 0\n        val executedCommands = mutableListOf<String>()\n        val cancellationSignal = CompletableDeferred<Unit>()\n        val activeFlows = mutableMapOf<String, Job?>()\n\n        // When\n        Maestro(driver).use { maestro ->\n            runBlocking {\n                val supervisorScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)\n                val flowId = \"test-flow-124\"\n\n                val flowJob = supervisorScope.launch {\n                    try {\n                        val orchestra = Orchestra(\n                            maestro,\n                            onCommandComplete = { cmd, _ ->\n                                val isActive = coroutineContext[Job]?.isActive ?: false\n                                if (!isActive) {\n                                    skipped += 1\n                                    return@Orchestra\n                                }\n                                completed += 1\n                            },\n                            onCommandSkipped = { _, _ ->\n                                skipped += 1\n                            },\n                            onCommandMetadataUpdate = { cmd, _ ->\n                                val isActive = coroutineContext[Job]?.isActive ?: false\n                                if (!isActive) {\n                                    return@Orchestra\n                                }\n\n                                val commandName = when {\n                                    cmd.launchAppCommand != null -> \"LaunchAppCommand\"\n                                    cmd.inputTextCommand != null -> \"InputTextCommand\"\n                                    cmd.evalScriptCommand != null -> \"EvalScriptCommand\"\n                                    cmd.defineVariablesCommand != null -> \"DefineVariablesCommand\"\n                                    cmd.applyConfigurationCommand != null -> \"ApplyConfigurationCommand\"\n                                    cmd.tapOnElement != null -> \"TapOnCommand\"\n                                    else -> \"UnknownCommand\"\n                                }\n                                executedCommands.add(commandName)\n\n                                if (commandName == \"InputTextCommand\" && !cancellationSignal.isCompleted) {\n                                    cancellationSignal.complete(Unit)\n                                }\n                            }\n                        )\n\n                        activeFlows[flowId] = coroutineContext[Job]\n\n                        try {\n                            orchestra.runFlow(commands)\n                        } finally {\n                            activeFlows.remove(flowId)\n                        }\n                    } catch (e: CancellationException) {\n                        throw e\n                    } catch (e: Exception) {\n                        throw e\n                    }\n                }\n\n                cancellationSignal.await()\n                activeFlows[flowId]?.cancel()\n\n                try {\n                    flowJob.join()\n                } catch (e: CancellationException) {\n                    // Expected\n                }\n            }\n        }\n\n        // Then\n        assertThat(completed).isGreaterThan(0)\n        assertThat(skipped).isGreaterThan(0)\n\n        assertThat(executedCommands).containsAtLeast(\n            \"LaunchAppCommand\",\n            \"EvalScriptCommand\",\n            \"InputTextCommand\"\n        ).inOrder()\n\n        assertThat(executedCommands).doesNotContain(\"TapOnCommand\")\n\n        driver.assertEvents(\n            listOf(\n                Event.LaunchApp(\"com.example.app\"),\n                Event.InputText(\"Hello before cancellation\")\n            )\n        )\n\n        assertThat(activeFlows).isEmpty()\n    }\n\n    @Test\n    fun `Case 125 - Assert visible by CSS selector`() {\n        // Given\n        val commands = readCommands(\"125_assert_by_css\")\n\n        val driver = driver {\n            element {\n                bounds = Bounds(0, 0, 100, 100)\n                text = \"Test Element\"\n                matchesCssFilter = \".test\"\n            }\n        }\n\n        driver.addInstalledApp(\"http://example.com\")\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n    }\n\n    @Test\n    fun `Case 126 - Set orientation`() {\n        // Given\n        val commands = readCommands(\"126_set_orientation\")\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        driver.assertHasEvent(Event.SetOrientation(DeviceOrientation.PORTRAIT))\n        driver.assertHasEvent(Event.SetOrientation(DeviceOrientation.LANDSCAPE_LEFT))\n        driver.assertHasEvent(Event.SetOrientation(DeviceOrientation.LANDSCAPE_RIGHT))\n        driver.assertHasEvent(Event.SetOrientation(DeviceOrientation.UPSIDE_DOWN))\n    }\n\n    @Test\n    fun `Case 126 - Set orientation with env variables`() {\n        // Given\n        val commands = readCommands(\"126_set_orientation_with_env\")\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        driver.assertHasEvent(Event.SetOrientation(DeviceOrientation.PORTRAIT))\n        driver.assertHasEvent(Event.SetOrientation(DeviceOrientation.LANDSCAPE_LEFT))\n        driver.assertHasEvent(Event.SetOrientation(DeviceOrientation.LANDSCAPE_RIGHT))\n        driver.assertHasEvent(Event.SetOrientation(DeviceOrientation.UPSIDE_DOWN))\n    }\n\n    @Test\n    fun `Case 127 RhinoJS - Environment variables should be isolated between flows`() {\n        // Test that environment variables from one runFlow don't leak to peer runFlow commands\n        val commands = readCommands(\"127_env_vars_isolation_rhinojs\")\n        val driver = driver {}\n\n        Maestro(driver).use {\n            runBlocking {\n                // Should succeed - uses positive assertions to verify isolation works\n                orchestra(it).runFlow(commands)\n            }\n        }\n    }\n\n    @Test\n    fun `Case 127 GraalJS - Environment variables should be isolated between flows`() {\n        // Test that environment variables are isolated between flows using GraalJS engine\n        val commands = readCommands(\"127_env_vars_isolation_graaljs\")\n        val driver = driver {}\n\n        Maestro(driver).use {\n            runBlocking {\n                // Should succeed - uses positive assertions to verify isolation works\n                orchestra(it).runFlow(commands)\n            }\n        }\n    }\n\n    @Test\n    fun `Case 128 - Random Data Generation`() {\n        // Test that environment variables are isolated between flows using GraalJS engine\n        val commands = readCommands(\"128_datafaker_graaljs\")\n        val driver = driver {}\n\n        Maestro(driver).use {\n            runBlocking {\n                // Should succeed - uses positive assertions to verify engine runs and validates data\n                orchestra(it).runFlow(commands)\n            }\n        }\n    }\n\n    @Test\n    fun `Case 129 - Text and ID with child elements`() {\n        // Given\n        // We're looking for an element with the given text and id, but it has a child element that is only a partial match\n        val commands = readCommands(\"129_text_and_id\")\n        val driver = driver {\n            element {\n                id = \"some_id\"\n                text = \"some_text\"\n                bounds = Bounds(0, 0, 200, 200)\n\n                element {\n                    id = \"\"\n                    text = \"some_text\"\n                    bounds = Bounds(50, 50, 150, 150)\n                }\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure - if we reach this point, the test passed successfully\n    }\n\n    @Test\n    fun `Case 130 - Duplicate elements case for checking deepestHierarchy is working`() {\n        // Given\n        // We're looking for an element with the given text and id, but it has a child element that is only a partial match\n        val commands = readCommands(\"130_text_and_index\")\n        val driver = driver {\n            id = \"0\"\n            element {\n                id = \"1\"\n                text = \"some_text\"\n                bounds = Bounds(0, 0, 200, 200)\n            }\n            element {\n                id = \"2\"\n                text = \"some_text\"\n                bounds = Bounds(0, 0, 200, 200)\n                element {\n                    id = \"3\"\n                    text = \"some_text\"\n                    bounds = Bounds(0, 0, 200, 200)\n\n                    element {\n                        id = \"4\"\n                        text = \"some_text\"\n                        bounds = Bounds(50, 50, 150, 150)\n                    }\n                }\n            }\n\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure - if we reach this point, the test passed successfully\n    }\n\n    @Test\n    fun `Case 131 - Set Permissions on an installed app`() {\n        // Given\n        val commands = readCommands(\"131_setPermissions\")\n\n        val driver = driver {\n        }\n        driver.addInstalledApp(\"com.example.app\")\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertHasEvent(Event.SetPermissions(appId = \"com.example.app\", permissions = mapOf(\"all\" to \"deny\", \"notifications\" to \"unset\")))\n    }\n\n\n    @Test\n    fun `Case 132 - repeatWhile respects coroutine timeout and gets cancelled`() {\n        // Given\n        // You can reuse 075_repeat_while.yaml or make a dedicated one that just keeps the while true.\n        val commands = readCommands(\"075_repeat_while\")\n\n        val driver = driver {\n            element {\n                text = \"Value 0\"\n                bounds = Bounds(0, 100, 100, 100)\n            }\n\n            element {\n                text = \"Button\"\n                bounds = Bounds(0, 0, 100, 100)\n                onClick = {\n                }\n            }\n        }\n\n        var completed = 0\n        var skipped = 0\n        val executedCommands = mutableListOf<String>()\n\n        Maestro(driver).use { maestro ->\n            // When & Then\n            runBlocking {\n                // Optional: mirror Case 124 style and isolate flow in its own scope\n                val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)\n\n                try {\n                    val maxReasonableSkips = 1_00\n\n                    withTimeout(2000) {\n                        val orchestra = Orchestra(\n                            maestro = maestro,\n                            lookupTimeoutMs = 0L,\n                            optionalLookupTimeoutMs = 0L,\n                            onCommandComplete = { index, command ->\n                                println(\"\"\"\n                                    Completed command ${command.description()} at index $index.\n                                \"\"\".trimIndent()\n                                )\n\n                                completed += 1\n                            },\n                            onCommandSkipped = { index, command ->\n                                skipped += 1\n                                /**\n                                 * When this fail it means we might have entered an infinite loop.\n                                 *\n                                 * Our orchestra should not have infinite loops when timeout exceed.\n                                 */\n                                println(\"\"\"\n                                        Command ${command.description()} at index $index was skipped $skipped times.\n                                \"\"\".trimIndent()\n                                )\n                                if (skipped > maxReasonableSkips) {\n                                    fail(\"Likely infinite loop: onCommandSkipped called $skipped times (command=$command index=$index)\")\n                                }\n                            }\n                        )\n\n                        // This should be interrupted by withTimeout if repeatWhile keeps going\n                        orchestra.runFlow(commands)\n                    }\n                } finally {\n                    scope.coroutineContext[Job]?.cancel()\n                }\n            }\n        }\n\n        // Assertions\n        // Some commands actually completed before timeout.\n        assertThat(completed).isGreaterThan(0)\n\n        // Depending on how you wire onCommandSkipped for cancellation, you may or may not\n        // see skipped > 0; keep this if you convert cancellations to \"skipped\".\n        assertThat(skipped).isGreaterThan(0)\n    }\n\n    @Test\n    fun `Case 133 - Set clipboard`() {\n        // Given\n        val commands = readCommands(\"133_setClipboard\")\n\n        val driver = driver {\n            element {\n                id = \"inputField\"\n                bounds = Bounds(0, 100, 100, 200)\n            }\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.InputText(\"Hello, Maestro!\"),\n            )\n        )\n    }\n\n    @Test\n    fun `Case 134 - Take screenshot with path`() {\n        // Given\n        val commands = readCommands(\"134_take_screenshot_with_path\")\n\n        val driver = driver {\n        }\n\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.TakeScreenshot,\n            )\n        )\n        assert(File(\"134_screenshots/filename.png\").exists())\n    }\n\n    @Test\n    fun `Case 135 - Screen recording with path`() {\n        // Given\n        val commands = readCommands(\"135_screen_recording_with_path\")\n\n        val driver = driver {\n        }\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure\n        driver.assertEvents(\n            listOf(\n                Event.StartRecording,\n                Event.StopRecording,\n            )\n        )\n        assert(File(\"135_recordings/filename.mp4\").exists())\n    }\n\n    @Test\n    fun `Case 136 - Relative path in http multipart script`() {\n        // Flow running a JS file which is using multipartForm which has an image as relative path from script\n        val commands = readCommands(\"136_js_http_multi_part_requests\")\n        val driver = driver {}\n\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n    }\n\n    @Test\n    fun `Case 138 - Take cropped screenshot`() {\n        // Given\n        val commands = readCommands(\"138_take_cropped_screenshot\")\n        val boundHeight = 100\n        val boundWidth = 100\n\n        val driver = driver {\n            element {\n                id = \"element_id\"\n                bounds = Bounds(0, 0, boundHeight, boundWidth)\n            }\n        }\n\n        val device = driver.deviceInfo()\n        val dpr = device.heightPixels / device.heightGrid\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then - takeScreenshot with bounds crops by bounds (grid) and outputs pixel dimensions (bounds * dpr)\n        driver.assertEvents(listOf(Event.TakeScreenshot))\n        val file = File(\"138_take_cropped_screenshot_with_filename.png\")\n        val image = ImageIO.read(file)\n        assert(file.exists())\n        assert(image.width == (boundWidth * dpr))\n        assert(image.height == (boundHeight * dpr))\n    }\n\n    @Test\n    fun `Case 137 - Shard and device env vars`() {\n        // Given\n        // Use the proper API parameters (deviceId, shardIndex) instead of manually setting\n        // MAESTRO_SHARD_* vars, since those are now reserved internal-only variables\n        val commands = readCommands(\n            caseName = \"137_shard_device_env_vars\",\n            deviceId = \"test-device\",\n            shardIndex = 0,  // Will set MAESTRO_SHARD_ID=1, MAESTRO_SHARD_INDEX=0\n        )\n\n        val driver = driver {\n        }\n        driver.addInstalledApp(\"com.example.app\")\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then\n        // No test failure - verify screenshot was created with env vars in filename\n        driver.assertEvents(\n            listOf(\n                Event.LaunchApp(appId = \"com.example.app\"),\n                Event.TakeScreenshot,\n            )\n        )\n        assert(File(\"137_shard_device_env_vars_test-device_shard1_idx0.png\").exists())\n    }\n\n    \n    @Test\n    fun `hideKeyboard succeeds when keyboard becomes hidden`() {\n        // Given\n        val commands = listOf(\n            MaestroCommand(HideKeyboardCommand())\n        )\n\n        val driver = driver {}\n\n        // When\n        Maestro(driver).use {\n            runBlocking {\n                orchestra(it).runFlow(commands)\n            }\n        }\n\n        // Then - should execute hideKeyboard command successfully\n        driver.assertEvents(\n            listOf(\n                Event.HideKeyboard,\n            )\n        )\n    }\n\n    @Test\n    fun `hideKeyboard throws HideKeyboardFailure when keyboard never gets hidden`() {\n        // Given\n        val commands = listOf(\n            MaestroCommand(HideKeyboardCommand())\n        )\n\n        val driver = driver {}\n        driver.keyboardRemainsVisible = true\n\n        // When & Then\n        assertThrows<MaestroException.HideKeyboardFailure> {\n            Maestro(driver).use {\n                runBlocking {\n                    orchestra(it).runFlow(commands)\n                }\n            }\n        }\n\n        // Verify hideKeyboard was still called\n        driver.assertEvents(\n            listOf(\n                Event.HideKeyboard,\n            )\n        )\n    }\n\n    @Test\n    fun `callback order should be correct for successful command in subflow`() {\n        // Given\n        val events = mutableListOf<CallbackEvent>()\n        var sequence = 0\n        val subflowCommand = MaestroCommand(BackPressCommand())\n        val runFlowCommand = RunFlowCommand(\n            commands = listOf(subflowCommand),\n            condition = null,\n            sourceDescription = null,\n            config = null,\n            label = null,\n            optional = false,\n        )\n        val commands = listOf(MaestroCommand(runFlowCommand))\n\n        val orchestra = createOrchestraWithCallbacks(events) { sequence++ }\n\n        // When\n        runBlocking {\n            orchestra.runFlow(commands)\n        }\n\n        // Then\n        // Expected order: onCommandStart -> onCommandMetadataUpdate -> onCommandComplete\n        // For subflow, verify the critical ordering is maintained for each command\n        // (subflow execution includes both RunFlowCommand and subflow command events)\n        val commandIndexes = events.map { it.commandIndex }.distinct()\n        for (cmdIndex in commandIndexes) {\n            val cmdEvents = events.filter { it.commandIndex == cmdIndex }\n            assertThat(cmdEvents.map { it.type }).containsExactly(\n                \"onCommandStart\",\n                \"onCommandMetadataUpdate\",\n                \"onCommandComplete\"\n            ).inOrder()\n        }\n    }\n\n    @Test\n    fun `callback order should be correct for successful command in main flow`() {\n        // Given\n        val events = mutableListOf<CallbackEvent>()\n        var sequence = 0\n        val command = MaestroCommand(BackPressCommand())\n        val commands = listOf(command)\n\n        val orchestra = createOrchestraWithCallbacks(events) { sequence++ }\n\n        // When\n        runBlocking {\n          orchestra.runFlow(commands)\n        }\n\n        // Then\n        // Expected order: onCommandStart -> onCommandMetadataUpdate -> onCommandComplete\n        val commandEvents = events.filter { it.commandIndex == 0 }\n        assertThat(commandEvents.map { it.type }).containsExactly(\n            \"onCommandStart\",\n            \"onCommandMetadataUpdate\",\n            \"onCommandComplete\"\n        ).inOrder()\n    }\n\n    @Test\n    fun `callback order should be correct for failed command in main flow`() {\n        // Given\n        val events = mutableListOf<CallbackEvent>()\n        var sequence = 0\n        // Use an assertion that will fail (element doesn't exist)\n        val command = MaestroCommand(\n            AssertConditionCommand(\n                condition = Condition(\n                    visible = ElementSelector(\n                        idRegex = \"non_existent_element\"\n                    )\n                )\n            )\n        )\n        val commands = listOf(command)\n\n        val orchestra = createOrchestraWithCallbacks(events) { sequence++ }\n\n        // When\n        runBlocking {\n            try {\n                orchestra.runFlow(commands)\n            } catch (e: Throwable) {\n                // Expected to fail, ignore the exception\n            }\n        }\n\n        // Then\n        // Expected order: onCommandStart -> onCommandMetadataUpdate -> onCommandFailed\n        val commandEvents = events.filter { it.commandIndex == 0 }\n        assertThat(commandEvents.map { it.type }).containsExactly(\n            \"onCommandStart\",\n            \"onCommandMetadataUpdate\",\n            \"onCommandFailed\"\n        ).inOrder()\n    }\n\n    private data class CallbackEvent(\n        val type: String,\n        val commandIndex: Int,\n        val sequence: Int\n    )\n\n    private fun createOrchestraWithCallbacks(\n        events: MutableList<CallbackEvent>,\n        getSequence: () -> Int,\n    ): Orchestra {\n        val driver = FakeDriver()\n        driver.setLayout(FakeLayoutElement())\n        driver.open()\n        val maestro = Maestro(driver)\n\n        // Track unique command index that increments for each command start\n        // This ensures subflow commands get different indices than parent flow commands\n        var uniqueCommandIndex = -1\n        // Use a stack to track active commands (handles nested commands that reuse Orchestra indices)\n        val activeCommandStack = mutableListOf<Int>()\n\n        return Orchestra(\n            maestro = maestro,\n            lookupTimeoutMs = 0L,\n            optionalLookupTimeoutMs = 0L,\n            onCommandStart = { _, _ ->\n                uniqueCommandIndex++\n                activeCommandStack.add(uniqueCommandIndex)\n                events.add(CallbackEvent(\"onCommandStart\", uniqueCommandIndex, getSequence()))\n            },\n            onCommandMetadataUpdate = { _, _ ->\n                // Use the most recent active command (top of stack)\n                val uniqueIndex = activeCommandStack.lastOrNull() ?: 0\n                events.add(CallbackEvent(\"onCommandMetadataUpdate\", uniqueIndex, getSequence()))\n            },\n            onCommandComplete = { _, _ ->\n                // Pop the most recent command from the stack (LIFO for nested commands)\n                val uniqueIndex = activeCommandStack.removeLastOrNull() ?: 0\n                events.add(CallbackEvent(\"onCommandComplete\", uniqueIndex, getSequence()))\n            },\n            onCommandFailed = { _, _, _ ->\n                // Pop the most recent command from the stack (LIFO for nested commands)\n                val uniqueIndex = activeCommandStack.removeLastOrNull() ?: 0\n                events.add(CallbackEvent(\"onCommandFailed\", uniqueIndex, getSequence()))\n                Orchestra.ErrorResolution.FAIL\n            },\n            onCommandWarned = { _, _ ->\n                // Use the most recent active command (top of stack)\n                val uniqueIndex = activeCommandStack.lastOrNull() ?: 0\n                events.add(CallbackEvent(\"onCommandWarned\", uniqueIndex, getSequence()))\n            },\n            onCommandSkipped = { _, _ ->\n                // Pop the most recent command from the stack (LIFO for nested commands)\n                val uniqueIndex = activeCommandStack.removeLastOrNull() ?: 0\n                events.add(CallbackEvent(\"onCommandSkipped\", uniqueIndex, getSequence()))\n            },\n        )\n    }\n\n    private fun orchestra(\n        maestro: Maestro,\n    ) = Orchestra(\n        maestro,\n        lookupTimeoutMs = 0L,\n        optionalLookupTimeoutMs = 0L,\n    )\n\n    private fun orchestra(\n        maestro: Maestro,\n        onCommandMetadataUpdate: (MaestroCommand, Orchestra.CommandMetadata) -> Unit = { _, _ -> },\n    ) = Orchestra(\n        maestro,\n        lookupTimeoutMs = 0L,\n        optionalLookupTimeoutMs = 0L,\n        onCommandMetadataUpdate = onCommandMetadataUpdate,\n    )\n\n    private fun orchestra(\n        maestro: Maestro,\n        onCommandFailed: (Int, MaestroCommand, Throwable) -> Orchestra.ErrorResolution,\n    ) = Orchestra(\n        maestro,\n        lookupTimeoutMs = 0L,\n        optionalLookupTimeoutMs = 0L,\n        onCommandFailed = onCommandFailed,\n    )\n\n    private fun driver(builder: FakeLayoutElement.() -> Unit): FakeDriver {\n        val driver = FakeDriver()\n        driver.setLayout(FakeLayoutElement().apply { builder() })\n        driver.open()\n        return driver\n    }\n\n    @Test\n    fun `jsEngine is closed after runFlow completes`() {\n        // Given\n        val driver = driver {}\n        var closeCalled = false\n\n        Maestro(driver).use { maestro ->\n            val orchestra = Orchestra(\n                maestro,\n                lookupTimeoutMs = 0L,\n                optionalLookupTimeoutMs = 0L,\n                jsEngineFactory = { config ->\n                    val real = GraalJsEngine(platform = \"android\")\n                    object : JsEngine by real {\n                        override fun close() {\n                            closeCalled = true\n                            real.close()\n                        }\n                    }\n                },\n            )\n\n            // When\n            runBlocking {\n                orchestra.runFlow(listOf(MaestroCommand(BackPressCommand())))\n            }\n        }\n\n        // Then\n        assertThat(closeCalled).isTrue()\n    }\n\n    private fun readCommands(\n        caseName: String,\n        deviceId: String? = null,\n        shardIndex: Int? = null,\n        withEnv: () -> Map<String, String> = { emptyMap() },\n    ): List<MaestroCommand> {\n        val resource = javaClass.classLoader.getResource(\"$caseName.yaml\")\n            ?: throw IllegalArgumentException(\"File $caseName.yaml not found\")\n        val flowPath = Paths.get(resource.toURI())\n        return YamlCommandReader.readCommands(flowPath)\n            .withEnv(withEnv().withDefaultEnvVars(flowPath.toFile(), deviceId, shardIndex))\n    }\n}\n"
  },
  {
    "path": "maestro-test/src/test/kotlin/maestro/test/JsEngineTest.kt",
    "content": "package maestro.test\n\nimport com.github.tomakehurst.wiremock.client.WireMock\nimport com.github.tomakehurst.wiremock.client.WireMock.equalTo\nimport com.github.tomakehurst.wiremock.client.WireMock.equalToJson\nimport com.github.tomakehurst.wiremock.client.WireMock.get\nimport com.github.tomakehurst.wiremock.client.WireMock.okJson\nimport com.github.tomakehurst.wiremock.client.WireMock.post\nimport com.github.tomakehurst.wiremock.client.WireMock.stubFor\nimport com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo\nimport com.github.tomakehurst.wiremock.junit5.WireMockTest\nimport com.github.tomakehurst.wiremock.matching.MultipartValuePatternBuilder\nimport com.google.common.net.HttpHeaders\nimport com.google.common.truth.Truth.assertThat\nimport maestro.js.JsEngine\nimport org.junit.jupiter.api.Test\nimport java.nio.file.Files\n\n@WireMockTest\nabstract class JsEngineTest {\n\n    lateinit var engine: JsEngine\n\n    @Test\n    fun `HTTP - Make GET request`(wiremockInfo: WireMockRuntimeInfo) {\n        // Given\n        val port = wiremockInfo.httpPort\n        stubFor(\n            get(\"/json\").willReturn(\n                okJson(\n                    \"\"\"\n                        {\n                            \"message\": \"GET Endpoint\"\n                        }\n                    \"\"\".trimIndent()\n                )\n            )\n        )\n\n        val script = \"\"\"\n            var response = http.get('http://localhost:$port/json')\n            \n            json(response.body).message\n        \"\"\".trimIndent()\n\n        // When\n        val result = engine.evaluateScript(script)\n\n        // Then\n        assertThat(result.toString()).isEqualTo(\"GET Endpoint\")\n    }\n\n    @Test\n    fun `HTTP - Make GET request with headers`(wiremockInfo: WireMockRuntimeInfo) {\n        // Given\n        val port = wiremockInfo.httpPort\n        stubFor(\n            get(\"/json\")\n                .withHeader(\"Authorization\", equalTo(\"Bearer Token\"))\n                .willReturn(\n                    okJson(\n                        \"\"\"\n                            {\n                                \"message\": \"GET Endpoint with auth\"\n                            }\n                        \"\"\".trimIndent()\n                    )\n                )\n        )\n\n        val script = \"\"\"\n            var response = http.get('http://localhost:$port/json', {\n                headers: {\n                    Authorization: 'Bearer Token'\n                }\n            })\n            \n            json(response.body).message\n        \"\"\".trimIndent()\n\n        // When\n        val result = engine.evaluateScript(script)\n\n        // Then\n        assertThat(result.toString()).isEqualTo(\"GET Endpoint with auth\")\n    }\n\n    @Test\n    fun `HTTP - Make POST request`(wiremockInfo: WireMockRuntimeInfo) {\n        // Given\n        val port = wiremockInfo.httpPort\n        stubFor(\n            post(\"/json\")\n                .withRequestBody(\n                    equalToJson(\n                        \"\"\"\n                            {\n                                \"payload\": \"Value\"\n                            }\n                        \"\"\".trimIndent()\n                    )\n                )\n                .willReturn(\n                    okJson(\n                        \"\"\"\n                            {\n                                \"message\": \"POST endpoint\"\n                            }\n                        \"\"\".trimIndent()\n                    )\n                )\n        )\n\n        val script = \"\"\"\n            var response = http.post('http://localhost:$port/json', {\n                body: JSON.stringify(\n                    {\n                        payload: 'Value'\n                    }\n                )\n            })\n            \n            json(response.body).message\n        \"\"\".trimIndent()\n\n        // When\n        val result = engine.evaluateScript(script)\n\n        // Then\n        assertThat(result.toString()).isEqualTo(\"POST endpoint\")\n    }\n\n    @Test\n    fun `Allow sharing output object between scripts`() {\n        engine.evaluateScript(\"output.foo = 'foo'\")\n        val foo = engine.evaluateScript(\"output.foo\")\n        assertThat(foo.toString()).isEqualTo(\"foo\")\n    }\n\n    @Test\n    fun `Undeclared variables are falsy`() {\n        val result = engine.evaluateScript(\"!!foo\").toString()\n        assertThat(result).isEqualTo(\"false\")\n    }\n\n    @Test\n    fun `Environment variables are accessible across scopes`() {\n        engine.putEnv(\"FOO\", \"foo\")\n\n        var result = engine.evaluateScript(\"FOO\").toString()\n        assertThat(result).isEqualTo(\"foo\")\n\n        engine.enterScope()\n\n        result = engine.evaluateScript(\"FOO\").toString()\n        assertThat(result).isEqualTo(\"foo\")\n    }\n\n    @Test\n    fun `Inline environment variables are accessible across scopes`() {\n        var result = engine.evaluateScript(\"FOO\", env = mapOf(\"FOO\" to \"foo\")).toString()\n        assertThat(result).isEqualTo(\"foo\")\n\n        result = engine.evaluateScript(\"FOO\").toString()\n        assertThat(result).isEqualTo(\"foo\")\n\n        engine.enterScope()\n\n        result = engine.evaluateScript(\"FOO\").toString()\n        assertThat(result).isEqualTo(\"foo\")\n    }\n\n    @Test\n    fun `HTTP - Make GET request and check response body and headers `(wiremockInfo: WireMockRuntimeInfo) {\n        // Given\n        val port = wiremockInfo.httpPort\n        val body =\n            \"\"\"\n                {\n                    \"message\": \"GET Endpoint\"\n                }\n            \"\"\".trimIndent()\n\n        val testHeader = \"testHeader\"\n        val response = WireMock.aResponse().withStatus(200)\n            .withHeader(HttpHeaders.CONTENT_TYPE, \"application/json\")\n            .withHeader(testHeader, \"first\")\n            .withHeader(testHeader, \"second\")\n            .withBody(body)\n\n        stubFor(\n            get(\"/json\").willReturn(response)\n        )\n\n        val script = \"\"\"\n            var response = http.get('http://localhost:$port/json');\n            \n            //check body\n            var message = json(response.body).message;\n            \n            // check headers\n            var contentType = response.headers['content-type'];\n            var testHeader = response.headers['testheader'];\n            String(message + String(\" \") + contentType + String(\" \") + testHeader);\n        \"\"\".trimIndent()\n\n        // When\n        val result = engine.evaluateScript(script)\n\n        // Then\n        assertThat(result.toString()).isEqualTo(\"GET Endpoint application/json first,second\")\n    }\n\n    @Test\n    fun `HTTP - Make POST request with multipart form`(wiremockInfo: WireMockRuntimeInfo) {\n        // Given\n        val port = wiremockInfo.httpPort\n        stubFor(\n            post(\"/json\")\n                .withMultipartRequestBody(\n                    MultipartValuePatternBuilder(\"uploadType\")\n                        .withBody(equalTo(\"import\"))\n                )\n                .withMultipartRequestBody(\n                    MultipartValuePatternBuilder(\"data\")\n                )\n                .willReturn(\n                    okJson(\n                        \"\"\"\n                            {\n                                \"message\": \"POST endpoint\"\n                            }\n                        \"\"\".trimIndent()\n                    )\n                )\n        )\n\n        val script = \"\"\"\n            var response = http.post('http://localhost:$port/json', {\n                multipartForm: {\n                    \"uploadType\": \"import\",\n                    \"data\": {\n                        \"filePath\": filePath\n                    }\n                }\n            });\n\n            json(response.body).message\n        \"\"\".trimIndent()\n\n        // When\n        val result = engine.evaluateScript(script)\n\n        // Then\n        assertThat(result.toString()).isEqualTo(\"POST endpoint\")\n    }\n\n    @Test\n    fun `HTTP - Multipart form with multiple files resolves each relative to script`(wiremockInfo: WireMockRuntimeInfo) {\n        // Given: Multiple files in different locations\n        val tempDir = Files.createTempDirectory(\"maestro-test\")\n        try {\n            val scriptsDir = tempDir.resolve(\"scripts\").toFile().apply { mkdirs() }\n            val mediaDir = tempDir.resolve(\"media\").toFile().apply { mkdirs() }\n            val docsDir = tempDir.resolve(\"docs\").toFile().apply { mkdirs() }\n            \n            val imageFile = mediaDir.resolve(\"image.txt\").apply { writeText(\"image content\") }\n            val docFile = docsDir.resolve(\"doc.txt\").apply { writeText(\"doc content\") }\n            val scriptFile = scriptsDir.resolve(\"upload.js\")\n\n            val port = wiremockInfo.httpPort\n            stubFor(\n                post(\"/upload\")\n                    .withMultipartRequestBody(\n                        MultipartValuePatternBuilder(\"image\")\n                            .withBody(equalTo(\"image content\"))\n                    )\n                    .withMultipartRequestBody(\n                        MultipartValuePatternBuilder(\"document\")\n                            .withBody(equalTo(\"doc content\"))\n                    )\n                    .willReturn(okJson(\"\"\"{\"success\": true}\"\"\"))\n            )\n\n            val script = \"\"\"\n                var response = http.post('http://localhost:$port/upload', {\n                    multipartForm: {\n                        \"image\": {\n                            \"filePath\": \"../media/image.txt\",\n                            \"mediaType\": \"text/plain\"\n                        },\n                        \"document\": {\n                            \"filePath\": \"../docs/doc.txt\",\n                            \"mediaType\": \"text/plain\"\n                        }\n                    }\n                });\n                json(response.body).success\n            \"\"\".trimIndent()\n\n            // When: Upload multiple files\n            val result = engine.evaluateScript(script, sourceName = scriptFile.absolutePath)\n\n            // Then: All files should be resolved correctly\n            assertThat(result.toString()).isEqualTo(\"true\")\n        } finally {\n            tempDir.toFile().deleteRecursively()\n        }\n    }\n}\n"
  },
  {
    "path": "maestro-test/src/test/kotlin/maestro/test/RhinoJsEngineTest.kt",
    "content": "package maestro.test\n\nimport com.github.tomakehurst.wiremock.client.WireMock\nimport com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo\nimport com.google.common.net.HttpHeaders\nimport com.google.common.truth.Truth.assertThat\nimport maestro.js.RhinoJsEngine\nimport org.junit.jupiter.api.BeforeEach\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.assertThrows\nimport org.mozilla.javascript.RhinoException\n\nclass RhinoJsEngineTest : JsEngineTest() {\n\n    @BeforeEach\n    fun setUp() {\n        engine = RhinoJsEngine()\n    }\n\n    @Test\n    fun `Redefinitions of variables are not allowed`() {\n        engine.evaluateScript(\"const foo = null\")\n\n        assertThrows<RhinoException> {\n            engine.evaluateScript(\"const foo = null\")\n        }\n    }\n\n    @Test\n    fun `You can access variables across scopes`() {\n        engine.evaluateScript(\"const foo = 'foo'\")\n        assertThat(engine.evaluateScript(\"foo\")).isEqualTo(\"foo\")\n\n        engine.enterScope()\n        assertThat(engine.evaluateScript(\"foo\")).isEqualTo(\"foo\")\n    }\n\n    @Test\n    fun `Backslash and newline are not supported`() {\n        assertThrows<RhinoException> {\n            engine.setCopiedText(\"\\\\\")\n        }\n\n        assertThrows<RhinoException> {\n            engine.putEnv(\"FOO\", \"\\\\\")\n        }\n\n        engine.setCopiedText(\"\\n\")\n        engine.putEnv(\"FOO\", \"\\n\")\n\n        val result = engine.evaluateScript(\"maestro.copiedText + FOO\").toString()\n\n        assertThat(result).isEqualTo(\"\")\n    }\n\n    @Test\n    fun `parseInt returns a double representation`() {\n        val result = engine.evaluateScript(\"parseInt('1')\").toString()\n        assertThat(result).isEqualTo(\"1.0\")\n    }\n\n    @Test\n    fun `sandboxing works`() {\n        try {\n            engine.evaluateScript(\"require('fs')\")\n            assert(false)\n        } catch (e: RhinoException) {\n            assertThat(e.message).contains(\"TypeError: require is not a function, it is object. (inline-script#1)\")\n        }\n    }\n\n    @Test\n    fun `Environment variables are isolated between env scopes`() {\n        // Set a variable in the root scope\n        engine.putEnv(\"ROOT_VAR\", \"root_value\")\n        \n        // Enter new env scope and set a variable\n        engine.enterEnvScope()\n        engine.putEnv(\"SCOPED_VAR\", \"scoped_value\")\n        \n        // Both variables should be accessible in the child scope\n        assertThat(engine.evaluateScript(\"ROOT_VAR\")).isEqualTo(\"root_value\")\n        assertThat(engine.evaluateScript(\"SCOPED_VAR\")).isEqualTo(\"scoped_value\")\n        \n        // Leave the env scope\n        engine.leaveEnvScope()\n        \n        // Root variable should still be accessible\n        assertThat(engine.evaluateScript(\"ROOT_VAR\")).isEqualTo(\"root_value\")\n        \n        // Scoped variable should no longer be accessible (null in RhinoJS due to scope mechanism)\n        assertThat(engine.evaluateScript(\"SCOPED_VAR\")).isNull()\n    }\n}"
  },
  {
    "path": "maestro-test/src/test/resources/001_assert_visible_by_id.yaml",
    "content": "appId: com.example.app\n---\n- assertVisible:\n    id: \"element_id\""
  },
  {
    "path": "maestro-test/src/test/resources/002_assert_visible_by_text.yaml",
    "content": "appId: com.example.app\n---\n- assertVisible:\n    text: \"Element Text\""
  },
  {
    "path": "maestro-test/src/test/resources/003_assert_visible_by_size.yaml",
    "content": "appId: com.example.app\n---\n- assertVisible:\n    width: 100\n    height: 100"
  },
  {
    "path": "maestro-test/src/test/resources/004_assert_no_visible_element_with_id.yaml",
    "content": "appId: com.example.app\n---\n- assertVisible:\n    id: \"element_id\""
  },
  {
    "path": "maestro-test/src/test/resources/005_assert_no_visible_element_with_text.yaml",
    "content": "appId: com.example.app\n---\n- assertVisible:\n    text: \"Element Text\""
  },
  {
    "path": "maestro-test/src/test/resources/006_assert_no_visible_element_with_size.yaml",
    "content": "appId: com.example.app\n---\n- assertVisible:\n    width: 100\n    height: 100"
  },
  {
    "path": "maestro-test/src/test/resources/007_assert_visible_by_size_with_tolerance.yaml",
    "content": "appId: com.example.app\n---\n- assertVisible:\n    width: 100\n    height: 100\n    tolerance: 1"
  },
  {
    "path": "maestro-test/src/test/resources/008_tap_on_element.yaml",
    "content": "appId: com.example.app\n---\n- tapOn:\n    text: \".*button.*\""
  },
  {
    "path": "maestro-test/src/test/resources/009_skip_optional_elements.yaml",
    "content": "appId: com.example.app\n---\n- tapOn:\n    text: \"Optional Element\"\n    optional: true\n- assertVisible:\n    text: \"Non Optional\"\n    optional: false"
  },
  {
    "path": "maestro-test/src/test/resources/010_scroll.yaml",
    "content": "appId: com.example.app\n---\n- scroll"
  },
  {
    "path": "maestro-test/src/test/resources/011_back_press.yaml",
    "content": "appId: com.example.app\n---\n- back"
  },
  {
    "path": "maestro-test/src/test/resources/012_input_text.yaml",
    "content": "appId: com.example.app\n---\n- inputText: \"Hello World\"\n- inputText: user@example.com"
  },
  {
    "path": "maestro-test/src/test/resources/013_launch_app.yaml",
    "content": "appId: com.example.app\n---\n- launchApp"
  },
  {
    "path": "maestro-test/src/test/resources/014_tap_on_point.yaml",
    "content": "appId: com.example.app\n---\n- tapOn:\n    point: 100,200"
  },
  {
    "path": "maestro-test/src/test/resources/015_element_relative_position.yaml",
    "content": "appId: com.example.app\n---\n- tapOn:\n    retryTapIfNoChange: false\n    above:\n      text: \"Middle\"\n    rightOf:\n      text: \"Top Left\"\n- tapOn:\n    retryTapIfNoChange: false\n    below:\n      text: \"Middle\"\n    rightOf:\n      text: \"Bottom Left\"\n- tapOn:\n    retryTapIfNoChange: false\n    leftOf:\n      text: \"Middle\"\n    below:\n      text: \"Top Left\"\n- tapOn:\n    retryTapIfNoChange: false\n    rightOf:\n      text: \"Middle\"\n    below:\n      text: \"Top Right\"\n- tapOn:\n    retryTapIfNoChange: false\n    above:\n      text: \"Middle\"\n    leftOf:\n      text: \"Middle\"\n- tapOn:\n    retryTapIfNoChange: false\n    above:\n      text: \"Middle\"\n    rightOf:\n      text: \"Middle\"\n- tapOn:\n    retryTapIfNoChange: false\n    below:\n      text: \"Middle\"\n    leftOf:\n      text: \"Middle\"\n- tapOn:\n    retryTapIfNoChange: false\n    below:\n      text: \"Middle\"\n    rightOf:\n      text: \"Middle\""
  },
  {
    "path": "maestro-test/src/test/resources/016_multiline_text.yaml",
    "content": "appId: com.example.app\n---\n- tapOn:\n    text: \"Hello world.*\"\n    retryTapIfNoChange: false"
  },
  {
    "path": "maestro-test/src/test/resources/017_swipe.yaml",
    "content": "appId: com.example.app\n---\n- swipe:\n    start: 100,500\n    end: 100,200\n    duration: 3000"
  },
  {
    "path": "maestro-test/src/test/resources/018_contains_child.yaml",
    "content": "appId: com.example.app\n---\n- tapOn:\n    retryTapIfNoChange: false\n    containsChild:\n        text: \"Child\""
  },
  {
    "path": "maestro-test/src/test/resources/019_dont_wait_for_visibility.yaml",
    "content": "appId: com.example.app\n---\n- tapOn:\n    text: \"Button\"\n    retryTapIfNoChange: false\n    waitUntilVisible: false"
  },
  {
    "path": "maestro-test/src/test/resources/020_parse_config.yaml",
    "content": "appId: com.example.app\n---\n- launchApp\n"
  },
  {
    "path": "maestro-test/src/test/resources/021_launch_app_with_clear_state.yaml",
    "content": "appId: com.example.app\n---\n- launchApp:\n    clearState: true"
  },
  {
    "path": "maestro-test/src/test/resources/022_launch_app_that_is_not_installed.yaml",
    "content": "appId: com.example.nonexistent\n---\n- launchApp:\n    clearState: true"
  },
  {
    "path": "maestro-test/src/test/resources/025_element_relative_position_shortcut.yaml",
    "content": "appId: com.example.app\n---\n- tapOn:\n    retryTapIfNoChange: false\n    above: \"Middle\"\n    rightOf: \"Top Left\"\n- tapOn:\n    retryTapIfNoChange: false\n    below: \"Middle\"\n    rightOf: \"Bottom Left\"\n- tapOn:\n    retryTapIfNoChange: false\n    leftOf: \"Middle\"\n    below: \"Top Left\"\n- tapOn:\n    retryTapIfNoChange: false\n    rightOf: \"Middle\"\n    below: \"Top Right\"\n- tapOn:\n    retryTapIfNoChange: false\n    above: \"Middle\"\n    leftOf: \"Middle\"\n- tapOn:\n    retryTapIfNoChange: false\n    above: \"Middle\"\n    rightOf: \"Middle\"\n- tapOn:\n    retryTapIfNoChange: false\n    below: \"Middle\"\n    leftOf: \"Middle\"\n- tapOn:\n    retryTapIfNoChange: false\n    below: \"Middle\"\n    rightOf: \"Middle\""
  },
  {
    "path": "maestro-test/src/test/resources/026_assert_not_visible.yaml",
    "content": "appId: com.example.app\n---\n- assertNotVisible:\n    id: \"element_id\""
  },
  {
    "path": "maestro-test/src/test/resources/027_open_link.yaml",
    "content": "appId: com.example.app\n---\n- openLink: https://example.com"
  },
  {
    "path": "maestro-test/src/test/resources/028_env.yaml",
    "content": "appId: com.example.app\n---\n- launchApp:\n    appId: ${APP_ID}\n- tapOn:\n    id: .*${BUTTON_ID}.*\n    retryTapIfNoChange: false\n- tapOn:\n    text: .*${BUTTON_TEXT}.*\n    retryTapIfNoChange: false\n- assertVisible:\n    text: .*${BUTTON_TEXT}.*\n- assertVisible:\n    id: .*${BUTTON_ID}.*\n- assertNotVisible:\n    text: .*${NON_EXISTENT_TEXT}.*\n- assertNotVisible:\n    id: .*${NON_EXISTENT_ID}.*\n- inputText: \\${PASSWORD} is ${PASSWORD}\n- openLink: https://example.com/${URL}\n- setLocation:\n    latitude: ${LAT}\n    longitude: ${LNG}\n- startRecording: ${MAESTRO_FILENAME}\n"
  },
  {
    "path": "maestro-test/src/test/resources/029_long_press_on_element.yaml",
    "content": "appId: com.example.app\n---\n- longPressOn:\n    text: \".*button.*\""
  },
  {
    "path": "maestro-test/src/test/resources/030_long_press_on_point.yaml",
    "content": "appId: com.example.app\n---\n- longPressOn:\n    point: 100,200"
  },
  {
    "path": "maestro-test/src/test/resources/031_traits.yaml",
    "content": "appId: com.example.app\n---\n- tapOn:\n    traits: text\n    retryTapIfNoChange: false\n- tapOn:\n    traits: square\n    retryTapIfNoChange: false\n- tapOn:\n    traits: long-text\n    retryTapIfNoChange: false"
  },
  {
    "path": "maestro-test/src/test/resources/032_element_index.yaml",
    "content": "appId: com.example.app\n---\n- tapOn:\n    text: Item.*\n    index: 0\n    retryTapIfNoChange: false\n- tapOn:\n    text: Item.*\n    index: ${0 + 1}\n    retryTapIfNoChange: false"
  },
  {
    "path": "maestro-test/src/test/resources/033_int_text.yaml",
    "content": "appId: com.example.app\n---\n- tapOn: 2022"
  },
  {
    "path": "maestro-test/src/test/resources/034_press_key.yaml",
    "content": "appId: com.example.app\n---\n- pressKey: Enter\n- pressKey: Backspace\n- pressKey: Home\n- pressKey: back\n- pressKey: Volume Up\n- pressKey: volume Down\n- pressKey: lOcK\n- pressKey: remote dpad up\n- pressKey: Remote dpad Down\n- pressKey: Remote Dpad Left\n- pressKey: Remote Dpad Right\n- pressKey: remote dpad Center\n- pressKey: rEmote Media plAy Pause\n- pressKey: RemOte MediA StoP\n- pressKey: remote Media NeXt\n- pressKey: Remote Media Previous\n- pressKey: REMOTE MEDIA REWIND\n- pressKey: Remote Media Fast Forward\n- pressKey: power\n- pressKey: Tab\n- pressKey: Remote System Navigation Up\n- pressKey: Remote System Navigation Down\n- pressKey: Remote Button A\n- pressKey: Remote Button B\n- pressKey: Remote Menu\n- pressKey: TV Input\n- pressKey: TV Input HDMI 1\n- pressKey: TV Input HDMI 2\n- pressKey: TV Input HDMI 3\n\n"
  },
  {
    "path": "maestro-test/src/test/resources/035_refresh_position_ignore_duplicates.yaml",
    "content": "appId: com.example.app\n---\n- tapOn:\n    below: Item\n"
  },
  {
    "path": "maestro-test/src/test/resources/036_erase_text.yaml",
    "content": "appId: com.example.app\n---\n- inputText: Hello World\n- eraseText: 6\n"
  },
  {
    "path": "maestro-test/src/test/resources/037_unicode_input.yaml",
    "content": "appId: com.example.app\n---\n- inputText: Tést inpüt\n"
  },
  {
    "path": "maestro-test/src/test/resources/038_partial_id.yaml",
    "content": "appId: com.example.app\n---\n- tapOn:\n    id: \"keyboard_area\"\n    retryTapIfNoChange: false\n- tapOn:\n    id: \".*keyboard_area\"\n    retryTapIfNoChange: false\n"
  },
  {
    "path": "maestro-test/src/test/resources/039_hide_keyboard.yaml",
    "content": "appId: com.example.app\n---\n- hideKeyboard"
  },
  {
    "path": "maestro-test/src/test/resources/040_escape_regex.yaml",
    "content": "appId: com.example.app\n---\n- tapOn: \\+123456"
  },
  {
    "path": "maestro-test/src/test/resources/041_take_screenshot.yaml",
    "content": "appId: com.example.app\n---\n- takeScreenshot: ${MAESTRO_FILENAME}_with_filename\n"
  },
  {
    "path": "maestro-test/src/test/resources/042_extended_wait.yaml",
    "content": "appId: com.example.app\nenv:\n    TIMEOUT: 1000\n---\n- extendedWaitUntil:\n    visible: Item\n    timeout: ${TIMEOUT}\n- extendedWaitUntil:\n    notVisible: Another item\n    timeout: 1000\n- extendedWaitUntil:\n    visible: Item\n"
  },
  {
    "path": "maestro-test/src/test/resources/043_stop_app.yaml",
    "content": "appId: com.example.app\n---\n- stopApp\n- stopApp: another.app\n"
  },
  {
    "path": "maestro-test/src/test/resources/044_clear_state.yaml",
    "content": "appId: com.example.app\n---\n- clearState\n- clearState: another.app\n"
  },
  {
    "path": "maestro-test/src/test/resources/045_clear_keychain.yaml",
    "content": "appId: com.example.app\n---\n- clearKeychain\n- launchApp:\n    appId: com.example.app\n    clearKeychain: true\n"
  },
  {
    "path": "maestro-test/src/test/resources/046_run_flow.yaml",
    "content": "appId: com.other.app\n---\n- runFlow: 013_launch_app.yaml\n- tapOn: Primary button"
  },
  {
    "path": "maestro-test/src/test/resources/047_run_flow_nested.yaml",
    "content": "appId: com.other.app\n---\n- runFlow: 046_run_flow.yaml\n- tapOn: Secondary Button"
  },
  {
    "path": "maestro-test/src/test/resources/048_tapOn_clickable.yaml",
    "content": "appId: com.example.app\n---\n- tapOn: Button"
  },
  {
    "path": "maestro-test/src/test/resources/049_run_flow_conditionally.yaml",
    "content": "appId: com.other.app\n---\n- runFlow:\n    when:\n      visible: Not Clicked\n    file: 008_tap_on_element.yaml\n- assertVisible: Clicked\n- runFlow:\n    when:\n      visible: ${NOT_CLICKED}\n    file: 008_tap_on_element.yaml"
  },
  {
    "path": "maestro-test/src/test/resources/051_set_location.yaml",
    "content": "appId: com.example.app\n---\n- launchApp:\n    appId: com.example.app\n- setLocation:\n    latitude: 12.5266\n    longitude: 78.2150\n"
  },
  {
    "path": "maestro-test/src/test/resources/052_text_random.yaml",
    "content": "appId: com.example.app\n---\n- inputRandomText\n- inputRandomNumber\n- inputRandomText:\n    length: 5\n- inputRandomNumber:\n    length: 5\n- inputRandomEmail\n- inputRandomPersonName\n"
  },
  {
    "path": "maestro-test/src/test/resources/053_repeat_times.yaml",
    "content": "appId: com.other.app\n---\n- repeat:\n    times: 3\n    commands:\n      - tapOn: Button\n- assertVisible: \"3\"\n- evalScript: ${output.list = [1, 2, 3]}\n- repeat:\n    times: ${output.list.length}\n    commands:\n      - tapOn: Button\n- assertVisible: \"6\""
  },
  {
    "path": "maestro-test/src/test/resources/054_enabled.yaml",
    "content": "appId: com.other.app\n---\n- assertVisible:\n    text: Button\n    enabled: true\n- tapOn: Button\n- assertNotVisible:\n    text: Button\n    enabled: true\n- assertVisible:\n    text: Button\n    enabled: false"
  },
  {
    "path": "maestro-test/src/test/resources/055_compare_regex.yaml",
    "content": "appId: com.example.app\n---\n- tapOn:\n    text: (Secondary button)"
  },
  {
    "path": "maestro-test/src/test/resources/056_ignore_error.yaml",
    "content": "appId: com.example.app\n---\n- tapOn: Non existent text\n- repeat:\n    times: 2\n    commands:\n      - tapOn: Non existent text\n- tapOn: Button\n"
  },
  {
    "path": "maestro-test/src/test/resources/057_runFlow_env.yaml",
    "content": "appId: com.example.app\n---\n- runFlow:\n    file: 057_subflow.yaml\n    env:\n      INNER_ENV: Inner Parameter\n"
  },
  {
    "path": "maestro-test/src/test/resources/057_subflow.yaml",
    "content": "appId: com.example.app\n---\n- inputText: ${INNER_ENV}\n- inputText: ${OUTER_ENV}\n- runFlow:\n    file: 057_subflow_override.yaml\n    env:\n      INNER_ENV: Overridden Parameter\n"
  },
  {
    "path": "maestro-test/src/test/resources/057_subflow_override.yaml",
    "content": "appId: com.example.app\n---\n- inputText: ${INNER_ENV}\n"
  },
  {
    "path": "maestro-test/src/test/resources/058_inline_env.yaml",
    "content": "appId: com.example.app\nenv:\n  INLINE_ENV: Inline Parameter\n---\n- inputText: ${INLINE_ENV}\n- runFlow: 058_subflow.yaml\n"
  },
  {
    "path": "maestro-test/src/test/resources/058_subflow.yaml",
    "content": "appId: com.example.app\nenv:\n  INLINE_ENV: Overridden Parameter\n---\n- inputText: ${INLINE_ENV}\n"
  },
  {
    "path": "maestro-test/src/test/resources/059_directional_swipe_command.yaml",
    "content": "appId: com.example.app\n---\n- swipe:\n    direction: RIGHT\n    duration: 500"
  },
  {
    "path": "maestro-test/src/test/resources/060_pass_env_to_env.yaml",
    "content": "appId: com.example.app\nenv:\n  PARAM_INLINE: ${PARAM}\n  PARAM: ${PARAM}\n---\n- runFlow:\n    file: 060_subflow.yaml\n    env:\n      PARAM_FLOW: ${PARAM_INLINE}\n      PARAM: ${PARAM}"
  },
  {
    "path": "maestro-test/src/test/resources/060_subflow.yaml",
    "content": "appId: com.example.app\n---\n- inputText: ${PARAM_FLOW}\n- inputText: ${PARAM_INLINE}\n- inputText: ${PARAM}"
  },
  {
    "path": "maestro-test/src/test/resources/061_launchApp_withoutStopping.yaml",
    "content": "appId: com.example.app\n---\n- launchApp:\n    stopApp: false"
  },
  {
    "path": "maestro-test/src/test/resources/062_copy_paste_text.yaml",
    "content": "appId: com.example.app\n---\n- copyTextFrom:\n    id: \"myId\"\n- pasteText\n"
  },
  {
    "path": "maestro-test/src/test/resources/063_js_injection.yaml",
    "content": "appId: com.example.app\nenv:\n  A: 1\n  B: 2\n---\n- inputText: ${A}\n- inputText: ${B}\n- inputText: ${A + B}\n- inputText: ${parseInt(A) + parseInt(B)}\n- inputText: \\${A} \\${B} ${A} ${B}\n\n"
  },
  {
    "path": "maestro-test/src/test/resources/064_js_files.yaml",
    "content": "appId: com.example.app\n---\n- runScript: 064_script.js\n- inputText: ${output.sharedResult}\n- runFlow: 064_subflow.yaml\n- inputText: ${output.sharedResult}\n- inputText: ${output.mainFlow.result}\n- inputText: ${output.subFlow.result}\n- inputText: ${output.subFlowFileName}\n- runScript:\n    file: 064_script_with_args.js\n    env:\n      parameter: Input Parameter\n- inputText: ${output.resultWithParameters}\n- runScript:\n    file: 064_script_with_args.js\n    env:\n      parameter: ${'Evaluated Parameter'}\n- inputText: ${output.resultWithParameters}\n- inputText: ${output.fileName}\n"
  },
  {
    "path": "maestro-test/src/test/resources/064_script.js",
    "content": "output.sharedResult = 'Main'\noutput.mainFlow = {\n  result: 'Main'\n}\noutput.fileName = MAESTRO_FILENAME\n"
  },
  {
    "path": "maestro-test/src/test/resources/064_script_alt.js",
    "content": "output.sharedResult = 'Sub'\noutput.subFlow = {\n  result: 'Sub'\n}\noutput.subFlowFileName = MAESTRO_FILENAME\n"
  },
  {
    "path": "maestro-test/src/test/resources/064_script_with_args.js",
    "content": "output.resultWithParameters = 'Hello, ' + parameter + '!'\n"
  },
  {
    "path": "maestro-test/src/test/resources/064_subflow.yaml",
    "content": "appId: com.example.app\n---\n- runScript: 064_script_alt.js\n- inputText: ${output.sharedResult}\n"
  },
  {
    "path": "maestro-test/src/test/resources/065_subflow.yaml",
    "content": "appId: com.example.app\n---\n- inputText: ${name}"
  },
  {
    "path": "maestro-test/src/test/resources/065_when_true.yaml",
    "content": "appId: com.example.app\n---\n- runFlow:\n    when:\n      true: ${true}\n    env:\n      name: 'True'\n    file: 065_subflow.yaml\n- runFlow:\n    when:\n      true: ${false}\n    env:\n      name: 'False'\n    file: 065_subflow.yaml\n- runFlow:\n    when:\n      true: ${'String'}\n    env:\n      name: 'String'\n    file: 065_subflow.yaml\n- runFlow:\n    when:\n      true: ${undefined}\n    env:\n      name: 'Undefined'\n    file: 065_subflow.yaml\n- runFlow:\n    when:\n      true: ${}\n    env:\n      name: 'Empty'\n    file: 065_subflow.yaml\n- runFlow:\n    when:\n      true: ${null}\n    env:\n      name: 'Null'\n    file: 065_subflow.yaml\n- runFlow:\n    when:\n      true: ${0}\n    env:\n      name: 'Zero'\n    file: 065_subflow.yaml\n- runFlow:\n    when:\n      true: ${123}\n    env:\n      name: 'Positive Int'\n    file: 065_subflow.yaml\n- runFlow:\n    when:\n      true: \"${{field: 'value'}}\"\n    env:\n      name: 'Object'\n    file: 065_subflow.yaml\n- runFlow:\n    when:\n      true: ${[]}\n    env:\n      name: 'Array'\n    file: 065_subflow.yaml"
  },
  {
    "path": "maestro-test/src/test/resources/066_copyText_jsVar.yaml",
    "content": "appId: com.example.app\n---\n- copyTextFrom:\n    id: Field\n- inputText: ${'Hello, ' + maestro.copiedText}"
  },
  {
    "path": "maestro-test/src/test/resources/067_assertTrue_fail.yaml",
    "content": "appId: com.example.app\n---\n- assertTrue: ${1-1}"
  },
  {
    "path": "maestro-test/src/test/resources/067_assertTrue_pass.yaml",
    "content": "appId: com.example.app\n---\n- assertTrue: ${1+1}"
  },
  {
    "path": "maestro-test/src/test/resources/068_erase_all_text.yaml",
    "content": "appId: com.example.app\n---\n- inputText: Hello World\n- eraseText\n"
  },
  {
    "path": "maestro-test/src/test/resources/069_wait_for_animation_to_end.yaml",
    "content": "appId: com.example.app\n---\n- waitForAnimationToEnd:\n    timeout: 500"
  },
  {
    "path": "maestro-test/src/test/resources/070_evalScript.yaml",
    "content": "appId: com.example.app\n---\n- evalScript: ${output.number = 1 + 1}\n- evalScript: \"${output.text = 'Result is: ' + output.number}\"\n- inputText: ${output.number}\n- inputText: ${output.text}"
  },
  {
    "path": "maestro-test/src/test/resources/071_tapOnRelativePoint.yaml",
    "content": "appId: com.example.app\nenv:\n  QUARTER: 25%\n  QUARTER_FLOAT: 0.25\n---\n- tapOn:\n    point: 0%,0%\n- tapOn:\n    point: 100%,100%\n- tapOn:\n    point: 50%,50%\n- tapOn:\n    point: ${QUARTER},${QUARTER}\n- tapOn:\n    point: ${relativePoint(QUARTER_FLOAT,QUARTER_FLOAT)}\n"
  },
  {
    "path": "maestro-test/src/test/resources/072_searchDepthFirst.yaml",
    "content": "appId: com.example.app\n---\n- tapOn: \"Element\"\n"
  },
  {
    "path": "maestro-test/src/test/resources/073_handle_linebreaks.yaml",
    "content": "appId: com.example.app\n---\n- tapOn: Hello World\n- tapOn: Hello\\nWorld"
  },
  {
    "path": "maestro-test/src/test/resources/074_directional_swipe_element.yaml",
    "content": "appId: com.example.app\n---\n- swipe:\n    direction: RIGHT\n    from:\n        text: \"swiping element\"\n"
  },
  {
    "path": "maestro-test/src/test/resources/075_repeat_while.yaml",
    "content": "appId: com.other.app\n---\n- repeat:\n    while:\n      notVisible: \"Value 3\"\n    commands:\n      - tapOn: Button\n- assertVisible: \"Value 3\""
  },
  {
    "path": "maestro-test/src/test/resources/076_optional_assertion.yaml",
    "content": "appId: com.example.app\n---\n- scrollUntilVisible:\n    timeout: 1\n    element:\n        id: \"not_found\"\n    optional: true\n- assertTrue:\n    condition: \"false\"\n    optional: true\n- extendedWaitUntil:\n    visible:\n        id: \"not_found\"\n    timeout: 1\n    optional: true\n- assertVisible:\n    text: \"Button\"\n    optional: true\n"
  },
  {
    "path": "maestro-test/src/test/resources/077_env_special_characters.yaml",
    "content": "appId: com.example.app\nenv:\n    INNER: |-\n        !@#$&*()_+{}|:\"<>?[]\\\\;',./\n---\n- inputText: ${OUTER}\n- inputText: ${INNER}\n"
  },
  {
    "path": "maestro-test/src/test/resources/078_swipe_relative.yaml",
    "content": "appId: com.example.app\n---\n- swipe:\n    start: \"50%,30%\"\n    end: \"50%,60%\"\n    duration: 3000"
  },
  {
    "path": "maestro-test/src/test/resources/079_scroll_until_visible.yaml",
    "content": "appId: com.example.app\n---\n- scrollUntilVisible:\n    element:\n      text: \"Test\"\n    speed: 100\n    visibilityPercentage: 100\n    direction: DOWN\n    timeout: 10"
  },
  {
    "path": "maestro-test/src/test/resources/080_hierarchy_pruning_assert_visible.yaml",
    "content": "appId: com.example.app\n---\n- assertVisible:\n    id: \"visible_1\"\n- assertVisible:\n      id: \"visible_2\"\n- assertVisible:\n    id: \"visible_3\"\n- assertVisible:\n    id: \"visible_4\""
  },
  {
    "path": "maestro-test/src/test/resources/081_hierarchy_pruning_assert_not_visible.yaml",
    "content": "appId: com.example.app\n---\n- assertNotVisible:\n    id: \"not_visible_1\"\n- assertNotVisible:\n      id: \"not_visible_2\"\n- assertNotVisible:\n      id: \"not_visible_3\"\n- assertNotVisible:\n      id: \"not_visible_4\""
  },
  {
    "path": "maestro-test/src/test/resources/082_repeat_while_true.yaml",
    "content": "appId: com.other.app\n---\n- evalScript: ${output.value = 0}\n- repeat:\n    while:\n      true: ${output.value < 3}\n    commands:\n      - evalScript: ${output.value = output.value + 1}\n      - inputText: ${output.value}\n      - tapOn: Button\n- assertVisible: \"Value 3\""
  },
  {
    "path": "maestro-test/src/test/resources/083_assert_properties.yaml",
    "content": "appId: com.example.app\n---\n# True\n- assertVisible:\n    text: \"Field\"\n    checked: true\n- assertVisible:\n    text: \"Field\"\n    selected: true\n- assertVisible:\n    text: \"Field\"\n    enabled: true\n- assertVisible:\n    text: \"Field\"\n    focused: true\n\n- tapOn: Flip\n\n# False\n- assertNotVisible:\n    text: \"Field\"\n    checked: true\n- assertNotVisible:\n    text: \"Field\"\n    selected: true\n- assertNotVisible:\n    text: \"Field\"\n    enabled: true\n- assertNotVisible:\n    text: \"Field\"\n    focused: true"
  },
  {
    "path": "maestro-test/src/test/resources/084_open_browser.yaml",
    "content": "appId: com.example.app\n---\n- openLink:\n    link: https://example.com\n    browser: true"
  },
  {
    "path": "maestro-test/src/test/resources/085_open_link_auto_verify.yaml",
    "content": "appId: com.example.app\n---\n- openLink:\n    link: https://example.com\n    autoVerify: true"
  },
  {
    "path": "maestro-test/src/test/resources/086_launchApp_sets_all_permissions_to_allow.yaml",
    "content": "appId: com.example.app\n---\n- launchApp\n"
  },
  {
    "path": "maestro-test/src/test/resources/087_launchApp_with_all_permissions_to_deny.yaml",
    "content": "appId: com.example.app\n---\n- launchApp:\n    permissions: { all: deny }\n"
  },
  {
    "path": "maestro-test/src/test/resources/088_launchApp_with_all_permissions_to_deny_and_notification_to_allow.yaml",
    "content": "appId: com.example.app\n---\n- launchApp:\n    permissions:\n      all: deny\n      notifications: allow\n"
  },
  {
    "path": "maestro-test/src/test/resources/089_launchApp_with_sms_permission_group_to_allow.yaml",
    "content": "appId: com.example.app\n---\n- launchApp:\n    permissions:\n      sms: allow\n"
  },
  {
    "path": "maestro-test/src/test/resources/090_travel.yaml",
    "content": "appId: com.example.app\n---\n- travel:\n    points:\n      - 0.0,0.0\n      - 0.1,0.0\n      - 0.1,0.1\n      - 0.0,0.1\n    speed: 7900 # 7.9 km/s aka orbital velocity\n"
  },
  {
    "path": "maestro-test/src/test/resources/091_assert_visible_by_index.yaml",
    "content": "appId: com.example.app\n---\n- assertVisible:\n    text: Item\n    index: 0\n- assertVisible:\n    text: Item\n    index: 1\n- assertNotVisible:\n    text: Item\n    index: 2"
  },
  {
    "path": "maestro-test/src/test/resources/092_log_messages.yaml",
    "content": "appId: com.example.app\n---\n- evalScript: ${console.log('Log from evalScript')}\n- runScript: 092_script.js"
  },
  {
    "path": "maestro-test/src/test/resources/092_script.js",
    "content": "console.log('Log from runScript')"
  },
  {
    "path": "maestro-test/src/test/resources/093_js_default_value.yaml",
    "content": "appId: ${APP_ID}\nenv:\n  APP_ID: ${APP_ID || 'com.example.default'}\n---\n- launchApp"
  },
  {
    "path": "maestro-test/src/test/resources/094_runFlow_inline.yaml",
    "content": "appId: com.example.app\n---\n- runFlow:\n    env:\n      INNER_ENV: Inner Parameter\n    commands:\n      - inputText: ${INNER_ENV}\n"
  },
  {
    "path": "maestro-test/src/test/resources/095_launch_arguments.yaml",
    "content": "appId: com.example.app\nenv:\n  ARGUMENT_B: argumentB\n  BOOL_VALUE: true\n---\n- launchApp:\n    arguments:\n      argumentA: true\n      ${ARGUMENT_B}: 4\n      argumentC: 4.0\n      argumentD: \"Hello String Value ${BOOL_VALUE}\"\n"
  },
  {
    "path": "maestro-test/src/test/resources/096_platform_condition.yaml",
    "content": "appId: com.example.app\n---\n- runFlow:\n    when:\n      platform: iOS\n    commands:\n      - inputText: Hello iOS\n- runFlow:\n    when:\n      platform: ios\n    commands:\n      - inputText: Hello ios\n- runFlow:\n    when:\n      platform: Android\n    commands:\n      - inputText: Hello Android\n"
  },
  {
    "path": "maestro-test/src/test/resources/097_contains_descendants.yaml",
    "content": "appId: com.example.app\n---\n- assertVisible:\n    id: id1\n    containsDescendants:\n      - text: \"Child 1\"\n      - text: \"Child 2\"\n        enabled: false\n- assertVisible:\n    id: id2\n    containsDescendants:\n      - text: \"Child 1\"\n- assertNotVisible:\n    id: id1\n    containsDescendants:\n      - text: \"Child 1\"\n      - text: \"Child 3\"\n"
  },
  {
    "path": "maestro-test/src/test/resources/098_runScript.js",
    "content": "console.log('Log from runScript')"
  },
  {
    "path": "maestro-test/src/test/resources/098_runscript_conditionals.yaml",
    "content": "appId: com.other.app\n---\n- runScript:\n    when:\n      visible: Click me\n    file: 098_runScript.js\n- tapOn: Click me\n- assertVisible: Clicked\n- assertNotVisible: Click me\n- runScript:\n    when:\n      visible: Not Clicked\n    file: 098_runScript.js"
  },
  {
    "path": "maestro-test/src/test/resources/098_runscript_conditionals_eager.yaml",
    "content": "appId: com.other.app\n---\n- runScript:\n    when:\n      true: ${ 1 + 1 != 2 }\n      visible: Click me\n    file: 098_runScript.js\n"
  },
  {
    "path": "maestro-test/src/test/resources/099_screen_recording.yaml",
    "content": "appId: com.other.app\n---\n- startRecording: ${MAESTRO_FILENAME}\n- stopRecording\n"
  },
  {
    "path": "maestro-test/src/test/resources/100_tapOn_multiple_times.yaml",
    "content": "appId: com.other.app\n---\n- tapOn:\n    text: Button\n    repeat: 3\n    delay: 1\n    retryTapIfNoChange: false\n"
  },
  {
    "path": "maestro-test/src/test/resources/101_doubleTapOn.yaml",
    "content": "appId: com.other.app\n---\n- doubleTapOn:\n    text: Button\n    retryTapIfNoChange: false\n"
  },
  {
    "path": "maestro-test/src/test/resources/102_graaljs.yaml",
    "content": "appId: com.example.app\njsEngine: graaljs\n---\n# Note: The ?? operator is an example of an ES2020 feature and is not supported by RhinoJS\n- inputText: ${null ?? 'foo'}\n- runFlow: 102_graaljs_subflow.yaml"
  },
  {
    "path": "maestro-test/src/test/resources/102_graaljs_subflow.yaml",
    "content": "appId: com.example.app\n---\n# Still uses graaljs in a subflow based on the top-level flow config\n- inputText: ${null ?? 'bar'}\n"
  },
  {
    "path": "maestro-test/src/test/resources/103_on_flow_start_complete_hooks.yaml",
    "content": "appId: com.example.app\nonFlowStart:\n  - runScript: \"103_setup.js\"\n  - inputText: \"test1\"\nonFlowComplete:\n  - runScript: \"103_teardown.js\"\n  - inputText: \"test2\"\n---\n- tapOn:\n    point: 100,200"
  },
  {
    "path": "maestro-test/src/test/resources/103_setup.js",
    "content": "console.log('setup');"
  },
  {
    "path": "maestro-test/src/test/resources/103_teardown.js",
    "content": "console.log('teardown');"
  },
  {
    "path": "maestro-test/src/test/resources/104_on_flow_start_complete_hooks_flow_failed.yaml",
    "content": "appId: com.example.app\nonFlowStart:\n  - inputText: \"test1\"\nonFlowComplete:\n  - inputText: \"test2\"\n---\n- assertVisible:\n    id: \"element_id\""
  },
  {
    "path": "maestro-test/src/test/resources/105_on_flow_start_complete_when_js_output_set.yaml",
    "content": "appId: com.example.app\nonFlowStart:\n  - runScript: \"105_setup.js\"\nonFlowComplete:\n  - runScript: \"105_teardown.js\"\n  - evalScript: ${ console.log(output.teardown_result); }\n---\n- evalScript: ${ console.log(output.setup_result); }"
  },
  {
    "path": "maestro-test/src/test/resources/105_setup.js",
    "content": "output.setup_result = 'setup';"
  },
  {
    "path": "maestro-test/src/test/resources/105_teardown.js",
    "content": "output.teardown_result = 'teardown';"
  },
  {
    "path": "maestro-test/src/test/resources/106_on_flow_start_complete_when_js_output_set_subflows.yaml",
    "content": "appId: com.example.app\n---\n- runFlow: \"106_subflow.yaml\"\n- evalScript: ${ console.log(output.setup_subflow_result); }\n- evalScript: ${ console.log(output.teardown_subflow_result); }"
  },
  {
    "path": "maestro-test/src/test/resources/106_setup.js",
    "content": "output.setup_subflow_result = 'setup subflow';"
  },
  {
    "path": "maestro-test/src/test/resources/106_subflow.yaml",
    "content": "appId: com.example.app\nonFlowStart:\n  - runScript: \"106_setup.js\"\nonFlowComplete:\n  - runScript: \"106_teardown.js\"\n---\n- evalScript: ${ console.log('subflow'); }"
  },
  {
    "path": "maestro-test/src/test/resources/106_teardown.js",
    "content": "output.teardown_subflow_result = 'teardown subflow';"
  },
  {
    "path": "maestro-test/src/test/resources/107_define_variables_command_before_hooks.yaml",
    "content": "appId: ${MAESTRO_APP_ID}\nenv:\n  MAESTRO_APP_ID: com.example.app\nonFlowStart:\n  - launchApp:\n      appId: ${MAESTRO_APP_ID}\n---\n- evalScript: ${ console.log(MAESTRO_APP_ID); }"
  },
  {
    "path": "maestro-test/src/test/resources/108_failed_start_hook.yaml",
    "content": "appId: com.example.app\nonFlowStart:\n  - evalScript: ${ console.log('on start'); }\n  - assertTrue: ${1-1}\nonFlowComplete:\n  - evalScript: ${ console.log('on complete'); }\n---\n- evalScript: ${ console.log('main flow'); }"
  },
  {
    "path": "maestro-test/src/test/resources/109_failed_complete_hook.yaml",
    "content": "appId: com.example.app\nonFlowStart:\n  - evalScript: ${ console.log('on start'); }\nonFlowComplete:\n  - evalScript: ${ console.log('on complete'); }\n  - assertTrue: ${1-1}\n---\n- evalScript: ${ console.log('main flow'); }"
  },
  {
    "path": "maestro-test/src/test/resources/110_add_media_device.yaml",
    "content": "appId: com.example.id\n---\n- addMedia:\n  - \"./media/abc.png\""
  },
  {
    "path": "maestro-test/src/test/resources/111_add_multiple_media.yaml",
    "content": "appId: com.example.id\n---\n- addMedia:\n  - \"./media/abc.png\"\n  - \"./media/abc.png\"\n  - \"./media/abc.png\"\n"
  },
  {
    "path": "maestro-test/src/test/resources/112_scroll_until_visible_center.yaml",
    "content": "appId: com.example.app\n---\n- scrollUntilVisible:\n    centerElement: true\n    element:\n      text: \"Test\"\n    speed: 100\n    visibilityPercentage: 100\n    direction: DOWN\n    timeout: 10"
  },
  {
    "path": "maestro-test/src/test/resources/113_tap_on_element_settle_timeout.yaml",
    "content": "appId: com.example.app\n---\n- tapOn:\n    text: \"The time is.*\"\n    waitToSettleTimeoutMs: 100"
  },
  {
    "path": "maestro-test/src/test/resources/114_child_of_selector.yaml",
    "content": "appId: com.example.app\n---\n- assertVisible:\n    text: \"child_id\"\n    childOf:\n      text: \"parent_id_1\"\n- assertNotVisible:\n    text: \"child_id\"\n    childOf:\n      text: \"parent_id_3\""
  },
  {
    "path": "maestro-test/src/test/resources/115_airplane_mode.yaml",
    "content": "appId: com.example.app\n---\n- setAirplaneMode: enabled\n- setAirplaneMode: disabled\n- toggleAirplaneMode\n"
  },
  {
    "path": "maestro-test/src/test/resources/116_kill_app.yaml",
    "content": "appId: com.example.app\n---\n- killApp\n- killApp: another.app\n"
  },
  {
    "path": "maestro-test/src/test/resources/117_scroll_until_visible_speed.js",
    "content": "output.speed = {\n    slow: 40\n}\n\noutput.timeout = {\n    slow: 20000\n}\n\noutput.element = {\n    id: \"maestro\"\n}\n"
  },
  {
    "path": "maestro-test/src/test/resources/117_scroll_until_visible_speed.yaml",
    "content": "appId: com.example.app\n---\n- runScript: 117_scroll_until_visible_speed.js\n- scrollUntilVisible:\n    element:\n      id: ${output.element.id}\n    direction: DOWN\n    speed: ${output.speed.slow}\n    timeout: ${output.timeout.slow}"
  },
  {
    "path": "maestro-test/src/test/resources/118_scroll_until_visible_negative.yaml",
    "content": "appId: com.example.app\n---\n- scrollUntilVisible:\n    element:\n      id: \"maestro\"\n    direction: DOWN\n    speed: 110\n    timeout: -200"
  },
  {
    "path": "maestro-test/src/test/resources/119_retry_commands.yaml",
    "content": "appId: com.other.app\n---\n- retry:\n    maxRetries: 3\n    commands:\n      - scroll\n      - tapOn:\n          text: Button\n          waitToSettleTimeoutMs: 40\n      - scroll\n"
  },
  {
    "path": "maestro-test/src/test/resources/120_tap_on_element_retryTapIfNoChange.yaml",
    "content": "appId: com.example.app\n---\n- tapOn:\n    text: \".*button.*\"\n    retryTapIfNoChange: true"
  },
  {
    "path": "maestro-test/src/test/resources/122_pause_resume.yaml",
    "content": "appId: com.example.app\n---\n- launchApp\n- inputText: \"Test after pause resume\"\n- tapOn:\n    text: \"Button\"\n    retryTapIfNoChange: false"
  },
  {
    "path": "maestro-test/src/test/resources/123_pause_resume_preserves_js_engine.yaml",
    "content": "appId: com.example.app\n---\n# First evalScript to set up variables and functions\n- launchApp\n- evalScript: ${output.preMessage = \"Hello from pre-message\"; output.preMessage}\n- evalScript: ${output.message = \"Hello from preserved JS state!\"; output.message}\n- evalScript: ${output.getMessage = function() { return output.message; }; output.counter = 1; output.counter}\n\n# Input text using the pre-message variable\n- inputText: ${output.preMessage}\n\n# Input text using the preserved JS variable\n- inputText: ${output.message}\n\n# Pause will happen here in the test\n\n# Second evalScript to verify state preservation\n- evalScript:\n    // Verify that our variables and functions are still available\n    if (output.message !== \"Hello from preserved JS state!\") {\n      throw new Error(\"Message variable was not preserved\");\n    }\n    if (output.counter !== 1) {\n      throw new Error(\"Counter variable was not preserved\");\n    }\n    if (output.getMessage() !== \"Hello from preserved JS state!\") {\n      throw new Error(\"Function was not preserved\");\n    }\n    // Try to modify and verify the state is still mutable\n    output.counter++;\n    if (output.counter !== 2) {\n      throw new Error(\"Counter was not mutable\");\n    }\n    // Return a value to ensure the script completes successfully\n    output.counter "
  },
  {
    "path": "maestro-test/src/test/resources/124_cancellation_during_flow_execution.yaml",
    "content": "appId: com.example.app\n---\n# First set of commands that should complete\n- launchApp\n- evalScript: ${output.message = \"Hello before cancellation\"; output.message}\n- inputText: ${output.message}\n\n# These commands should be skipped when we cancel\n- evalScript: ${output.message = \"This should be skipped\"; output.message}\n- inputText: ${output.message}\n- tapOn: Button\n- assertVisible: \"Button was clicked\"\n- evalScript: ${output.message = \"This should also be skipped\"; output.message}\n- inputText: ${output.message} "
  },
  {
    "path": "maestro-test/src/test/resources/125_assert_by_css.yaml",
    "content": "url: http://example.com\n---\n- launchApp\n- assertVisible:\n    css: .test"
  },
  {
    "path": "maestro-test/src/test/resources/126_set_orientation.yaml",
    "content": "appId: com.example.app\n---\n- setOrientation: PORTRAIT\n- setOrientation: LANDSCAPE_LEFT\n- setOrientation: LANDSCAPE_RIGHT\n- setOrientation: UPSIDE_DOWN\n"
  },
  {
    "path": "maestro-test/src/test/resources/126_set_orientation_with_env.yaml",
    "content": "appId: com.example.app\nenv:\n  orientation_portrait: PORTRAIT\n  orientation_landscape_left: LANDSCAPE_LEFT\n  orientation_landscape_right: LANDSCAPE_RIGHT\n  orientation_upside_down: UPSIDE_DOWN\n---\n- setOrientation: ${orientation_portrait}\n- setOrientation: ${orientation_landscape_left}\n- setOrientation: ${orientation_landscape_right}\n- setOrientation: ${orientation_upside_down}"
  },
  {
    "path": "maestro-test/src/test/resources/127_env_vars_isolation_graaljs.yaml",
    "content": "appId: com.example.app\njsEngine: graaljs\nenv:\n  MY_VAR: 0\n---\n- runFlow:\n    env:\n      MY_VAR: 1\n    commands:\n      - assertTrue: ${MY_VAR === '1'} \n      - evalScript: ${if(MY_VAR !== '1') { throw Error } } # tests scoping for evalScript\n      - runScript:\n          file: 127_script.js\n          env:\n            MY_VAR: 3\n      - runScript:\n          file: 127_script_mutate_env_var.js            \n      - runFlow: \n          env:\n            MY_VAR: 2\n          commands:\n            - assertTrue: ${MY_VAR === '2'}\n      - assertTrue: ${MY_VAR === '1'} \n\n  \n# Second flow should NOT see first flow's variable or script variable\n- runFlow:\n    commands:\n      - assertTrue: ${MY_VAR === '0'}"
  },
  {
    "path": "maestro-test/src/test/resources/127_env_vars_isolation_rhinojs.yaml",
    "content": "appId: com.example.app\njsEngine: rhino\nenv:\n  MY_VAR: 0\n---\n- runFlow:\n    env:\n      MY_VAR: 1\n    commands:\n      - assertTrue: ${MY_VAR === '1'}\n      - evalScript: ${if(MY_VAR !== '1') { throw Error } } # tests scoping for evalScript\n      - runScript:\n          file: 127_script.js\n          env:\n            MY_VAR: 3\n      # THIS WILL FAIL. Unlike the Graal engine, the Rhino engine will persist vals set in scripts\n      # across runs. I added this line in the Graal engine to show that the Graal engine does the \n      # right thing, but I'm leaving this line and this commment to show what Rhino does _not_ support.\n      # - runScript:\n      #     file: 127_script_mutate_env_var.js\n      - runFlow:\n          env:\n            MY_VAR: 2\n          commands:\n            - assertTrue: ${MY_VAR === '2'}\n      - assertTrue: ${MY_VAR === '1'}\n\n# Second flow should NOT see first flow's variable or script variable\n- runFlow:\n    commands:\n      - assertTrue: ${MY_VAR === '0'}\n"
  },
  {
    "path": "maestro-test/src/test/resources/127_script.js",
    "content": "console.log('Log from runScript')"
  },
  {
    "path": "maestro-test/src/test/resources/127_script_mutate_env_var.js",
    "content": "MY_VAR = 4"
  },
  {
    "path": "maestro-test/src/test/resources/128_datafaker_graaljs.yaml",
    "content": "appId: com.example.app\njsEngine: graaljs\n---\n- evalScript: ${output.thisNumber = faker.expression(\"#{number.numberBetween '1' '10'}\")}\n- assertTrue: ${output.thisNumber >= 1 && output.thisNumber <= 10}"
  },
  {
    "path": "maestro-test/src/test/resources/129_text_and_id.yaml",
    "content": "appId: com.example.app\n---\n- assertVisible:\n    id: \"some_id\"\n    text: \"some_text\""
  },
  {
    "path": "maestro-test/src/test/resources/130_text_and_index.yaml",
    "content": "appId: com.example.app\n---\n- assertNotVisible:\n    text: 'some_text'\n    index: 2\n"
  },
  {
    "path": "maestro-test/src/test/resources/131_setPermissions.yaml",
    "content": "appId: com.example.app\n---\n- setPermissions:\n    permissions:\n      all: deny\n      notifications: unset"
  },
  {
    "path": "maestro-test/src/test/resources/132_repeat_while_timeout.yaml",
    "content": "appId: com.example.app\n---\n- runFlow:\n    commands:\n      - repeat:\n          label: \"Tap button while Value 0 is visible (with timeout)\"\n          while:\n            visible:\n              text: \"Value 0\"\n          commands:\n            - tapOn:\n                text: \"Button\""
  },
  {
    "path": "maestro-test/src/test/resources/133_setClipboard.yaml",
    "content": "appId: com.example.app\n---\n- setClipboard: \"Hello, Maestro!\"\n- pasteText\n"
  },
  {
    "path": "maestro-test/src/test/resources/134_take_screenshot_with_path.yaml",
    "content": "appId: com.example.app\n---\n- takeScreenshot: '134_screenshots/filename'\n"
  },
  {
    "path": "maestro-test/src/test/resources/135_screen_recording_with_path.yaml",
    "content": "appId: com.other.app\n---\n- startRecording: '135_recordings/filename'\n- stopRecording\n"
  },
  {
    "path": "maestro-test/src/test/resources/136_js_http_multi_part_requests.yaml",
    "content": "appId: xyz.blueskyweb.app\n---\n- runScript: \"./script/multipart_request_file_script.js\"\n"
  },
  {
    "path": "maestro-test/src/test/resources/137_shard_device_env_vars.yaml",
    "content": "appId: com.example.app\n---\n# Test MAESTRO_DEVICE_UDID, MAESTRO_SHARD_ID, and MAESTRO_SHARD_INDEX env vars\n- launchApp\n- takeScreenshot: ${MAESTRO_FILENAME}_${MAESTRO_DEVICE_UDID}_shard${MAESTRO_SHARD_ID}_idx${MAESTRO_SHARD_INDEX}\n"
  },
  {
    "path": "maestro-test/src/test/resources/138_take_cropped_screenshot.yaml",
    "content": "appId: com.example.app\n---\n- takeScreenshot:\n    path: 138_take_cropped_screenshot_with_filename\n    cropOn:\n      id: \"element_id\"\n"
  },
  {
    "path": "maestro-test/src/test/resources/script/multipart_request_file_script.js",
    "content": "const response = http.post('https://eu.httpbin.org/anything', {\n    multipartForm: {\n        imagefile: {\n            filePath: \"../media/abc.png\",\n        },\n        name: \"logo\"\n    }\n})\n\nconsole.log(JSON.stringify(response.body, null, 2));\n"
  },
  {
    "path": "maestro-utils/build.gradle.kts",
    "content": "import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask\n\nplugins {\n    id(\"maven-publish\")\n    alias(libs.plugins.kotlin.jvm)\n    alias(libs.plugins.mavenPublish)\n}\n\ndependencies {\n    api(libs.square.okio)\n    implementation(libs.square.okhttp)\n    implementation(libs.micrometer.core)\n    implementation(libs.micrometer.observation)\n\n    testImplementation(libs.mockk)\n    testImplementation(libs.junit.jupiter.api)\n    testRuntimeOnly(libs.junit.jupiter.engine)\n    testImplementation(libs.google.truth)\n}\n\njava {\n    sourceCompatibility = JavaVersion.VERSION_17\n    targetCompatibility = JavaVersion.VERSION_17\n}\n\nkotlin {\n    jvmToolchain {\n        languageVersion.set(JavaLanguageVersion.of(17))\n    }\n}\n\ntasks.named(\"compileKotlin\", KotlinCompilationTask::class.java) {\n    compilerOptions {\n        freeCompilerArgs.addAll(\"-Xjdk-release=17\")\n    }\n}\n\nmavenPublishing {\n    publishToMavenCentral(true)\n    signAllPublications()\n}\n\ntasks.named<Test>(\"test\") {\n    useJUnitPlatform()\n}\n"
  },
  {
    "path": "maestro-utils/gradle.properties",
    "content": "POM_NAME=Maestro Utils\nPOM_ARTIFACT_ID=maestro-utils\nPOM_PACKAGING=jar\n"
  },
  {
    "path": "maestro-utils/src/main/kotlin/Collections.kt",
    "content": "package maestro.utils\n\nimport java.io.File\nimport java.nio.file.Path\nimport kotlin.io.path.isRegularFile\n\nval Collection<File>.isSingleFile get() =\n    size == 1 && first().isDirectory().not()\n\nval Collection<Path>.isRegularFile get() =\n    size == 1 && first().isRegularFile()\n"
  },
  {
    "path": "maestro-utils/src/main/kotlin/DepthTracker.kt",
    "content": "package maestro.utils\n\nobject DepthTracker {\n\n    private var currentDepth: Int = 0\n    private var maxDepth: Int = 0\n\n    fun trackDepth(depth: Int) {\n        currentDepth = depth\n        if (currentDepth > maxDepth) {\n            maxDepth = currentDepth\n        }\n    }\n\n    fun getMaxDepth(): Int = maxDepth\n}"
  },
  {
    "path": "maestro-utils/src/main/kotlin/HttpClient.kt",
    "content": "package maestro.utils\n\nimport java.io.IOException\nimport java.util.concurrent.TimeUnit\nimport kotlin.time.Duration\nimport kotlin.time.Duration.Companion.seconds\nimport okhttp3.Call\nimport okhttp3.EventListener\nimport okhttp3.Interceptor\nimport okhttp3.OkHttpClient\nimport okhttp3.Protocol\nimport java.net.InetSocketAddress\nimport java.net.Proxy\n\nclass MetricsEventListener(\n    private val registry: Metrics,\n    private val clientName: String,\n) : EventListener() {\n\n    override fun connectFailed(\n        call: Call,\n        inetSocketAddress: InetSocketAddress,\n        proxy: Proxy,\n        protocol: Protocol?,\n        ioe: IOException\n    ) {\n        registry.counter(\n            \"http.client.errors\",\n            mapOf(\n                \"client\" to clientName,\n                \"method\" to call.request().method,\n                \"url\" to call.request().url.host,\n                \"exception\" to ioe.javaClass.simpleName,\n                \"kind\" to \"connect\"\n            )\n        ).increment()\n    }\n\n    override fun callFailed(call: Call, ioe: IOException) {\n        registry.counter(\n            \"http.client.errors\",\n            mapOf(\n                \"client\" to clientName,\n                \"method\" to call.request().method,\n                \"url\" to call.request().url.host,\n                \"exception\" to ioe.javaClass.simpleName,\n                \"kind\" to \"call\"\n            )\n        ).increment()\n    }\n\n    class Factory(\n        private val registry: Metrics,\n        private val clientName: String,\n    ) : EventListener.Factory {\n        override fun create(call: Call): EventListener =\n            MetricsEventListener(registry, clientName)\n    }\n}\n\n// utility object to build http clients with metrics\nobject HttpClient {\n    fun build(\n        name: String,\n        connectTimeout: Duration = 10.seconds,\n        readTimeout: Duration = 10.seconds,\n        writeTimeout: Duration = 10.seconds,\n        callTimeout: Duration? = null,\n        interceptors: List<Interceptor> = emptyList(),\n        networkInterceptors: List<Interceptor> = emptyList(),\n        protocols: List<Protocol> = listOf(Protocol.HTTP_1_1),\n        metrics: Metrics = MetricsProvider.getInstance()\n    ): OkHttpClient {\n        val effectiveCallTimeout = callTimeout ?: maxOf(60.seconds, readTimeout)\n        var b = OkHttpClient.Builder()\n            .eventListenerFactory(MetricsEventListener.Factory(metrics, name))\n            .connectTimeout(connectTimeout.inWholeMilliseconds, TimeUnit.MILLISECONDS)\n            .readTimeout(readTimeout.inWholeMilliseconds, TimeUnit.MILLISECONDS)\n            .writeTimeout(writeTimeout.inWholeMilliseconds, TimeUnit.MILLISECONDS)\n            .callTimeout(effectiveCallTimeout.inWholeMilliseconds, TimeUnit.MILLISECONDS)\n            .addNetworkInterceptor(Interceptor { chain ->\n                val start = System.currentTimeMillis()\n                val response = chain.proceed(chain.request())\n                val duration = System.currentTimeMillis() - start\n                metrics.timer(\n                    \"http.client.request.duration\",\n                    mapOf(\n                        \"client\" to name,\n                        \"method\" to chain.request().method,\n                        \"url\" to chain.request().url.host,\n                        \"status\" to response.code.toString()\n                    )\n                ).record(duration, TimeUnit.MILLISECONDS)\n                response\n            })\n            .protocols(protocols)\n\n        b = networkInterceptors.map { b.addNetworkInterceptor(it) }.lastOrNull() ?: b\n        b = interceptors.map { b.addInterceptor(it) }.lastOrNull() ?: b\n\n        return b.build()\n    }\n}\n"
  },
  {
    "path": "maestro-utils/src/main/kotlin/Insight.kt",
    "content": "package maestro.utils\n\nobject CliInsights: Insights {\n\n    private var insight: Insight = Insight(\"\", Insight.Level.NONE)\n    private val listeners = mutableListOf<(Insight) -> Unit>()\n\n    override fun report(insight: Insight) {\n        CliInsights.insight = insight\n        listeners.forEach { it.invoke(insight) }\n    }\n\n    override fun onInsightsUpdated(callback: (Insight) -> Unit) {\n        listeners.add(callback)\n    }\n\n    override fun unregisterListener(callback: (Insight) -> Unit) {\n        listeners.remove(callback)\n    }\n}\n"
  },
  {
    "path": "maestro-utils/src/main/kotlin/Insights.kt",
    "content": "package maestro.utils\n\ninterface Insights {\n\n    fun report(insight: Insight)\n\n    fun onInsightsUpdated(callback: (Insight) -> Unit)\n\n    fun unregisterListener(callback: (Insight) -> Unit)\n}\n\nobject NoopInsights: Insights {\n\n    override fun report(insight: Insight) {\n        /* no-op */\n    }\n\n    override fun onInsightsUpdated(callback: (Insight) -> Unit) {\n        /* no-op */\n    }\n\n    override fun unregisterListener(callback: (Insight) -> Unit) {\n        /* no-op */\n    }\n\n}\n\n\ndata class Insight(\n    val message: String,\n    val level: Level\n) {\n    enum class Level {\n        WARNING,\n        INFO,\n        NONE\n    }\n}"
  },
  {
    "path": "maestro-utils/src/main/kotlin/MaestroTimer.kt",
    "content": "package maestro.utils\n\nobject MaestroTimer {\n\n    var sleep: (Reason, Long) -> Unit = { _, ms -> Thread.sleep(ms) }\n        private set\n\n    fun setTimerFunc(sleep: (Reason, Long) -> Unit) {\n        this.sleep = sleep\n    }\n\n    fun <T> withTimeout(timeoutMs: Long, block: () -> T?): T? {\n        val endTime = System.currentTimeMillis() + timeoutMs\n\n        do {\n            val result = block()\n\n            if (result != null) {\n                return result\n            }\n        } while (System.currentTimeMillis() < endTime)\n\n        return null\n    }\n\n    fun retryUntilTrue(\n        timeoutMs: Long,\n        delayMs: Long? = null,\n        onException: (Exception) -> Unit = {},\n        block: () -> Boolean,\n    ): Boolean {\n        val endTime = System.currentTimeMillis() + timeoutMs\n        do {\n            try {\n                delayMs?.let {\n                    sleep(Reason.BUFFER, delayMs)\n                }\n                if (block()) {\n                    return true\n                }\n            } catch (ignored: Exception) {\n                onException(ignored)\n            }\n        } while (System.currentTimeMillis() < endTime)\n\n        return false\n    }\n\n    enum class Reason {\n        WAIT_UNTIL_VISIBLE,\n        WAIT_TO_SETTLE,\n        BUFFER,\n    }\n\n}\n"
  },
  {
    "path": "maestro-utils/src/main/kotlin/Metrics.kt",
    "content": "package maestro.utils\n\nimport io.micrometer.core.instrument.Counter\nimport io.micrometer.core.instrument.MeterRegistry\nimport io.micrometer.core.instrument.Tag\nimport io.micrometer.core.instrument.Timer\nimport io.micrometer.core.instrument.simple.SimpleMeterRegistry\nimport java.util.concurrent.TimeUnit\n\n\n// singleton to provide a metric manager across maestro code since there's so many singleton objects and passing it around would be a massive change\nobject MetricsProvider {\n    private var metrics: Metrics = NoOpMetrics()\n\n    fun setMetrics(metrics: Metrics) {\n        this.metrics = metrics\n    }\n\n    fun getInstance(): Metrics {\n        return metrics\n    }\n}\n\nprivate fun toTags(map: Map<String, String?>): Iterable<Tag> {\n    return map.filterValues {\n        it != null\n    }.map { Tag.of(it.key, it.value) }.toList()\n}\n\nprivate fun prefixed(prefix: String?, name: String): String {\n    if (prefix == null) {\n        return name\n    }\n    return \"$prefix.$name\"\n}\n\nopen class Metrics(\n    val registry: MeterRegistry,\n    val prefix: String? = null,\n    val tags: Map<String, String?> = emptyMap(),\n) {\n    fun <T> measured(name: String, tags: Map<String, String?> = emptyMap(), block: () -> T): T {\n        val timer = Timer.builder(prefixed(prefix, name)).tags(toTags(tags)).register(registry)\n        counter(prefixed(prefix, \"$name.calls\"), tags).increment()\n\n        val t0 = System.currentTimeMillis()\n        try {\n            return block()\n        } catch (e: Exception) {\n            registry.counter(\n                prefixed(prefix, \"$name.errors\"),\n                toTags(tags + (\"exception\" to e.javaClass.simpleName))\n            ).increment()\n            throw e\n        } finally {\n            timer.record(System.currentTimeMillis() - t0, TimeUnit.MILLISECONDS)\n        }\n    }\n\n    // get a metrics object that adds a certain prefix to all metrics\n    fun withPrefix(prefix: String): Metrics {\n        return Metrics(registry, prefixed(this.prefix, prefix))\n    }\n\n    // get a metrics object that adds labels to all metrics\n    fun withTags(tags: Map<String, String>): Metrics {\n        return Metrics(registry, prefix, this.tags + tags)\n    }\n\n    fun counter(name: String, labels: Map<String, String?> = emptyMap()): Counter {\n        return Counter.builder(prefixed(prefix, name)).tags(toTags(tags + labels)).register(registry)\n    }\n\n    fun timer(name: String, labels: Map<String, String?> = emptyMap()): Timer {\n        return Timer.builder(prefixed(prefix, name)).tags(toTags(tags + labels)).register(registry)\n    }\n}\n\nclass NoOpMetrics : Metrics(SimpleMeterRegistry(), \"noop\", emptyMap()) {\n\n}\n"
  },
  {
    "path": "maestro-utils/src/main/kotlin/SocketUtils.kt",
    "content": "/*\n *\n *  Copyright (c) 2022 mobile.dev inc.\n *\n *  Licensed under the Apache License, Version 2.0 (the \"License\");\n *  you may not use this file except in compliance with the License.\n *  You may obtain a copy of the License at\n *\n *    http://www.apache.org/licenses/LICENSE-2.0\n *\n *  Unless required by applicable law or agreed to in writing, software\n *  distributed under the License is distributed on an \"AS IS\" BASIS,\n *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n *  See the License for the specific language governing permissions and\n *  limitations under the License.\n *\n *\n */\n\npackage maestro.utils\n\nimport java.net.Inet4Address\nimport java.net.InetAddress\nimport java.net.NetworkInterface\nimport java.net.ServerSocket\nimport kotlin.random.Random\n\nobject SocketUtils {\n\n    fun nextFreePort(from: Int, to: Int): Int {\n        val mid = (to - from) / 2 + from\n        val range = Random.nextInt(from, mid)..Random.nextInt(mid, to)\n        range.forEach { port ->\n            try {\n                ServerSocket(port).use { return port }\n            } catch (ignore: Exception) {}\n        }\n        throw IllegalStateException(\"Failed to retrieve an available port\")\n    }\n\n    fun localIp(): String {\n        return NetworkInterface.getNetworkInterfaces()\n            .toList()\n            .firstNotNullOfOrNull { networkInterface ->\n                networkInterface.inetAddresses\n                    .toList()\n                    .find { inetAddress ->\n                        !inetAddress.isLoopbackAddress\n                            && inetAddress is Inet4Address\n                            && inetAddress.hostAddress.startsWith(\"192\")\n                    }\n                    ?.hostAddress\n            }\n            ?: InetAddress.getLocalHost().hostAddress\n    }\n\n}\n"
  },
  {
    "path": "maestro-utils/src/main/kotlin/Strings.kt",
    "content": "package maestro.utils\n\nfun String.chunkStringByWordCount(chunkSize: Int): List<String> {\n    val words = trim().split(\"\\\\s+\".toRegex())\n    val chunkedStrings = mutableListOf<String>()\n    var currentChunk = StringBuilder()\n\n    for (word in words) {\n        if (currentChunk.isNotEmpty()) {\n            currentChunk.append(\" \")\n        }\n        currentChunk.append(word)\n\n        if (currentChunk.toString().count { it == ' ' } + 1 == chunkSize) {\n            chunkedStrings.add(currentChunk.toString())\n            currentChunk = StringBuilder()\n        }\n    }\n\n    if (currentChunk.isNotEmpty()) {\n        chunkedStrings.add(currentChunk.toString())\n    }\n\n    return chunkedStrings\n}\n\nfun drawTextBox(text: String, maxWidth: Int): String {\n    // Ensure maxWidth is reasonable (at least 4 to fit \"╭─╮\" with at least one character)\n    val effectiveMaxWidth = maxOf(4, maxWidth)\n\n    // Calculate available content width (accounting for borders and spacing)\n    val contentMaxWidth = effectiveMaxWidth - 4 // -4 for \"│ \" and \" │\"\n\n    // Split the text by newlines first, then handle word wrapping for each paragraph\n    val paragraphs = text.split(\"\\n\")\n    val lines = mutableListOf<String>()\n\n    for (paragraph in paragraphs) {\n        // If paragraph is empty, add an empty line\n        if (paragraph.isEmpty()) {\n            lines.add(\"\")\n            continue\n        }\n\n        // Split the paragraph into words for wrapping\n        val words = paragraph.split(\" \")\n\n        var currentLine = \"\"\n        for (_word in words) {\n            val word = _word.replace(\"\\u00A0\", \" \") // Replace non-breaking spaces with regular spaces\n            // Check if word is longer than the maximum content width\n            if (word.length > contentMaxWidth) {\n                // If we have content on the current line, add it first\n                if (currentLine.isNotEmpty()) {\n                    lines.add(currentLine)\n                    currentLine = \"\"\n                }\n\n                // Split the long word into chunks\n                var remainingWord = word\n                while (remainingWord.isNotEmpty()) {\n                    val segment = remainingWord.take(contentMaxWidth)\n                    lines.add(segment)\n                    remainingWord = remainingWord.drop(contentMaxWidth)\n                }\n            } else if (currentLine.isEmpty()) {\n                // First word on the line\n                currentLine = word\n            } else if (currentLine.length + word.length + 1 <= contentMaxWidth) {\n                // Word fits on current line\n                currentLine += \" $word\"\n            } else {\n                // Word doesn't fit, start a new line\n                lines.add(currentLine)\n                currentLine = word\n            }\n        }\n\n        // Add the last line if not empty\n        if (currentLine.isNotEmpty()) {\n            lines.add(currentLine)\n        }\n    }\n\n    // Find the width of the box\n    val contentWidth = minOf(\n        contentMaxWidth,\n        (lines.maxOfOrNull { it.length } ?: 0)\n    )\n    val boxWidth = contentWidth + 2 // +2 for spacing inside the box\n\n    // Build the box\n    val result = StringBuilder()\n\n    // Top border\n    result.append(\"╭\").append(\"─\".repeat(boxWidth)).append(\"╮\\n\")\n\n    // Content lines\n    for (line in lines) {\n        result.append(\"│ \")\n        result.append(line)\n        // Padding to align right border\n        val padding = boxWidth - line.length - 1\n        if (padding > 0) {\n            result.append(\" \".repeat(padding))\n        }\n        result.append(\"│\\n\")\n    }\n\n    // Bottom border\n    result.append(\"╰\").append(\"─\".repeat(boxWidth)).append(\"╯\")\n\n    return result.toString()\n}"
  },
  {
    "path": "maestro-utils/src/main/kotlin/TempFileHandler.kt",
    "content": "package maestro.utils\n\nimport java.io.Closeable\nimport java.io.File\nimport java.nio.file.Files\n\n// creates temporary files and directories and makes sure they get disposed\nclass TempFileHandler: Closeable {\n    val tempFiles = mutableListOf<File>()\n\n    fun createTempFile(prefix: String? = null, suffix: String? = null): File {\n        val file = Files.createTempFile(prefix, suffix).toFile()\n        file.deleteOnExit()\n        tempFiles.add(file)\n        return file\n    }\n\n    fun createTempDirectory(prefix: String? = null): File {\n        val file = Files.createTempDirectory(prefix).toFile()\n        file.deleteOnExit()\n        tempFiles.add(file)\n        return file\n    }\n\n    override fun close() {\n        tempFiles.forEach {\n            try {\n                // if it's a directory, recursively clean it up\n                it.deleteRecursively()\n            } catch (_: Exception) {\n            }\n        }\n    }\n\n    // add a file for deletion\n    fun addFile(logsFile: File) {\n        tempFiles.add(logsFile)\n    }\n}"
  },
  {
    "path": "maestro-utils/src/main/kotlin/network/Errors.kt",
    "content": "package maestro.utils.network\n\nclass InputFieldNotFound : Throwable(\"Unable to find focused input field\")\nclass UnknownFailure(errorResponse: String) : Throwable(errorResponse)\n\nsealed class XCUITestServerResult<out T> {\n    data class Success<T>(val data: T): XCUITestServerResult<T>()\n    data class Failure(val errors: XCUITestServerError): XCUITestServerResult<Nothing>()\n}\n\nsealed class XCUITestServerError: Throwable() {\n    data class UnknownFailure(val errorResponse: String) : XCUITestServerError()\n    data class NetworkError(val errorResponse: String): XCUITestServerError()\n    data class AppCrash(val errorResponse: String): XCUITestServerError()\n    data class OperationTimeout(val errorResponse: String, val operation: String): XCUITestServerError()\n    data class BadRequest(val errorResponse: String, val clientMessage: String): XCUITestServerError()\n}"
  },
  {
    "path": "maestro-utils/src/test/kotlin/CollectionsTest.kt",
    "content": "import maestro.utils.isRegularFile\nimport maestro.utils.isSingleFile\nimport org.junit.jupiter.api.Assertions.assertFalse\nimport org.junit.jupiter.api.Assertions.assertTrue\nimport org.junit.jupiter.api.Test\nimport java.nio.file.Files\n\nclass CollectionsTest {\n\n    @Test\n    fun `isSingleFile should return true for a single regular file`() {\n        val file = Files.createTempFile(\"testFile\", \".txt\").toFile()\n        val files = listOf(file)\n        assertTrue(files.isSingleFile)\n        file.delete()\n    }\n\n    @Test\n    fun `isSingleFile should return false for multiple files`() {\n        val file1 = Files.createTempFile(\"testFile1\", \".txt\").toFile()\n        val file2 = Files.createTempFile(\"testFile2\", \".txt\").toFile()\n        val files = listOf(file1, file2)\n        assertFalse(files.isSingleFile)\n        file1.delete()\n        file2.delete()\n    }\n\n    @Test\n    fun `isSingleFile should return false for a single directory`() {\n        val dir = Files.createTempDirectory(\"testDir\").toFile()\n        val files = listOf(dir)\n        assertFalse(files.isSingleFile)\n        dir.delete()\n    }\n\n    @Test\n    fun `isRegularFile should return true for a single regular file`() {\n        val file = Files.createTempFile(\"testFile\", \".txt\")\n        val paths = listOf(file)\n        assertTrue(paths.isRegularFile)\n        Files.delete(file)\n    }\n\n    @Test\n    fun `isRegularFile should return false for multiple files`() {\n        val file1 = Files.createTempFile(\"testFile1\", \".txt\")\n        val file2 = Files.createTempFile(\"testFile2\", \".txt\")\n        val paths = listOf(file1, file2)\n        assertFalse(paths.isRegularFile)\n        Files.delete(file1)\n        Files.delete(file2)\n    }\n\n    @Test\n    fun `isRegularFile should return false for a single directory`() {\n        val dir = Files.createTempDirectory(\"testDir\")\n        val paths = listOf(dir)\n        assertFalse(paths.isRegularFile)\n        Files.delete(dir)\n    }\n}"
  },
  {
    "path": "maestro-utils/src/test/kotlin/DepthTrackerTest.kt",
    "content": "import maestro.utils.DepthTracker\nimport org.junit.jupiter.api.Assertions.assertEquals\nimport org.junit.jupiter.api.BeforeEach\nimport org.junit.jupiter.api.Test\n\nclass DepthTrackerTest {\n\n    @BeforeEach\n    fun setUp() {\n        DepthTracker.trackDepth(0)\n    }\n\n    @Test\n    fun `trackDepth should update currentDepth and maxDepth`() {\n        DepthTracker.trackDepth(10)\n        assertEquals(10, DepthTracker.getMaxDepth())\n    }\n\n    @Test\n    fun `getMaxDepth should return the maximum depth tracked`() {\n        DepthTracker.trackDepth(3)\n        DepthTracker.trackDepth(2)\n        DepthTracker.trackDepth(5)\n\n        assertEquals(5, DepthTracker.getMaxDepth())\n    }\n}"
  },
  {
    "path": "maestro-utils/src/test/kotlin/InsightTest.kt",
    "content": "import maestro.utils.Insight\nimport maestro.utils.CliInsights\nimport org.junit.jupiter.api.Assertions.assertEquals\nimport org.junit.jupiter.api.Assertions.assertTrue\nimport org.junit.jupiter.api.Test\n\nclass CliInsightsTest {\n\n    @Test\n    fun `report should update insight and notify listeners`() {\n        val insight = Insight(\"Test message\", Insight.Level.INFO)\n        var notifiedInsight: Insight? = null\n\n        CliInsights.onInsightsUpdated { notifiedInsight = it }\n        CliInsights.report(insight)\n\n        assertEquals(insight, notifiedInsight)\n    }\n\n    @Test\n    fun `onInsightsUpdated should add a listener`() {\n        val insight = Insight(\"Test message\", Insight.Level.INFO)\n        var notified = false\n\n        CliInsights.onInsightsUpdated { notified = true }\n        CliInsights.report(insight)\n\n        assertTrue(notified)\n    }\n\n    @Test\n    fun `unregisterListener should remove a listener`() {\n        val insight = Insight(\"Test message\", Insight.Level.INFO)\n        var notified = false\n        val listener: (Insight) -> Unit = { notified = true }\n\n        CliInsights.onInsightsUpdated(listener)\n        CliInsights.unregisterListener(listener)\n        CliInsights.report(insight)\n\n        assertTrue(!notified)\n    }\n}"
  },
  {
    "path": "maestro-utils/src/test/kotlin/MaestroTimerTest.kt",
    "content": "import maestro.utils.MaestroTimer\nimport org.junit.jupiter.api.Assertions.*\nimport org.junit.jupiter.api.BeforeEach\nimport org.junit.jupiter.api.Test\n\nclass MaestroTimerTest {\n\n    @BeforeEach\n    fun setUp() {\n        MaestroTimer.setTimerFunc { _, ms -> Thread.sleep(ms) }\n    }\n\n    @Test\n    fun `withTimeout should return result within timeout`() {\n        val result = MaestroTimer.withTimeout(1000) {\n            \"Success\"\n        }\n\n        assertEquals(\"Success\", result)\n    }\n\n    @Test\n    fun `withTimeout should return null if body is null`() {\n        val result = MaestroTimer.withTimeout(1000) {\n            null\n        }\n\n        assertNull(result)\n    }\n\n    @Test\n    fun `retryUntilTrue should return true if block succeeds within timeout`() {\n        val result = MaestroTimer.retryUntilTrue(1000) {\n            true\n        }\n\n        assertTrue(result)\n    }\n\n    @Test\n    fun `retryUntilTrue should return false if block fails within timeout`() {\n        val result = MaestroTimer.retryUntilTrue(100) {\n            false\n        }\n\n        assertFalse(result)\n    }\n\n    @Test\n    fun `retryUntilTrue should handle exceptions and continue retrying`() {\n        var attempts = 0\n        val result = MaestroTimer.retryUntilTrue(1000, 100, { }) {\n            attempts++\n            if (attempts < 3) throw Exception(\"Test exception\")\n            true\n        }\n\n        assertTrue(result)\n        assertEquals(3, attempts)\n    }\n\n    @Test\n    fun `setTimerFunc should change the sleep function`() {\n        var sleepCalled = false\n        MaestroTimer.setTimerFunc { _, _ -> sleepCalled = true }\n        MaestroTimer.sleep(MaestroTimer.Reason.BUFFER, 100)\n\n        assertTrue(sleepCalled)\n    }\n}"
  },
  {
    "path": "maestro-utils/src/test/kotlin/SocketUtilsTest.kt",
    "content": "import io.mockk.every\nimport io.mockk.mockkStatic\nimport maestro.utils.SocketUtils\nimport org.junit.jupiter.api.Assertions.assertEquals\nimport org.junit.jupiter.api.Assertions.assertNotNull\nimport org.junit.jupiter.api.Assertions.assertThrows\nimport org.junit.jupiter.api.Assertions.assertTrue\nimport org.junit.jupiter.api.Test\nimport java.net.Inet4Address\nimport java.net.InetAddress\nimport java.net.NetworkInterface\nimport java.util.*\n\nclass SocketUtilsTest {\n\n    private fun <T> List<T>.toEnumeration(): Enumeration<T> = Collections.enumeration(this)\n\n    @Test\n    fun `nextFreePort should return a free port within the specified range`() {\n        val from = 5000\n        val to = 5100\n        val port = SocketUtils.nextFreePort(from, to)\n\n        assertTrue(port in from..to)\n    }\n\n    @Test\n    fun `nextFreePort should throw IllegalStateException when no ports are available in the range`() {\n        val from = 100000\n        val to = 100010\n\n        assertThrows(IllegalStateException::class.java) {\n            SocketUtils.nextFreePort(from, to)\n        }\n    }\n\n    @Test\n    fun `localIp should return local IP address`() {\n        val ip = SocketUtils.localIp()\n\n        assertNotNull(ip)\n        assertTrue(ip.startsWith(\"192\") || ip.startsWith(\"10\") || ip.startsWith(\"172\") || ip.startsWith(\"127\"))\n        assertTrue(InetAddress.getByName(ip) is Inet4Address)\n    }\n\n    @Test\n    fun `localIp should return localhost address if no network interfaces are available`() {\n        mockkStatic(NetworkInterface::class)\n        every { NetworkInterface.getNetworkInterfaces() } returns listOf<NetworkInterface>().toEnumeration()\n\n        val ip = SocketUtils.localIp()\n\n        assertEquals(InetAddress.getLocalHost().hostAddress, ip)\n    }\n}"
  },
  {
    "path": "maestro-utils/src/test/kotlin/StringsTest.kt",
    "content": "import com.google.common.truth.Truth.assertThat\nimport maestro.utils.chunkStringByWordCount\nimport maestro.utils.drawTextBox\nimport org.junit.jupiter.api.Assertions.assertEquals\nimport org.junit.jupiter.api.Test\n\nclass StringsTest {\n\n    @Test\n    fun `chunkStringByWordCount should return empty list for empty string`() {\n        val result = \"\".chunkStringByWordCount(2)\n        assertEquals(emptyList<String>(), result)\n    }\n\n    @Test\n    fun `chunkStringByWordCount should return single chunk for string with fewer words than chunk size`() {\n        val result = \"hello world\".chunkStringByWordCount(3)\n        assertEquals(listOf(\"hello world\"), result)\n    }\n\n    @Test\n    fun `chunkStringByWordCount should return multiple chunks for string with more words than chunk size`() {\n        val result = \"hello world this is a test\".chunkStringByWordCount(2)\n        assertEquals(listOf(\"hello world\", \"this is\", \"a test\"), result)\n    }\n\n    @Test\n    fun `chunkStringByWordCount should handle exact chunk size`() {\n        val result = \"hello world this is a test\".chunkStringByWordCount(5)\n        assertEquals(listOf(\"hello world this is a\", \"test\"), result)\n    }\n\n    @Test\n    fun `chunkStringByWordCount should handle trailing spaces`() {\n        val result = \"  hello   world  \".chunkStringByWordCount(1)\n        assertEquals(listOf(\"hello\", \"world\"), result)\n    }\n\n    @Test\n    fun `chunkStringByWordCount should handle multiple spaces between words`() {\n        val result = \"hello   world this  is   a test\".chunkStringByWordCount(2)\n        assertEquals(listOf(\"hello world\", \"this is\", \"a test\"), result)\n    }\n\n    @Test\n    fun `drawTextBox simple`() {\n        assertThat(drawTextBox(\"hello\", 10)).isEqualTo(\"\"\"\n            ╭───────╮\n            │ hello │\n            ╰───────╯\n        \"\"\".trimIndent())\n    }\n\n    @Test\n    fun `drawTextBox empty`() {\n        assertThat(drawTextBox(\"\", 10)).isEqualTo(\"\"\"\n            ╭──╮\n            │  │\n            ╰──╯\n        \"\"\".trimIndent())\n    }\n\n    @Test\n    fun `drawTextBox long word`() {\n        assertThat(drawTextBox(\"reallyreallyreallyreallyreallyreallylongword\", 10)).isEqualTo(\"\"\"\n            ╭────────╮\n            │ really │\n            │ really │\n            │ really │\n            │ really │\n            │ really │\n            │ really │\n            │ longwo │\n            │ rd     │\n            ╰────────╯\n        \"\"\".trimIndent())\n    }\n\n    @Test\n    fun `drawTextBox long line`() {\n        assertThat(drawTextBox(\"really really really really really really long line\", 10)).isEqualTo(\"\"\"\n            ╭────────╮\n            │ really │\n            │ really │\n            │ really │\n            │ really │\n            │ really │\n            │ really │\n            │ long   │\n            │ line   │\n            ╰────────╯\n        \"\"\".trimIndent())\n    }\n\n    @Test\n    fun `drawTextBox single line`() {\n        assertThat(drawTextBox(\"a single line\", 50)).isEqualTo(\"\"\"\n            ╭───────────────╮\n            │ a single line │\n            ╰───────────────╯\n        \"\"\".trimIndent())\n    }\n\n    @Test\n    fun `drawTextBox multi line`() {\n        assertThat(drawTextBox(\"\"\"\n            first line\n            second line\n        \"\"\".trimIndent(), 80)).isEqualTo(\"\"\"\n            ╭─────────────╮\n            │ first line  │\n            │ second line │\n            ╰─────────────╯\n        \"\"\".trimIndent())\n    }\n\n    @Test\n    fun `drawTextBox overflow long word`() {\n        assertThat(drawTextBox(\"\"\"\n            there is a reallyreallyreallyreallylongword in this line\n        \"\"\".trimIndent(), 20)).isEqualTo(\"\"\"\n            ╭──────────────────╮\n            │ there is a       │\n            │ reallyreallyreal │\n            │ lyreallylongword │\n            │ in this line     │\n            ╰──────────────────╯\n        \"\"\".trimIndent())\n    }\n}"
  },
  {
    "path": "maestro-utils/src/test/kotlin/network/ErrorsTest.kt",
    "content": "package network\n\nimport maestro.utils.network.*\nimport org.junit.jupiter.api.Assertions.*\nimport org.junit.jupiter.api.Test\nimport org.junit.jupiter.api.assertThrows\n\nclass ErrorsTest {\n\n    @Test\n    fun `InputFieldNotFound should have correct message`() {\n        assertThrows<InputFieldNotFound>(\"Unable to find focused input field\") {\n            throw InputFieldNotFound()\n        }\n    }\n\n    @Test\n    fun `UnknownFailure should have correct message`() {\n        val errorMessage = \"An unknown error occurred\"\n\n        assertThrows<UnknownFailure>(errorMessage) {\n            throw UnknownFailure(errorMessage)\n        }\n    }\n\n    @Test\n    fun `XCUITestServerResult Success should contain data`() {\n        val data = \"Test Data\"\n        val result = XCUITestServerResult.Success(data)\n\n        assertEquals(data, result.data)\n    }\n\n    @Test\n    fun `XCUITestServerResult Failure should contain error`() {\n        val error = XCUITestServerError.UnknownFailure(\"Error\")\n        val result = XCUITestServerResult.Failure(error)\n\n        assertEquals(error, result.errors)\n    }\n\n    @Test\n    fun `XCUITestServerError UnknownFailure should have correct message`() {\n        val errorMessage = \"Unknown error\"\n\n        assertThrows<XCUITestServerError.UnknownFailure>(errorMessage) {\n            throw XCUITestServerError.UnknownFailure(errorMessage)\n        }\n    }\n\n    @Test\n    fun `XCUITestServerError NetworkError should have correct message`() {\n        val errorMessage = \"Network error\"\n\n        assertThrows<XCUITestServerError.NetworkError>(errorMessage) {\n            throw XCUITestServerError.NetworkError(errorMessage)\n        }\n    }\n\n    @Test\n    fun `XCUITestServerError AppCrash should have correct message`() {\n        val errorMessage = \"App crashed\"\n\n        assertThrows<XCUITestServerError.AppCrash>(errorMessage) {\n            throw XCUITestServerError.AppCrash(errorMessage)\n        }\n    }\n\n    @Test\n    fun `XCUITestServerError BadRequest should have correct messages`() {\n        val errorMessage = \"Bad request\"\n        val clientMessage = \"Client error\"\n\n        assertThrows<XCUITestServerError.BadRequest>(errorMessage) {\n            throw XCUITestServerError.BadRequest(errorMessage, clientMessage)\n        }\n    }\n}"
  },
  {
    "path": "maestro-web/build.gradle.kts",
    "content": "import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask\n\nplugins {\n    id(\"maven-publish\")\n    alias(libs.plugins.kotlin.jvm)\n    alias(libs.plugins.kotlin.serialization)\n    alias(libs.plugins.mavenPublish)\n}\n\ndependencies {\n    implementation(libs.square.okio)\n\n    api(libs.selenium)\n    api(libs.selenium.devtools)\n    implementation(libs.jcodec)\n    implementation(libs.jcodec.awt)\n\n    // Ktor\n    api(libs.ktor.client.core)\n    implementation(libs.ktor.client.cio)\n    implementation(libs.ktor.serial.json)\n    implementation(libs.ktor.client.content.negotiation)\n}\n\njava {\n    sourceCompatibility = JavaVersion.VERSION_17\n    targetCompatibility = JavaVersion.VERSION_17\n}\n\nkotlin {\n    jvmToolchain {\n        languageVersion.set(JavaLanguageVersion.of(17))\n    }\n}\n\ntasks.named(\"compileKotlin\", KotlinCompilationTask::class.java) {\n    compilerOptions {\n        freeCompilerArgs.addAll(\"-Xjdk-release=17\")\n    }\n}\n\nmavenPublishing {\n    publishToMavenCentral(true)\n    signAllPublications()\n}\n\ntasks.named<Test>(\"test\") {\n    useJUnitPlatform()\n    environment.put(\"PROJECT_DIR\", projectDir.absolutePath)\n}\n"
  },
  {
    "path": "maestro-web/gradle.properties",
    "content": "POM_NAME=Maestro Web\nPOM_ARTIFACT_ID=maestro-web\nPOM_PACKAGING=jar"
  },
  {
    "path": "maestro-web/src/main/kotlin/maestro/web/cdp/CdpClient.kt",
    "content": "import io.ktor.client.*\nimport io.ktor.client.engine.cio.*\nimport io.ktor.client.plugins.contentnegotiation.*\nimport io.ktor.client.plugins.websocket.*\nimport io.ktor.client.request.*\nimport io.ktor.client.statement.*\nimport io.ktor.serialization.kotlinx.json.*\nimport io.ktor.websocket.*\nimport kotlinx.coroutines.sync.Mutex\nimport kotlinx.coroutines.sync.withLock\nimport kotlinx.serialization.Serializable\nimport kotlinx.serialization.encodeToString\nimport kotlinx.serialization.json.Json\nimport kotlinx.serialization.json.JsonObject\nimport kotlinx.serialization.json.JsonPrimitive\nimport kotlinx.serialization.json.jsonObject\nimport kotlinx.serialization.json.jsonPrimitive\nimport java.io.File\nimport java.util.*\nimport java.util.concurrent.atomic.AtomicInteger\n\n/**\n * Descriptor for a CDP target (an open tab/page).\n */\n@Serializable\ndata class CdpTarget(\n    val id: String,\n    val title: String,\n    val url: String,\n    val webSocketDebuggerUrl: String? = null,\n)\n\n/**\n * A simple client for Chrome DevTools Protocol (CDP).\n *\n * Connects via HTTP to list targets and via WebSocket\n * to evaluate JS expressions with full JSON serialization.\n */\nclass CdpClient(\n    private val host: String = \"localhost\",\n    private val port: Int = 9222\n) {\n    private val httpClient = HttpClient(CIO) {\n        install(ContentNegotiation) {\n            json()\n        }\n        install(WebSockets)\n    }\n    private val json = Json { ignoreUnknownKeys = true }\n    private val idCounter = AtomicInteger(1)\n    private val evalMutex = Mutex()\n\n    /**\n     * Fetches the list of open CDP targets (tabs/pages).\n     */\n    suspend fun listTargets(): List<CdpTarget> {\n        val endpoint = \"http://$host:$port/json\"\n        val response = httpClient.get(endpoint).bodyAsText()\n\n        return json.decodeFromString(response)\n    }\n\n    /**\n     * Evaluates a JS expression on the given target, serializing the result via JSON.stringify.\n     *\n     * @param expression JS code to evaluate.\n     * @param target The CDP target descriptor.\n     * @return A JSON string of the evaluated result.\n     */\n    suspend fun evaluate(expression: String, target: CdpTarget): String {\n        val wsUrl = target.webSocketDebuggerUrl ?: error(\"Target ${target.id} has no WebSocket debugger URL\")\n\n        // The idea here is that we return JSON object as a String. That makes it much easier to handle\n        // as passing objects between JS and outside world would require many round-trips to query the values\n        // from the browser.\n        val wrapped = \"\"\"\n            JSON.stringify((() => {\n                try { return $expression }\n                catch(e) { return { __cdpError: e.toString() } }\n            })())\n        \"\"\".trimIndent()\n\n        val exprJson = Json.encodeToString(JsonPrimitive(wrapped))\n        val messageId = idCounter.getAndIncrement()\n        val payload = \"\"\"\n            {\n                \"id\":$messageId,\n                \"method\":\"Runtime.evaluate\",\n                \"params\":{\"expression\":$exprJson,\"awaitPromise\":true}\n            }\n        \"\"\".trimIndent()\n\n        return evalMutex.withLock {\n            httpClient.webSocketSession {\n                url(wsUrl)\n            }.use { session ->\n                session.send(Frame.Text(payload))\n\n                val text = session.waitForMessage(messageId)\n\n                // Parse JSON\n                val root = json.parseToJsonElement(text).jsonObject\n                val resultObj = root[\"result\"]?.jsonObject\n                    ?.get(\"result\")?.jsonObject\n                    ?: error(\"Invalid CDP response: $text\")\n\n                val raw: String = resultObj[\"value\"]?.jsonPrimitive?.content\n                    ?: \"\"\n\n                if (raw.isEmpty()) {\n                    return@use \"\"\n                }\n\n                // Check for JS error\n                val parsed = json.parseToJsonElement(raw)\n                if (parsed is JsonObject && parsed.jsonObject.containsKey(\"__cdpError\")) {\n                    val err = parsed.jsonObject[\"__cdpError\"]?.jsonPrimitive?.content\n                    error(\"JS error: $err\")\n                }\n                return@use raw\n            }\n        }\n    }\n\n    suspend fun captureScreenshot(target: CdpTarget): ByteArray {\n        val messageId = idCounter.getAndIncrement()\n\n        // Request the screenshot\n        val payload = \"\"\"\n            {\n                \"id\": $messageId,\n                \"method\": \"Page.captureScreenshot\",\n                \"params\": {\n                    \"format\": \"png\",\n                    \"quality\": 100\n                }\n            }\n        \"\"\".trimIndent()\n\n        // Open WS, send & await\n        val wsUrl = target.webSocketDebuggerUrl ?: error(\"Target ${target.id} has no WebSocket debugger URL\")\n\n        return httpClient.webSocketSession { url(wsUrl) }\n            .use { session ->\n                session.send(Frame.Text(payload))\n\n                val text = session.waitForMessage(messageId)\n\n                val data = Json.parseToJsonElement(text)\n                    .jsonObject[\"result\"]!!.jsonObject[\"data\"]!!.jsonPrimitive.content\n\n                return@use Base64.getDecoder().decode(data)\n            }\n    }\n\n    suspend fun openUrl(url: String, target: CdpTarget) {\n        // Send a CDP command to open a new tab with the specified URL\n        val messageId = idCounter.getAndIncrement()\n        val payload = \"\"\"\n            {\n                \"id\": $messageId,\n                \"method\": \"Page.navigate\",\n                \"params\": {\n                    \"url\": \"$url\"\n                }\n            }\n        \"\"\".trimIndent()\n\n        httpClient.webSocketSession { url(target.webSocketDebuggerUrl ?: error(\"Target ${target.id} has no WebSocket debugger URL\")) }\n            .use { session ->\n                session.send(Frame.Text(payload))\n\n                session.waitForMessage(messageId)\n            }\n    }\n\n    suspend fun clearDataForOrigin(origin: String, storageTypes: String, target: CdpTarget) {\n        val messageId = idCounter.getAndIncrement()\n        val originJson = Json.encodeToString(JsonPrimitive(origin))\n        val storageTypesJson = Json.encodeToString(JsonPrimitive(storageTypes))\n        val payload = \"\"\"\n            {\n                \"id\": $messageId,\n                \"method\": \"Storage.clearDataForOrigin\",\n                \"params\": {\n                    \"origin\": $originJson,\n                    \"storageTypes\": $storageTypesJson\n                }\n            }\n        \"\"\".trimIndent()\n\n        evalMutex.withLock {\n            httpClient.webSocketSession { url(target.webSocketDebuggerUrl ?: error(\"Target ${target.id} has no WebSocket debugger URL\")) }\n                .use { session ->\n                    session.send(Frame.Text(payload))\n\n                    val text = session.waitForMessage(messageId)\n                    val root = json.parseToJsonElement(text).jsonObject\n                    if (root[\"error\"] != null) {\n                        error(\"CDP error: ${root[\"error\"]}\")\n                    }\n                }\n        }\n    }\n\n    private suspend fun DefaultClientWebSocketSession.waitForMessage(messageId: Int): String {\n        for (frame in incoming) {\n            if (frame is Frame.Text) {\n                val text = frame.readText()\n                if (text.contains(\"\\\"id\\\":$messageId\")) {\n                    return text\n                }\n            }\n        }\n        error(\"No message with id $messageId received\")\n    }\n\n    private suspend fun <R> DefaultClientWebSocketSession.use(block: suspend (DefaultClientWebSocketSession) -> R): R {\n        return try {\n            block(this)\n        } finally {\n            close()\n        }\n    }\n\n}\n\nsuspend fun main() {\n    val client = CdpClient(\"localhost\", 9222)\n    val targets = client.listTargets()\n    println(\"Available pages: $targets\")\n\n    val page = targets.first()\n    val json = client.evaluate(\"1+1\", page)\n    println(\"Result: $json\")\n\n    val screenshot = client.captureScreenshot(page)\n    println(\"Screenshot captured, size: ${screenshot.size} bytes\")\n\n    // Save screenshot to file or process as needed\n    File(\"local/screenshot.png\").writeBytes(screenshot)\n}\n"
  },
  {
    "path": "maestro-web/src/main/kotlin/maestro/web/record/JcodecVideoEncoder.kt",
    "content": "package maestro.web.record\n\nimport okio.Sink\nimport okio.buffer\nimport okio.source\nimport org.jcodec.api.SequenceEncoder\nimport org.jcodec.scale.AWTUtil\nimport java.io.ByteArrayInputStream\nimport java.io.File\nimport javax.imageio.ImageIO\n\nclass JcodecVideoEncoder : VideoEncoder {\n\n    private lateinit var sequenceEncoder: SequenceEncoder\n    private lateinit var tempFile: File\n    private lateinit var out: Sink\n\n    override fun start(out: Sink) {\n        tempFile = File.createTempFile(\"maestro_jcodec\", \".mp4\")\n\n        sequenceEncoder = SequenceEncoder.create2997Fps(tempFile)\n\n        this.out = out\n    }\n\n    override fun encodeFrame(frame: ByteArray) {\n        val image = ByteArrayInputStream(frame).use { ImageIO.read(it) }\n\n        val picture = AWTUtil.fromBufferedImageRGB(image)\n\n        sequenceEncoder.encodeNativeFrame(picture)\n    }\n\n    override fun close() {\n        sequenceEncoder.finish()\n\n        try {\n            out.buffer().use {\n                it.writeAll(tempFile.source().buffer())\n            }\n        } finally {\n            tempFile.delete()\n        }\n    }\n\n}"
  },
  {
    "path": "maestro-web/src/main/kotlin/maestro/web/record/VideoEncoder.kt",
    "content": "package maestro.web.record\n\nimport okio.Sink\n\ninterface VideoEncoder : AutoCloseable {\n\n    fun start(out: Sink)\n\n    fun encodeFrame(frame: ByteArray)\n\n}"
  },
  {
    "path": "maestro-web/src/main/kotlin/maestro/web/record/WebScreenRecorder.kt",
    "content": "package maestro.web.record\n\nimport okio.Sink\nimport org.openqa.selenium.WebDriver\nimport org.openqa.selenium.devtools.HasDevTools\nimport org.openqa.selenium.devtools.v144.page.Page\nimport java.util.*\nimport java.util.concurrent.ExecutorService\nimport java.util.concurrent.Executors\nimport java.util.concurrent.TimeUnit\n\nclass WebScreenRecorder(\n    private val videoEncoder: VideoEncoder,\n    private val seleniumDriver: WebDriver\n) : AutoCloseable {\n\n    private val screenRecordingSessions = mutableListOf<AutoCloseable>()\n    private lateinit var recordingExecutor: ExecutorService\n\n    private var closed = false\n\n    fun startScreenRecording(out: Sink) {\n        ensureNotClosed()\n\n        recordingExecutor = Executors.newSingleThreadExecutor()\n        videoEncoder.start(out)\n\n        startScreenRecordingForCurrentWindow()\n    }\n\n    fun onWindowChange() {\n        if (closed) {\n            return\n        }\n\n        startScreenRecordingForCurrentWindow()\n    }\n\n    override fun close() {\n        if (closed) {\n            return\n        }\n        closed = true\n\n        closeScreenRecordingSessions()\n\n        recordingExecutor.shutdown()\n        recordingExecutor.awaitTermination(2, TimeUnit.MINUTES)\n\n        videoEncoder.close()\n    }\n\n    private fun startScreenRecordingForCurrentWindow() {\n        closeScreenRecordingSessions()\n\n        val driver = seleniumDriver as HasDevTools\n\n        val seleniumDevTools = driver.devTools\n\n        seleniumDevTools.createSessionIfThereIsNotOne()\n\n        seleniumDevTools.send(Page.enable(Optional.of(false)))\n\n        seleniumDevTools.send(\n            Page.startScreencast(\n                Optional.of(Page.StartScreencastFormat.JPEG),\n                Optional.of(80),\n                Optional.of(1280),\n                Optional.of(1280),\n                Optional.of(1)\n            )\n        )\n\n        seleniumDevTools.addListener(Page.screencastFrame()) { frame ->\n            recordingExecutor.submit {\n                val imageData = frame.data\n                val imageBytes = Base64.getDecoder().decode(imageData)\n\n                videoEncoder.encodeFrame(imageBytes)\n\n                seleniumDevTools.send(Page.screencastFrameAck(frame.sessionId))\n            }\n        }\n\n        val session = AutoCloseable { seleniumDevTools.send(Page.stopScreencast()) }\n        screenRecordingSessions.add(session)\n    }\n\n    private fun closeScreenRecordingSessions() {\n        screenRecordingSessions.forEach {\n            it.close()\n        }\n        screenRecordingSessions.clear()\n    }\n\n    private fun ensureNotClosed() {\n        if (closed) {\n            error(\"Screen recorder is already closed\")\n        }\n    }\n\n}"
  },
  {
    "path": "maestro-web/src/main/kotlin/maestro/web/selenium/ChromeSeleniumFactory.kt",
    "content": "package maestro.web.selenium\n\nimport org.openqa.selenium.WebDriver\nimport org.openqa.selenium.chrome.ChromeDriver\nimport org.openqa.selenium.chrome.ChromeDriverService\nimport org.openqa.selenium.chrome.ChromeOptions\nimport org.openqa.selenium.chromium.ChromiumDriverLogLevel\nimport java.util.logging.Level\nimport java.util.logging.Logger\n\nclass ChromeSeleniumFactory(\n    private val isHeadless: Boolean,\n    private val screenSize: String?\n) : SeleniumFactory {\n\n    override fun create(): WebDriver {\n        System.setProperty(\"webdriver.chrome.silentOutput\", \"true\")\n        System.setProperty(ChromeDriverService.CHROME_DRIVER_SILENT_OUTPUT_PROPERTY, \"true\")\n        Logger.getLogger(\"org.openqa.selenium\").level = Level.OFF\n        Logger.getLogger(\"org.openqa.selenium.devtools.CdpVersionFinder\").level = Level.OFF\n\n        val driverService = ChromeDriverService.Builder()\n            .withLogLevel(ChromiumDriverLogLevel.OFF)\n            .build()\n\n        return ChromeDriver(\n            driverService,\n            ChromeOptions().apply {\n                addArguments(\"--remote-allow-origins=*\")\n                addArguments(\"--disable-search-engine-choice-screen\")\n                addArguments(\"--lang=en\")\n\n                // Disable password management\n                addArguments(\"--password-store=basic\")\n                val chromePrefs = hashMapOf<String, Any>(\n                    \"credentials_enable_service\" to false,\n                    \"profile.password_manager_enabled\" to false,\n                    \"profile.password_manager_leak_detection\" to false   // important one\n                )\n                setExperimentalOption(\"prefs\", chromePrefs)\n\n                if (isHeadless) {\n                    addArguments(\"--headless=new\")\n\n                    if(screenSize != null){\n                        addArguments(\"--window-size=\" + screenSize.replace('x',','))\n                    }\n                    else{\n                        addArguments(\"--window-size=1024,768\")\n                    }\n\n                    setExperimentalOption(\"detach\", true)\n                }\n            }\n        )\n    }\n\n}"
  },
  {
    "path": "maestro-web/src/main/kotlin/maestro/web/selenium/SeleniumFactory.kt",
    "content": "package maestro.web.selenium\n\nimport org.openqa.selenium.WebDriver\n\ninterface SeleniumFactory {\n\n    fun create(): WebDriver\n\n}"
  },
  {
    "path": "scripts/install.sh",
    "content": "#!/bin/bash\n# Inspired by Sdkman setup script\n\nwhich_maestro=$(which maestro)\nif [[ \"$which_maestro\" == \"/usr/local\"* || $which_maestro == \"/opt/homebrew\"* || $which_maestro == \"/home/linuxbrew\"* ]]; then\n  echo \"Your maestro installation is already managed by a homebrew\"\n  echo \"\"\n  echo \"Update to the latest version with:\"\n  echo \"\"\n  echo \"    brew upgrade maestro\"\n  echo \"\"\n  echo \"Or delete brew installation with:\"\n  echo \"\"\n  echo \"    brew uninstall maestro\"\n  echo \"\"\n  echo \"Then re-run this script.\"\n  exit 1\nfi\n\nif ! command -v java > /dev/null; then\n\techo \"java not found.\"\n\techo \"======================================================================================================\"\n\techo \" Please install java on your system using your favourite package manager.\"\n\techo \"\"\n\techo \" Restart after installing java.\"\n\techo \"======================================================================================================\"\n\techo \"\"\n\texit 1\nfi\n\nif ! command -v unzip > /dev/null; then\n\techo \"unzip not found.\"\n\techo \"======================================================================================================\"\n\techo \" Please install unzip on your system using your favourite package manager.\"\n\techo \"\"\n\techo \" Restart after installing unzip.\"\n\techo \"======================================================================================================\"\n\techo \"\"\n\texit 1\nfi\n\nif ! command -v curl > /dev/null; then\n\techo \"curl not found.\"\n\techo \"\"\n\techo \"======================================================================================================\"\n\techo \" Please install curl on your system using your favourite package manager.\"\n\techo \"\"\n\techo \" Restart after installing curl.\"\n\techo \"======================================================================================================\"\n\techo \"\"\n\texit 1\nfi\n\nif [ -z \"$MAESTRO_DIR\" ]; then\n    MAESTRO_DIR=\"$HOME/.maestro\"\n    MAESTRO_BIN_DIR_RAW='$HOME/.maestro/bin'\nelse\n    MAESTRO_BIN_DIR_RAW=\"$MAESTRO_DIR/bin\"\nfi\nexport MAESTRO_DIR\n\n# Local variables\nmaestro_tmp_folder=\"${MAESTRO_DIR}/tmp\"\nmaestro_bash_profile=\"${HOME}/.bash_profile\"\nmaestro_bashrc=\"${HOME}/.bashrc\"\nmaestro_zshrc=\"${ZDOTDIR:-${HOME}}/.zshrc\"\n\n# OS specific support (must be 'true' or 'false').\ncygwin=false;\ndarwin=false;\nsolaris=false;\nfreebsd=false;\ncase \"$(uname)\" in\n    CYGWIN*)\n        cygwin=true\n        ;;\n    Darwin*)\n        darwin=true\n        ;;\n    SunOS*)\n        solaris=true\n        ;;\n    FreeBSD*)\n        freebsd=true\nesac\n\necho \"* Create distribution directories...\"\nmkdir -p \"$maestro_tmp_folder\"\n\n\nif [ -z \"$MAESTRO_VERSION\" ]; then\n    download_url=\"https://github.com/mobile-dev-inc/maestro/releases/latest/download/maestro.zip\"\nelse\n    download_url=\"https://github.com/mobile-dev-inc/maestro/releases/download/cli-$MAESTRO_VERSION/maestro.zip\"\nfi\n\nmaestro_zip_file=\"${maestro_tmp_folder}/maestro.zip\"\necho \"* Downloading...\"\ncurl --fail --location --progress-bar \"$download_url\" > \"$maestro_zip_file\"\n\necho \"* Checking archive integrity...\"\nARCHIVE_OK=$(unzip -qt \"$maestro_zip_file\" | grep 'No errors detected in compressed data')\nif [[ -z \"$ARCHIVE_OK\" ]]; then\n\techo \"Downloaded zip archive is corrupt. Are you connected to the internet?\"\n\texit\nfi\n\n# Extract archive\necho \"* Extracting archive...\"\nif [[ \"$cygwin\" == 'true' ]]; then\n\tmaestro_tmp_folder=$(cygpath -w \"$maestro_tmp_folder\")\n\tmaestro_zip_file=$(cygpath -w \"$maestro_zip_file\")\nfi\nunzip -qo \"$maestro_zip_file\" -d \"$maestro_tmp_folder\"\n\n# Empty destinations\necho \"* Remove previous installation (if any)\"\nif [[ -d \"$MAESTRO_DIR/lib\" ]]; then\n  rm -rf \"${MAESTRO_DIR:?}/lib\"\nfi\nif [[ -d \"$MAESTRO_DIR/bin\" ]]; then\n  rm -rf \"${MAESTRO_DIR:?}/bin\"\nfi\n\n# Copy in place\necho \"* Copying archive contents...\"\ncp -rf \"${maestro_tmp_folder}\"/maestro/* \"$MAESTRO_DIR\"\n\n# Clean up\necho \"* Cleaning up...\"\nrm -rf \"$maestro_tmp_folder\"/maestro\nrm -rf \"$maestro_zip_file\"\n\necho \"\"\n\n# Installing\nif [[ $darwin == true ]]; then\n  touch \"$maestro_bash_profile\"\n  if ! command -v maestro > /dev/null; then\n    echo \"Adding maestro to your PATH in $maestro_bash_profile\"\n    echo 'export PATH=$PATH:'\"$MAESTRO_BIN_DIR_RAW\" >> \"$maestro_bash_profile\"\n  fi\nelse\n  echo \"Attempt update of interactive bash profile on regular UNIX...\"\n  touch \"${maestro_bashrc}\"\n  if ! command -v maestro > /dev/null; then\n    echo \"Adding maestro to your PATH in $maestro_bashrc\"\n    echo 'export PATH=$PATH:'\"$MAESTRO_BIN_DIR_RAW\" >> \"$maestro_bashrc\"\n  fi\nfi\n\ntouch \"$maestro_zshrc\"\nif ! command -v maestro > /dev/null; then\n  echo \"Adding maestro to your PATH in $maestro_zshrc\"\n  echo 'export PATH=$PATH:'\"$MAESTRO_BIN_DIR_RAW\" >> \"$maestro_zshrc\"\nfi\n\necho \"\"\necho \"Installation was successful!\"\necho \"Please open a new terminal OR run the following in the existing one:\"\necho \"\"\necho \"    export PATH=\\\"\\$PATH\\\":\\\"$MAESTRO_BIN_DIR_RAW\\\"\"\necho \"\"\necho \"Then run the following command:\"\necho \"\"\necho \"    maestro\"\necho \"\"\necho \"Welcome to Maestro!\""
  },
  {
    "path": "settings.gradle.kts",
    "content": "rootProject.name = \"maestro\"\n\npluginManagement {\n    repositories {\n        google()\n        mavenCentral()\n        gradlePluginPortal()\n    }\n}\n\ndependencyResolutionManagement {\n    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)\n    repositories {\n        google()\n        mavenCentral()\n    }\n}\n\n// Configure Source Control for forked kotlin-sdk\nsourceControl {\n    gitRepository(uri(\"https://github.com/steviec/kotlin-sdk.git\")) {\n        producesModule(\"io.modelcontextprotocol:kotlin-sdk\")\n    }\n}\n\ninclude(\"maestro-utils\")\ninclude(\"maestro-android\")\ninclude(\"maestro-cli\")\ninclude(\"maestro-client\")\ninclude(\"maestro-ios\")\ninclude(\"maestro-ios-driver\")\ninclude(\"maestro-orchestra\")\ninclude(\"maestro-orchestra-models\")\ninclude(\"maestro-orchestra-proto\")\ninclude(\"maestro-proto\")\ninclude(\"maestro-studio:server\")\ninclude(\"maestro-studio:web\")\ninclude(\"maestro-test\")\ninclude(\"maestro-ai\")\ninclude(\"maestro-web\")\ninclude(\":maestro-client\")\ninclude(\":maestro-driver-ios\")\ninclude(\":maestro-orchestra\")\ninclude(\":maestro-studio\")\ninclude(\":maestro-test\")\ninclude(\":maestro-xcuitest-driver\")\n"
  },
  {
    "path": "tmp.sh",
    "content": "#!/usr/bin/env bash\n\nset -e\n\nif [ -t 0 ]; then\n  input=\"\"\nelse\n  input=$(cat -)\nfi\n\necho \"Hello $input\""
  }
]